diff --git a/README.md b/README.md index 79e6840..f64d17f 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ npm run visual:ci - PRD: `_bmad-output/planning-artifacts/main/prd.md` - 项目设置: `_bmad-output/planning-artifacts/main/PROJECT_SETUP.md` +- 帮助文档: [`docs/help/choose-extra-paths-or-knowledge-injection.md`](docs/help/choose-extra-paths-or-knowledge-injection.md) ## 环境要求 diff --git a/docs/help/choose-extra-paths-or-knowledge-injection.md b/docs/help/choose-extra-paths-or-knowledge-injection.md new file mode 100644 index 0000000..8c0a3f5 --- /dev/null +++ b/docs/help/choose-extra-paths-or-knowledge-injection.md @@ -0,0 +1,172 @@ +# Choose Extra Paths Or Knowledge Injection + +When you want to bring more knowledge into ClawScope, there are two common options: + +- **Extra paths** +- **Knowledge Injection** + +They are related, but they do different jobs. + +The short version: + +- Use **Extra paths** when you want ClawScope to search an external folder. +- Use **Knowledge Injection** when you want a specific node to absorb a piece of knowledge into its own memory. + +## Use extra paths when you want searchable external knowledge + +**Extra paths** belong to the **Memory** module. + +They tell ClawScope: + +> "Also include this folder when you build recall and search." + +Use extra paths when: + +- your team already has a shared folder of Markdown notes, playbooks, or reference docs +- you want those files to become searchable +- you do **not** want to rewrite a node's own memory +- you want to keep the original files where they are + +Think of **Extra paths** as: + +- attaching an external knowledge shelf +- expanding where search can look +- managing retrieval sources, not editing memory truth + +Extra paths are a good fit for: + +- team playbooks +- shared product notes +- reusable troubleshooting guides +- read-only knowledge directories + +Extra paths are **not** a good fit for: + +- runtime cache folders +- `sessions/` or `qmd/` directories +- SQLite stores +- content that should become part of a node's own managed memory + +## Use Knowledge Injection when you want the node to remember it + +**Knowledge Injection** belongs to the **Evolution** module. + +It tells ClawScope: + +> "Take this structured knowledge package and write it into the selected node's memory." + +Use Knowledge Injection when: + +- you want one node to permanently learn a rule, workflow, or role-specific context +- you want to preview the change before applying it +- you want the action to appear in history and audit trails +- you want rollback support + +Think of **Knowledge Injection** as: + +- writing managed knowledge into the node +- turning a knowledge package into part of that node's memory state +- running a governed change, not just adding another search source + +Knowledge Injection is a good fit for: + +- operating rules +- role instructions +- domain-specific procedures +- curated summaries distilled from larger documents + +## What is the difference? + +| Question | Extra paths | Knowledge Injection | +|---|---|---| +| What does it do? | Adds an external folder to recall and search | Writes a managed knowledge block into the selected node's memory | +| Which module owns it? | **Memory** | **Evolution** | +| Does it change the node's memory content? | No | Yes | +| Does it need preview / execute / rollback? | No | Yes | +| What is it best for? | Shared searchable documents | Node-specific long-term knowledge | +| What should you expect? | "Search can see this folder" | "This node now remembers this knowledge" | + +## Where they overlap + +Both features can improve later search results. + +That is where the overlap ends. + +They do **not** work the same way: + +- **Extra paths** change the search scope +- **Knowledge Injection** changes the node's memory content + +At the moment, ClawScope does **not** automatically sync the two: + +- adding an extra path does **not** create a knowledge injection task +- running a knowledge injection does **not** automatically add its source folder to extra paths + +## Recommended workflow + +If you are not sure which one to use, start here: + +1. Put the full reference material in a shared folder. +2. Add that folder through **Extra paths** so it becomes searchable. +3. Watch how useful the material is in practice. +4. If part of it should become durable node knowledge, turn the key parts into a smaller knowledge package. +5. Apply that package with **Knowledge Injection**. + +This gives you a clean split: + +- the full source stays external +- the most important rules get written into the node's memory + +## Avoid duplicate knowledge on both sides + +You can use both features together, but avoid copying the same long document into both places unchanged. + +If you: + +- add the full document through **Extra paths** +- and inject the same full document into node memory + +you may end up with duplicated recall hits and harder-to-read search results. + +A better pattern is: + +- keep the full document in **Extra paths** +- inject only the distilled summary, rules, or final operating guidance + +## FAQ + +### Does Knowledge Injection write to `MEMORY.md`? + +Yes, in most cases it does. + +More precisely, **Knowledge Injection** writes to the node's **root memory document**: + +- ClawScope uses `MEMORY.md` first +- if `MEMORY.md` is not available, it falls back to `memory.md` + +This means Knowledge Injection does **not**: + +- write into an extra path folder +- create a separate external knowledge database entry +- store the knowledge only as a search source + +Instead, it appends a managed knowledge block into the selected node's root memory document, and that content later becomes searchable through the normal memory indexing flow. + +## Quick decision guide + +Choose **Extra paths** if your question is: + +- "How do I make this folder searchable?" +- "How do I let multiple nodes search the same knowledge base?" +- "How do I include shared docs without editing node memory?" + +Choose **Knowledge Injection** if your question is: + +- "How do I make this node remember this?" +- "How do I apply a knowledge package with preview and rollback?" +- "How do I turn a source document into managed node memory?" + +## One-line summary + +- If you want ClawScope to **search it**, use **Extra paths**. +- If you want a node to **remember it**, use **Knowledge Injection**. 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/src/evolution/commands.rs b/src-tauri/src/evolution/commands.rs index cdc47e6..bf9539b 100644 --- a/src-tauri/src/evolution/commands.rs +++ b/src-tauri/src/evolution/commands.rs @@ -331,7 +331,7 @@ pub async fn evolution_history_list( let mut history = load_history(&store_paths).map_err(|error| GatewayErrorSummary::from_error(&error))?; history.retain(|entry| entry.agent_id == agent_id); - history.sort_by(|left, right| right.created_at_ms.cmp(&left.created_at_ms)); + history.sort_by_key(|entry| std::cmp::Reverse(entry.created_at_ms)); Ok(history) } @@ -343,7 +343,7 @@ pub async fn evolution_audit_summary( let mut audit = load_audit(&store_paths).map_err(|error| GatewayErrorSummary::from_error(&error))?; audit.retain(|entry| entry.agent_id == agent_id); - audit.sort_by(|left, right| right.ended_at_ms.cmp(&left.ended_at_ms)); + audit.sort_by_key(|entry| std::cmp::Reverse(entry.ended_at_ms)); Ok(summarize_audit_entries(agent_id, audit)) } @@ -2161,11 +2161,7 @@ fn summarize_audit_entries( last_7d_operations, last_7d_failures, last_7d_overrides, - average_duration_ms: if duration_count > 0 { - Some(duration_total / duration_count) - } else { - None - }, + average_duration_ms: duration_total.checked_div(duration_count), status_breakdown: status_breakdown .into_iter() .map(|(key, count)| EvolutionMetricBucket { key, count }) 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..fef7395 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, @@ -372,10 +463,11 @@ pub async fn gateway_config_schema_lookup( #[tauri::command] pub async fn gateway_config_set_local( state: State<'_, GatewayAppState>, + session_id: Option, key: String, value: String, ) -> Result { - connector::config_set_local(state.inner().clone(), &key, &value) + connector::config_set_local(state.inner().clone(), session_id.as_deref(), &key, &value) .await .map_err(|error| GatewayErrorSummary::from_error(&error)) } @@ -617,3 +709,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/connector.rs b/src-tauri/src/gateway/connector.rs index 5b8bbf1..f385d7e 100644 --- a/src-tauri/src/gateway/connector.rs +++ b/src-tauri/src/gateway/connector.rs @@ -1323,21 +1323,14 @@ fn is_config_schema_path_not_found(error: &GatewayError) -> bool { pub async fn config_set_local( state: GatewayAppState, + session_selector: Option<&str>, key: &str, value: &str, ) -> Result { - let endpoint = state - .session() - .await - .map(|session| session.endpoint.transport) - .ok_or_else(|| GatewayError::Transport { - message: "gateway not connected".to_string(), - })?; - if endpoint != GatewayTransportKind::LocalLoopback { - return Err(GatewayError::NotImplemented { - feature: "local-only config.set bridge for remote gateway sessions".to_string(), - }); - } + ensure_local_only_transport( + state.session_for_selector(session_selector).await.map(|session| session.endpoint.transport), + "local-only config.set bridge for remote gateway sessions", + )?; let trimmed_key = key.trim(); if trimmed_key.is_empty() { @@ -1375,6 +1368,22 @@ pub async fn config_set_local( }) } +fn ensure_local_only_transport( + transport: Option, + feature: &str, +) -> Result<(), GatewayError> { + let transport = transport.ok_or_else(|| GatewayError::Transport { + message: "gateway not connected".to_string(), + })?; + if transport != GatewayTransportKind::LocalLoopback { + return Err(GatewayError::NotImplemented { + feature: feature.to_string(), + }); + } + + Ok(()) +} + pub async fn agent_workspace_identity_set( state: GatewayAppState, session_selector: Option<&str>, @@ -4692,7 +4701,10 @@ fn spawn_connection_reader( let _ = connection.writer.lock().await.close().await; - if !state.clear_session_for_id(&connection.session_id).await { + if !state + .clear_session_for_id(&connection.session_id, &connection.instance_id) + .await + { return; } @@ -4858,6 +4870,7 @@ mod tests { config_schema_lookup_with, config_schema_lookup_candidate_paths, is_config_schema_path_not_found, + ensure_local_only_transport, normalize_memory_root_document_name, normalize_memory_timeline_entry_name, order_daily_memory_entries, order_memory_root_documents, parse_gateway_config, parse_json_patch_surface, parse_memory_search_update_input, @@ -4870,6 +4883,7 @@ mod tests { scan_local_memory_timeline_entries, }; use crate::gateway::errors::{GatewayError, GatewayErrorSummary}; + use crate::gateway::endpoint::GatewayTransportKind; use crate::gateway::types::{ GatewayAgentFileEntry, GatewayAgentMemorySearchSourceKind, GatewayAgentSettingsFieldSourceKind, GatewayAgentSettingsWriteAction, @@ -5261,6 +5275,29 @@ mod tests { })); } + #[test] + fn ensure_local_only_transport_accepts_loopback_and_rejects_remote() { + assert!(ensure_local_only_transport( + Some(GatewayTransportKind::LocalLoopback), + "local-only config.set bridge for remote gateway sessions", + ) + .is_ok()); + + let error = ensure_local_only_transport( + Some(GatewayTransportKind::Direct), + "local-only config.set bridge for remote gateway sessions", + ) + .expect_err("remote transport should be rejected"); + assert!(matches!(error, GatewayError::NotImplemented { .. })); + + let error = ensure_local_only_transport( + None, + "local-only config.set bridge for remote gateway sessions", + ) + .expect_err("missing transport should fail"); + assert!(matches!(error, GatewayError::Transport { .. })); + } + #[tokio::test] async fn config_schema_lookup_with_falls_back_across_request_attempts() { let mut attempted_paths = Vec::new(); diff --git a/src-tauri/src/gateway/discovery.rs b/src-tauri/src/gateway/discovery.rs index ae622db..2e44363 100644 --- a/src-tauri/src/gateway/discovery.rs +++ b/src-tauri/src/gateway/discovery.rs @@ -126,7 +126,7 @@ fn merge_ipv4_candidates(seed_ip: Option, interface_ips: Vec let mut seen_subnets = BTreeSet::new(); let mut merged = Vec::new(); - for ipv4 in seed_ip.into_iter().chain(interface_ips.into_iter()) { + for ipv4 in seed_ip.into_iter().chain(interface_ips) { if !ipv4.is_private() || ipv4.is_loopback() { continue; } 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/state.rs b/src-tauri/src/gateway/state.rs index 511ecae..ff4ae37 100644 --- a/src-tauri/src/gateway/state.rs +++ b/src-tauri/src/gateway/state.rs @@ -1,6 +1,9 @@ use std::{ collections::{BTreeSet, HashMap}, - sync::{Arc, Mutex}, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, }; use futures_util::stream::SplitSink; @@ -22,9 +25,11 @@ pub type GatewaySocket = WebSocketStream>; pub type GatewaySocketWriter = SplitSink; pub type GatewayPendingRequestResult = Result; type GatewayPendingRequests = Arc>>>; +static GATEWAY_CONNECTION_INSTANCE_COUNTER: AtomicU64 = AtomicU64::new(1); pub struct GatewayActiveConnection { pub session_id: String, + pub instance_id: String, pub endpoint: GatewayEndpoint, pub writer: Arc>, pending_requests: GatewayPendingRequests, @@ -40,6 +45,9 @@ impl GatewayActiveConnection { ) -> Self { Self { session_id, + instance_id: GATEWAY_CONNECTION_INSTANCE_COUNTER + .fetch_add(1, Ordering::Relaxed) + .to_string(), endpoint, writer, pending_requests: Arc::new(Mutex::new(HashMap::new())), @@ -104,6 +112,7 @@ impl Clone for GatewayActiveConnection { fn clone(&self) -> Self { Self { session_id: self.session_id.clone(), + instance_id: self.instance_id.clone(), endpoint: self.endpoint.clone(), writer: Arc::clone(&self.writer), pending_requests: Arc::clone(&self.pending_requests), @@ -273,8 +282,17 @@ impl GatewayAppState { self.replace_session(None).await } - pub async fn clear_session_for_id(&self, session_id: &str) -> bool { - let removed = self.inner.sessions.lock().await.remove(session_id).is_some(); + pub async fn clear_session_for_id(&self, session_id: &str, instance_id: &str) -> bool { + let removed = { + let mut sessions = self.inner.sessions.lock().await; + if !should_clear_replaced_session( + sessions.get(session_id).map(|session| session.instance_id.as_str()), + instance_id, + ) { + return false; + } + sessions.remove(session_id).is_some() + }; if removed { self.inner .snapshots @@ -443,6 +461,10 @@ impl GatewayAppState { } } +fn should_clear_replaced_session(current_instance_id: Option<&str>, closing_instance_id: &str) -> bool { + current_instance_id == Some(closing_instance_id) +} + fn resolve_next_active_session_id<'a>( mut session_ids: impl Iterator, ) -> Option { @@ -595,4 +617,11 @@ mod tests { assert!(next.is_none()); } + + #[test] + fn stale_reader_must_not_clear_replaced_session() { + assert!(!should_clear_replaced_session(Some("new-instance"), "old-instance")); + assert!(should_clear_replaced_session(Some("same-instance"), "same-instance")); + assert!(!should_clear_replaced_session(None, "same-instance")); + } } 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-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", diff --git a/src/app/components/setup/OpenClawConfigModule.tsx b/src/app/components/setup/OpenClawConfigModule.tsx index 1f4c12d..b353a2f 100644 --- a/src/app/components/setup/OpenClawConfigModule.tsx +++ b/src/app/components/setup/OpenClawConfigModule.tsx @@ -1,13 +1,23 @@ -import { useEffect, useState } from 'react'; -import { useOpenClaw, type AuthMode, type GatewayAdvancedConnectionConfig } from '../../contexts/OpenClawContext'; +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'; import { useI18n } from '../../contexts/I18nContext'; +import { + resolveAuthModeForGatewayUrl, + shouldAllowPairingUiForGatewayUrl, +} from '../../contexts/openClawConnectionPolicy'; import { buildAvailableOpenClawConfigSections, resolveSelectedOpenClawConfigSection, type OpenClawConfigSectionId, } from './openClawConfigSectionState'; +import { + canAdoptPairedDeviceForGatewayUrl, + resolveOpenClawPairingFollowup, + resolveOpenClawStartPairingTransition, + shouldShowNoPendingPairingHint, +} from './openClawPairingState'; export function OpenClawConfigModule() { const { t } = useI18n(); @@ -46,30 +56,158 @@ 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 authSecretRequired = authMode !== 'paired_device' && authSecret.trim().length === 0; - const authSecretRequiredMessage = authMode === 'token' ? t('setup.auth.requiredToken') : t('setup.auth.requiredPassword'); + 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 pairingBootstrapTokenInputRef = useRef(null); + const [pairingBootstrapToken, setPairingBootstrapToken] = useState( + savedAuthMode === 'token' ? savedAuthSecret : '', + ); + const [pairingAttempted, setPairingAttempted] = useState(false); + const [pairingCompletionPending, setPairingCompletionPending] = useState(false); + const [pairingSucceededWithoutDeviceToken, setPairingSucceededWithoutDeviceToken] = useState(false); + const pairingUiAllowed = shouldAllowPairingUiForGatewayUrl(url); + const pairedReady = pairingStatus?.pairedReady ?? false; + const pairedDeviceAvailable = pairingUiAllowed && pairedReady; + 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; + const authSecretRequiredMessage = authMode === 'password' ? t('setup.auth.requiredPassword') : t('setup.auth.requiredToken'); useEffect(() => { setUrl(gatewayUrl); - setAuthMode(savedAuthMode); + setAuthMode(resolveAuthModeForGatewayUrl(gatewayUrl, savedAuthMode)); setAuthSecret(savedAuthSecret); + setAuthModeTouched(false); + setPairingBootstrapToken(savedAuthMode === 'token' ? savedAuthSecret : ''); + setPairingAttempted(false); + setPairingCompletionPending(false); + setPairingSucceededWithoutDeviceToken(false); }, [gatewayUrl, savedAuthMode, savedAuthSecret]); + useEffect(() => { + if (!url.trim() || !pairingUiAllowed) { + 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; + } + applyPairingStatus(next, !authModeTouched); + } catch { + if (!cancelled) { + setPairingStatus(null); + } + } finally { + if (!cancelled) { + setPairingStatusLoading(false); + } + } + }, 220); + + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [authModeTouched, pairingUiAllowed, url]); + + useEffect(() => { + const nextMode = resolveAuthModeForGatewayUrl(url, authMode); + if (nextMode === authMode) { + return; + } + + setAuthMode(nextMode); + setAuthModeTouched(false); + setPairingAttempted(false); + setPairingCompletionPending(false); + setPairingSucceededWithoutDeviceToken(false); + setSaveFeedback(null); + }, [authMode, url]); + useEffect(() => { setAdvancedTimeoutMs(String(advancedConnectionConfig.timeoutMs)); setAdvancedHeartbeatMs(String(advancedConnectionConfig.heartbeatMs)); 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); + return; + } + + if (!next?.pairedReady && !authModeTouched && authMode === 'paired_device') { + setAuthMode('token'); + } + }; + + 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); + setPairingAttempted(false); + setPairingCompletionPending(false); + setPairingSucceededWithoutDeviceToken(false); + const success = await testConnection(targetUrl, effectiveMode, effectiveSecret); + + if (success) { + setPairingStatusLoading(true); + try { + const next = await gatewayPairingStatusLookup(targetUrl); + applyPairingStatus(next, canAdoptPairedDeviceForGatewayUrl(targetUrl)); + + 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'); @@ -78,6 +216,70 @@ export function OpenClawConfigModule() { } }; + const handleStartPairing = async () => { + const bootstrapToken = pairingBootstrapToken.trim(); + + setSaveFeedback(null); + setPairingAttempted(true); + setPairingCompletionPending(false); + setPairingSucceededWithoutDeviceToken(false); + + if (!bootstrapToken) { + setSaveFeedback({ kind: 'error', message: t('setup.auth.requiredToken') }); + window.requestAnimationFrame(() => { + pairingBootstrapTokenInputRef.current?.focus(); + }); + return; + } + + 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, canAdoptPairedDeviceForGatewayUrl(targetUrl)); + const transition = resolveOpenClawStartPairingTransition({ + connectSucceeded: true, + pairedReady: next.pairedReady, + }); + + setPairingSucceededWithoutDeviceToken( + transition.pairingSucceededWithoutDeviceToken, + ); + setPairingCompletionPending(transition.pairingCompletionPending); + + if (transition.pairingCompletionPending) { + setPairingAttempted(false); + setSaveFeedback({ + kind: 'info', + message: t('setup.pairing.deviceTokenReady'), + }); + setTestResult('success'); + } else { + setTestResult('none'); + } + } catch { + // Ignore refresh failures and keep the latest visible state. + } finally { + setPairingStatusLoading(false); + } + } + + setIsTesting(false); + if (!success) { + setTestResult('fail'); + } + }; + const handleSave = async () => { if (!url) { return; @@ -101,11 +303,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') } @@ -151,6 +362,17 @@ 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); + setPairingAttempted(false); + setPairingCompletionPending(false); + }; + const candidateConfidenceLabel = (confidence: (typeof discoveredGateways)[number]['confidence']) => { if (confidence === 'high') { return t('config.discovery.confidenceHigh'); @@ -176,7 +398,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}` @@ -353,7 +575,14 @@ export function OpenClawConfigModule() { setUrl(e.target.value)} + onChange={(e) => { + setUrl(e.target.value); + setAuthModeTouched(false); + setPairingAttempted(false); + setPairingCompletionPending(false); + setPairingSucceededWithoutDeviceToken(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 ${ !url @@ -365,6 +594,185 @@ export function OpenClawConfigModule() { {!url &&

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

} + {url && !pairingUiAllowed ? ( +
+
+ {t('setup.pairing.loopbackTitle')} +
+

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

+
+ ) : url && (pairingStatusLoading || pairingStatus) ? ( +
+
+
+
+ {t('setup.pairing.statusTitle')} +
+ {pairingStatusLoading ? ( +
{t('setup.pairing.detecting')}
+ ) : pairingStatus?.pairedReady ? ( + <> +
{t('setup.pairing.readyTitle')}
+
{t('setup.pairing.readyDesc')}
+
{t('setup.pairing.deviceTokenValid')}
+ + ) : awaitingPairApproval ? ( + <> +
{t('setup.pairing.awaitingApprovalTitle')}
+
{t('setup.pairing.awaitingApprovalDesc')}
+ + ) : pairingStatus ? ( + <> +
{t('setup.pairing.bootstrapTitle')}
+
{t('setup.pairing.bootstrapDesc')}
+ + ) : null} +
+ {pairingStatus?.pairedReady ? ( + + ) : pairingStatus ? ( + + ) : null} +
+ + {!pairedDeviceAvailable && pairingStatus ? ( +
+ {awaitingPairApproval ? ( +
+

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

+ + openclaw devices approve --latest + +
+ ) : ( +
+ {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} + +
+ + { + 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" + /> +
+

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

+
+ )} +
+ ) : null} + + {pairingUiAllowed ? ( +
+
+ {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} +
+ ) : null} +
) : null} @@ -595,6 +1025,8 @@ export function OpenClawConfigModule() {
{saveFeedback.message} @@ -615,7 +1047,7 @@ export function OpenClawConfigModule() { {t('config.setup.rerun')}
); } - - - - diff --git a/src/app/components/setup/SetupWizard.tsx b/src/app/components/setup/SetupWizard.tsx index 67f4a6e..8b94e96 100644 --- a/src/app/components/setup/SetupWizard.tsx +++ b/src/app/components/setup/SetupWizard.tsx @@ -1,10 +1,20 @@ 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 appLogo from '../../../assets/270226c058e3f12ad7bb9e96e3b029bc0e2c0461.png'; +import { + resolveAuthModeForGatewayUrl, + shouldAllowPairingUiForGatewayUrl, +} from '../../contexts/openClawConnectionPolicy'; +import { + canAdoptPairedDeviceForGatewayUrl, + resolveOpenClawPairingFollowup, + resolveOpenClawStartPairingTransition, + shouldShowNoPendingPairingHint, +} from './openClawPairingState'; const DEFAULT_GATEWAY_URL = 'http://127.0.0.1:18789'; @@ -32,18 +42,41 @@ 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); 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 [pairingActionHint, setPairingActionHint] = useState(null); + 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); + const pairingUiAllowed = shouldAllowPairingUiForGatewayUrl(url); 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 pairedDeviceAvailable = pairingUiAllowed && pairedReady; + 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; + const authSecretRequiredMessage = authMode === 'password' ? t('setup.auth.requiredPassword') : t('setup.auth.requiredToken'); useEffect(() => { setMounted(true); @@ -64,14 +97,73 @@ export function SetupWizard() { setStep(1); setUrl(gatewayUrl || DEFAULT_GATEWAY_URL); - setAuthMode(savedAuthMode); + setAuthMode( + resolveAuthModeForGatewayUrl( + gatewayUrl || DEFAULT_GATEWAY_URL, + savedAuthMode, + ), + ); setAuthSecret(savedAuthSecret); setIsTesting(false); setIsSaving(false); setTestResult('none'); setIsDetecting(false); + setAuthModeTouched(false); + setPairingActionHint(null); + setPairingBootstrapToken(savedAuthMode === 'token' ? savedAuthSecret : ''); + setPairingAttempted(false); + setPairingCompletionPending(false); + setPairingSucceededWithoutDeviceToken(false); }, [shouldShowWizard, gatewayUrl, savedAuthMode, savedAuthSecret]); + useEffect(() => { + if (!shouldShowWizard || !url.trim() || !pairingUiAllowed) { + 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; + } + applyPairingStatus(next, !authModeTouched); + } catch { + if (!cancelled) { + setPairingStatus(null); + } + } finally { + if (!cancelled) { + setPairingStatusLoading(false); + } + } + }, 220); + + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [authModeTouched, pairingUiAllowed, shouldShowWizard, url]); + + useEffect(() => { + const nextMode = resolveAuthModeForGatewayUrl(url, authMode); + if (nextMode === authMode) { + return; + } + + setAuthMode(nextMode); + setAuthModeTouched(false); + setPairingAttempted(false); + setPairingCompletionPending(false); + setPairingSucceededWithoutDeviceToken(false); + setPairingActionHint(null); + }, [authMode, url]); + const handleThemeToggle = (e: React.MouseEvent) => { const rect = e.currentTarget.getBoundingClientRect(); setRipplePos({ @@ -99,30 +191,142 @@ export function SetupWizard() { setUrl(DEFAULT_GATEWAY_URL); setAuthMode('paired_device'); setAuthSecret(''); + setAuthModeTouched(false); + setPairingAttempted(false); + setPairingCompletionPending(false); + setPairingSucceededWithoutDeviceToken(false); + setPairingActionHint(null); setIsDetecting(false); }, 800); }; + const applyPairingStatus = ( + next: GatewayPairingStatusResult | null, + adoptPairedDevice = false, + ) => { + setPairingStatus(next); + + if (next?.pairedReady && adoptPairedDevice) { + setAuthMode('paired_device'); + setAuthSecret(''); + setAuthModeTouched(false); + return; + } + + if (!next?.pairedReady && !authModeTouched && authMode === 'paired_device') { + setAuthMode('token'); + } + }; + const handleTestAndNext = async () => { + const targetUrl = url.trim(); + const effectiveMode = authMode; + const effectiveSecret = authSecret; + setIsTesting(true); setTestResult('none'); - - const success = await testConnection(url, authMode, authSecret); + setPairingActionHint(null); + setPairingAttempted(false); + setPairingCompletionPending(false); + setPairingSucceededWithoutDeviceToken(false); + + const success = await testConnection(targetUrl, effectiveMode, effectiveSecret); + + if (success) { + setPairingStatusLoading(true); + try { + const next = await gatewayPairingStatusLookup(targetUrl); + applyPairingStatus(next, canAdoptPairedDeviceForGatewayUrl(targetUrl)); + } catch { + // Ignore refresh failures and keep the test result visible. + } finally { + setPairingStatusLoading(false); + } + } 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); + setPairingSucceededWithoutDeviceToken(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, canAdoptPairedDeviceForGatewayUrl(targetUrl)); + const transition = resolveOpenClawStartPairingTransition({ + connectSucceeded: true, + pairedReady: next.pairedReady, + }); + + setPairingSucceededWithoutDeviceToken( + transition.pairingSucceededWithoutDeviceToken, + ); + + 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. + } finally { + setPairingStatusLoading(false); + } + } + + setIsTesting(false); + if (!success) { + setTestResult('fail'); + } + }; + 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) { @@ -312,7 +516,14 @@ export function SetupWizard() { setUrl(e.target.value)} + onChange={(e) => { + setUrl(e.target.value); + setAuthModeTouched(false); + setPairingAttempted(false); + setPairingCompletionPending(false); + setPairingSucceededWithoutDeviceToken(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 ${ !url @@ -328,6 +539,83 @@ export function SetupWizard() { )} + {url && !pairingUiAllowed ? ( +
+
+ {t('setup.pairing.loopbackTitle')} +
+

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

+
+ ) : url && (pairingStatusLoading || pairingStatus) ? ( +
+
+ {t('setup.pairing.statusTitle')} +
+ {pairingStatusLoading ? ( +
{t('setup.pairing.detecting')}
+ ) : pairingStatus?.pairedReady ? ( + <> +
{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); + 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} + +
+ + ) : null} +
+ ) : null} +
@@ -410,6 +720,11 @@ export function SetupWizard() { {connectedOrigin} )} + {pairingActionHint ? ( +
+ {pairingActionHint} +
+ ) : null} ) : ( <> @@ -444,10 +759,38 @@ export function SetupWizard() {

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

+ {showNoPendingPairingHint ? ( +

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

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

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

+

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

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

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

+

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

+
) : (
    @@ -517,7 +860,22 @@ export function SetupWizard() { {testResult === 'fail' ? t('btn.back') : t('btn.prev')} {testResult === 'success' && ( - + )} )} @@ -539,9 +897,3 @@ export function SetupWizard() {
); } - - - - - - diff --git a/src/app/components/setup/openClawConfigSectionState.test.ts b/src/app/components/setup/openClawConfigSectionState.test.ts index be1e11f..08fdbe4 100644 --- a/src/app/components/setup/openClawConfigSectionState.test.ts +++ b/src/app/components/setup/openClawConfigSectionState.test.ts @@ -29,4 +29,10 @@ describe("openClawConfigSectionState", () => { resolveSelectedOpenClawConfigSection("sessions", buildAvailableOpenClawConfigSections({ hasConnectedNodes: false })), ).toBe("status"); }); + + it("keeps sessions section whenever more than one connected node is still present", () => { + expect( + buildAvailableOpenClawConfigSections({ hasConnectedNodes: true }), + ).toContain("sessions"); + }); }); diff --git a/src/app/components/setup/openClawPairingState.test.ts b/src/app/components/setup/openClawPairingState.test.ts new file mode 100644 index 0000000..3201617 --- /dev/null +++ b/src/app/components/setup/openClawPairingState.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; + +import { + canAdoptPairedDeviceForGatewayUrl, + resolveOpenClawPairingFollowup, + resolveOpenClawStartPairingTransition, + shouldShowNoPendingPairingHint, +} from "./openClawPairingState"; + +describe("openClawPairingState", () => { + it("returns none before a pairing attempt is made", () => { + expect( + resolveOpenClawPairingFollowup({ + pairedReady: false, + pairingAttempted: false, + pairingCompletionPending: false, + lastError: { code: "PAIRING_REQUIRED", category: "pairing" }, + }), + ).toBe("none"); + }); + + it("returns awaiting_host_approval when pairing is pending host approval", () => { + expect( + resolveOpenClawPairingFollowup({ + pairedReady: false, + pairingAttempted: true, + pairingCompletionPending: false, + lastError: { code: "PAIRING_REQUIRED", category: "pairing" }, + }), + ).toBe("awaiting_host_approval"); + }); + + it("returns token_mismatch when the bootstrap token is rejected", () => { + expect( + resolveOpenClawPairingFollowup({ + pairedReady: false, + pairingAttempted: true, + pairingCompletionPending: false, + lastError: { code: "AUTH_TOKEN_MISMATCH", category: "auth" }, + }), + ).toBe("token_mismatch"); + }); + + it("returns token_required when the bootstrap token is missing", () => { + expect( + resolveOpenClawPairingFollowup({ + pairedReady: false, + pairingAttempted: true, + pairingCompletionPending: false, + lastError: { code: "AUTH_TOKEN_REQUIRED", category: "auth" }, + }), + ).toBe("token_required"); + }); + + it("treats other failures as request_not_queued", () => { + expect( + resolveOpenClawPairingFollowup({ + pairedReady: false, + pairingAttempted: true, + pairingCompletionPending: false, + lastError: { code: "SOCKET_ERROR", category: "transport" }, + }), + ).toBe("request_not_queued"); + }); + + it("suppresses follow-up prompts once device token persistence is pending", () => { + expect( + resolveOpenClawPairingFollowup({ + pairedReady: false, + pairingAttempted: true, + pairingCompletionPending: true, + lastError: { code: "PAIRING_REQUIRED", category: "pairing" }, + }), + ).toBe("none"); + }); + + it("returns connected_without_pairing when start pairing only establishes a shared-token connection", () => { + expect( + resolveOpenClawPairingFollowup({ + pairedReady: false, + pairingAttempted: true, + pairingCompletionPending: false, + pairingSucceededWithoutDeviceToken: true, + lastError: null, + }), + ).toBe("connected_without_pairing"); + }); + + it("marks only non-approval failures as no-pending-pairing hints", () => { + expect(shouldShowNoPendingPairingHint("none")).toBe(false); + expect(shouldShowNoPendingPairingHint("awaiting_host_approval")).toBe(false); + expect(shouldShowNoPendingPairingHint("connected_without_pairing")).toBe(true); + expect(shouldShowNoPendingPairingHint("token_mismatch")).toBe(true); + expect(shouldShowNoPendingPairingHint("token_required")).toBe(true); + expect(shouldShowNoPendingPairingHint("request_not_queued")).toBe(true); + }); + + it("keeps the wizard on the pairing step when start pairing succeeds without device token issuance", () => { + expect( + resolveOpenClawStartPairingTransition({ + connectSucceeded: true, + pairedReady: false, + }), + ).toEqual({ + shouldAdvanceWizard: false, + pairingCompletionPending: false, + pairingSucceededWithoutDeviceToken: true, + }); + }); + + it("advances the wizard only when start pairing actually finishes and device token is ready", () => { + expect( + resolveOpenClawStartPairingTransition({ + connectSucceeded: true, + pairedReady: true, + }), + ).toEqual({ + shouldAdvanceWizard: true, + pairingCompletionPending: true, + pairingSucceededWithoutDeviceToken: false, + }); + }); + + it("does not allow paired-device adoption for loopback gateway urls", () => { + expect(canAdoptPairedDeviceForGatewayUrl("http://127.0.0.1:18789")).toBe(false); + expect(canAdoptPairedDeviceForGatewayUrl("http://localhost:18789")).toBe(false); + expect(canAdoptPairedDeviceForGatewayUrl("http://192.168.1.112:18789")).toBe(true); + }); +}); diff --git a/src/app/components/setup/openClawPairingState.ts b/src/app/components/setup/openClawPairingState.ts new file mode 100644 index 0000000..534f485 --- /dev/null +++ b/src/app/components/setup/openClawPairingState.ts @@ -0,0 +1,102 @@ +import type { GatewayErrorSummary } from "../../contexts/OpenClawContext"; +import { isLoopbackGatewayUrl } from "../../contexts/openClawConnectionPolicy"; + +export type OpenClawPairingFollowupKind = + | "none" + | "awaiting_host_approval" + | "connected_without_pairing" + | "token_mismatch" + | "token_required" + | "request_not_queued"; + +interface ResolveOpenClawPairingFollowupInput { + pairedReady: boolean; + pairingAttempted: boolean; + pairingCompletionPending: boolean; + pairingSucceededWithoutDeviceToken?: boolean; + lastError: Pick | null; +} + +interface ResolveOpenClawStartPairingTransitionInput { + connectSucceeded: boolean; + pairedReady: boolean; +} + +interface OpenClawStartPairingTransition { + shouldAdvanceWizard: boolean; + pairingCompletionPending: boolean; + pairingSucceededWithoutDeviceToken: boolean; +} + +export function canAdoptPairedDeviceForGatewayUrl(url: string) { + return !isLoopbackGatewayUrl(url); +} + +export function resolveOpenClawPairingFollowup({ + pairedReady, + pairingAttempted, + pairingCompletionPending, + pairingSucceededWithoutDeviceToken = false, + lastError, +}: ResolveOpenClawPairingFollowupInput): OpenClawPairingFollowupKind { + if (pairedReady || pairingCompletionPending || !pairingAttempted) { + return "none"; + } + + if (pairingSucceededWithoutDeviceToken) { + return "connected_without_pairing"; + } + + if (!lastError) { + return "none"; + } + + switch (lastError.code) { + case "PAIRING_REQUIRED": + return "awaiting_host_approval"; + case "AUTH_TOKEN_MISMATCH": + return "token_mismatch"; + case "AUTH_TOKEN_REQUIRED": + return "token_required"; + default: + return "request_not_queued"; + } +} + +export function shouldShowNoPendingPairingHint( + kind: OpenClawPairingFollowupKind, +) { + return ( + kind === "connected_without_pairing" || + kind === "token_mismatch" || + kind === "token_required" || + kind === "request_not_queued" + ); +} + +export function resolveOpenClawStartPairingTransition({ + connectSucceeded, + pairedReady, +}: ResolveOpenClawStartPairingTransitionInput): OpenClawStartPairingTransition { + if (!connectSucceeded) { + return { + shouldAdvanceWizard: false, + pairingCompletionPending: false, + pairingSucceededWithoutDeviceToken: false, + }; + } + + if (pairedReady) { + return { + shouldAdvanceWizard: true, + pairingCompletionPending: true, + pairingSucceededWithoutDeviceToken: false, + }; + } + + return { + shouldAdvanceWizard: false, + pairingCompletionPending: false, + pairingSucceededWithoutDeviceToken: true, + }; +} diff --git a/src/app/components/views/EvolutionView.tsx b/src/app/components/views/EvolutionView.tsx index 8c2823a..e367b57 100644 --- a/src/app/components/views/EvolutionView.tsx +++ b/src/app/components/views/EvolutionView.tsx @@ -45,6 +45,7 @@ import { buildEvolutionAuditReportMarkdown } from "./evolutionAuditReport"; import { formatKnowledgePackExample, parseKnowledgeInjectionPack } from "./evolutionKnowledgePack"; import { buildEvolutionTargetNodeEntries, + resolveEvolutionSessionIdToActivate, resolveSelectedEvolutionAgentId, resolveSelectedEvolutionNodeId, } from "./evolutionTargetState"; @@ -180,7 +181,7 @@ function mapRuntimePhaseToStep(phase: EvolutionOperationStatusSnapshot["phase"]) export function EvolutionView() { const { lang, t } = useI18n(); - const { nodes, agents, isConnected } = useOpenClaw(); + const { nodes, agents, isConnected, setActiveSession } = useOpenClaw(); const RECENT_HISTORY_COLLAPSED_COUNT = 3; const buildCustomTemplateExample = () => `{\n "mode": "append_block",\n "title": "${t("evo.custom.example.blockTitle")}",\n "content": "${t("evo.custom.example.blockContent")}"\n}`; @@ -869,7 +870,17 @@ export function EvolutionView() { setNewExtraPath(event.target.value)} - disabled={!knowledgeModel.localWritable || savingAction !== null} + disabled={!knowledgeModel.localWritable || controlsDisabled} placeholder={t("memory.knowledge.pathPlaceholder")} className={`min-w-0 flex-1 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 outline-none dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 ${toneClasses.input}`} /> void handleAddExtraPath()} - disabled={!knowledgeModel.localWritable || savingAction !== null} + disabled={!knowledgeModel.localWritable || controlsDisabled} variant="primary" > {t("memory.knowledge.addPath")} +
+ {t("memory.knowledge.pathInputHint")} +
{knowledgeModel.extraPaths.length > 0 ? (
{knowledgeModel.extraPaths.map((path) => ( @@ -490,7 +806,7 @@ export function MemoryKnowledgePanel({ void handleRemoveExtraPath(path)} - disabled={!knowledgeModel.localWritable || savingAction !== null} + disabled={!knowledgeModel.localWritable || controlsDisabled} > {t("memory.knowledge.removePath")} @@ -515,7 +831,7 @@ export function MemoryKnowledgePanel({ type="checkbox" checked={knowledgeModel.sessionMemoryEnabled} onChange={(event) => void handleToggleSessionMemory(event.target.checked)} - disabled={!knowledgeModel.localWritable || savingAction !== null} + disabled={!knowledgeModel.localWritable || controlsDisabled} className={`h-4 w-4 rounded border-slate-300 ${toneClasses.checkbox}`} /> @@ -523,14 +839,16 @@ export function MemoryKnowledgePanel({ {(["memory", "sessions"] as const).map((source) => ( @@ -542,7 +860,7 @@ export function MemoryKnowledgePanel({
{configFeedback ?
{configFeedback}
: null} - {reindexFeedback ?
{reindexFeedback}
: null} + {reindexFeedback && !reindexActivity?.syncIssue ?
{reindexFeedback}
: null} ); diff --git a/src/app/components/views/MemoryView.reindexPersistence.test.tsx b/src/app/components/views/MemoryView.reindexPersistence.test.tsx new file mode 100644 index 0000000..c0c6845 --- /dev/null +++ b/src/app/components/views/MemoryView.reindexPersistence.test.tsx @@ -0,0 +1,217 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; + +vi.mock("motion/react", () => ({ + AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock("../../contexts/I18nContext", () => ({ + useI18n: () => ({ + t: (key: string, ...args: (string | number)[]) => + args.length ? `${key}:${args.join(",")}` : key, + }), +})); + +const openClawState = { + nodes: [ + { + id: "gateway:http://127.0.0.1:3100", + name: "OpenClaw Local", + status: "online" as const, + sessionId: "session-local", + origin: "http://127.0.0.1:3100", + grantedScopes: ["operator.admin"], + isActive: true, + }, + ], + agents: [ + { + id: "agent-main", + name: "Main Agent", + nodeId: "gateway:http://127.0.0.1:3100", + status: "active" as const, + }, + ], + grantedScopes: ["operator.admin"], + isConnected: true, + connectedOrigin: "http://127.0.0.1:3100", + setActiveSession: vi.fn(), +}; + +vi.mock("../../contexts/OpenClawContext", () => ({ + useOpenClaw: () => openClawState, + gatewayAgentMemoryGet: vi.fn().mockResolvedValue({ + agentId: "agent-main", + workspace: "workspace", + documents: [ + { + name: "MEMORY.md", + path: "workspace/MEMORY.md", + missing: false, + content: "# Memory", + }, + ], + sharedAgents: [], + diagnostics: { + memorySearchEnabled: true, + backend: "builtin", + provider: "openai", + embeddingModel: "text-embedding-3-large", + builtinStorePath: "~/.openclaw/memory/main.sqlite", + sources: ["memory"], + extraPaths: ["D:/shared/notes"], + sessionMemoryEnabled: false, + qmdActive: false, + qmdHome: null, + qmdPaths: [], + qmdSessionsEnabled: false, + }, + }), + gatewayAgentMemorySet: vi.fn(), + gatewayAgentMemoryIndex: vi.fn(), + gatewayAgentFileRead: vi.fn(), + gatewayAgentMemorySearch: vi.fn(), + gatewayAgentMemoryRuntimeStatus: vi.fn().mockResolvedValue({ + agentId: "agent-main", + embeddingOk: true, + vectorOk: true, + status: { + backend: "builtin", + files: 0, + totalFiles: 0, + chunks: 0, + dirty: true, + workspaceDir: null, + dbPath: null, + provider: "openai", + model: "text-embedding-3-large", + requestedProvider: "openai", + sources: ["memory"], + extraPaths: ["D:/shared/notes"], + sourceCounts: [], + }, + rawPayload: "{}", + }), + gatewayAgentMemoryStatus: vi.fn().mockResolvedValue(null), + gatewayAgentMemoryTimelineAccessResolve: vi.fn().mockResolvedValue({ + agentId: "agent-main", + workspace: "workspace", + mode: "local_workspace", + reason: "workspace_local_and_readable", + }), + gatewayAgentMemoryTimelineEntryRead: vi.fn().mockResolvedValue({ + agentId: "agent-main", + workspace: "workspace", + file: { + name: "memory/2026-04-20.md", + path: "workspace/memory/2026-04-20.md", + missing: false, + content: "", + }, + }), + gatewayAgentMemoryTimelineGet: vi.fn().mockResolvedValue({ + agentId: "agent-main", + workspace: "workspace", + source: "local_workspace", + entries: [], + diagnostics: { + gatewayVisibleFilesCount: 0, + gatewayVisibleRootDocsCount: 0, + gatewayVisibleDailyCount: 0, + gatewayOnlyReturnedRootDocs: false, + localScanDirectory: null, + localScanFilesCount: 0, + localScanSkippedCount: 0, + }, + probe: null, + }), + gatewayAgentMemoryTimelineLocalScan: vi.fn().mockResolvedValue({ + agentId: "agent-main", + workspace: "workspace", + source: "local_workspace", + entries: [], + diagnostics: { + gatewayVisibleFilesCount: 0, + gatewayVisibleRootDocsCount: 0, + gatewayVisibleDailyCount: 0, + gatewayOnlyReturnedRootDocs: false, + localScanDirectory: null, + localScanFilesCount: 0, + localScanSkippedCount: 0, + }, + probe: null, + }), + gatewayAgentMemoryTimelineRemoteProbeDates: vi.fn(), + gatewayAgentMemoryTimelineRemoteProbe: vi.fn(), +})); + +vi.mock("./memoryKnowledgeActions", () => ({ + runExternalKnowledgeReindex: vi.fn( + () => new Promise(() => undefined), + ), +})); + +vi.mock("./MemoryDiagnosticsDrawer", () => ({ + MemoryDiagnosticsDrawer: () => null, +})); +vi.mock("./MemorySearchPanel", () => ({ + MemorySearchPanel: () =>
search-panel
, +})); +vi.mock("./MemoryFootprintsPanel", () => ({ + MemoryFootprintsPanel: () =>
footprints-panel
, +})); +vi.mock("./MemoryResourcesPanel", () => ({ + MemoryResourcesPanel: () =>
resources-panel
, +})); +vi.mock("./MemoryDocumentsDesktop", () => ({ + MemoryDocumentsDesktop: () =>
documents-desktop
, +})); +vi.mock("./MemoryDocumentsMobile", () => ({ + MemoryDocumentsMobile: () =>
documents-mobile
, +})); +vi.mock("./MemoryKnowledgePanel", () => ({ + MemoryKnowledgePanel: ({ + reindexActivity, + onRunReindex, + }: { + reindexActivity: { phase: string } | null; + onRunReindex: () => Promise; + }) => ( +
+ +
{`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 6af8240..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"; @@ -46,13 +46,32 @@ import { resolveMemoryDocumentContent, resolveMemoryRootDocument, resolveInitialSearchMatchIndex, - resolveSelectedMemoryAgentId, resolveSelectedMemoryDocumentName, resolveSelectedTimelineEntryName, resolveTimelineProbeRangePreset, summarizeMemoryFootprintGroups, type MemoryTimelineFocusFilter, } from "./memoryState"; +import { + buildMemoryNodeEntries, + isLocalNodeOrigin, + resolveMemorySessionIdToActivate, + resolveSelectedMemoryAgentIdForNode, + resolveSelectedMemoryNodeId, +} from "./memoryNodeState"; +import { + captureMemoryKnowledgeReindexSnapshot, + describeMemoryKnowledgeReindexDelta, + hasMemoryKnowledgeReindexProgress, + isMemoryKnowledgeReindexSettled, + type MemoryKnowledgeRefreshResult, + type MemoryKnowledgeReindexActivityState, + type ReindexTimelineEntry, +} from "./memoryKnowledgeReindexState"; +import { + runExternalKnowledgeReindex, + type MemoryKnowledgeActionFailure, +} from "./memoryKnowledgeActions"; import { canRunSemanticMemorySearch, resolveSemanticMemorySearchGroup, @@ -286,11 +305,34 @@ function openTargetForResource(kind: "document" | "timeline" | "external_source" return "overview" as const; } +async function withTimeout(promise: Promise, timeoutMs: number) { + return await Promise.race([ + promise, + new Promise((resolve) => { + window.setTimeout(() => resolve(null), timeoutMs); + }), + ]); +} + export function MemoryView() { const { t } = useI18n(); - const { agents, grantedScopes, isConnected, connectedOrigin } = useOpenClaw(); + const { nodes, agents, grantedScopes, isConnected, connectedOrigin, setActiveSession } = useOpenClaw(); const [activeSection, setActiveSection] = useState("overview"); - const [selectedAgentId, setSelectedAgentId] = useState(agents[0]?.id ?? ""); + const memoryNodeEntries = useMemo( + () => + buildMemoryNodeEntries({ + isConnected, + nodes, + agents, + }), + [agents, isConnected, nodes], + ); + const [selectedNodeId, setSelectedNodeId] = useState( + resolveSelectedMemoryNodeId("", memoryNodeEntries), + ); + const [selectedAgentId, setSelectedAgentId] = useState( + resolveSelectedMemoryAgentIdForNode("", resolveSelectedMemoryNodeId("", memoryNodeEntries), memoryNodeEntries), + ); const [memoryResult, setMemoryResult] = useState(null); const [_memoryLoading, setMemoryLoading] = useState(false); const [_memoryError, setMemoryError] = useState(null); @@ -347,26 +389,44 @@ 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, + [memoryNodeEntries, selectedNodeId], + ); + const selectedNodeAgents = selectedNodeEntry?.agents ?? []; + const selectedSessionId = selectedNodeEntry?.sessionId; + const selectedNodeOrigin = selectedNodeEntry?.origin ?? connectedOrigin; + const isLocalGatewaySession = useMemo( + () => isLocalNodeOrigin(selectedNodeOrigin), + [selectedNodeOrigin], + ); - const isLocalGatewaySession = useMemo(() => { - if (!connectedOrigin) { - return false; + useEffect(() => { + const nextNodeId = resolveSelectedMemoryNodeId(selectedNodeId, memoryNodeEntries); + if (nextNodeId !== selectedNodeId) { + setSelectedNodeId(nextNodeId); } - - return /^(ws|http):\/\/(127\.0\.0\.1|localhost)(:\d+)?$/i.test( - connectedOrigin, - ); - }, [connectedOrigin]); + }, [memoryNodeEntries, selectedNodeId]); useEffect(() => { - const nextAgentId = resolveSelectedMemoryAgentId( + const nextAgentId = resolveSelectedMemoryAgentIdForNode( selectedAgentId, - agents.map((agent) => agent.id), + selectedNodeId, + memoryNodeEntries, ); if (nextAgentId !== selectedAgentId) { setSelectedAgentId(nextAgentId); } - }, [agents, selectedAgentId]); + }, [memoryNodeEntries, selectedAgentId, selectedNodeId]); useEffect(() => { if (!selectedAgentId || !isConnected) { @@ -382,7 +442,7 @@ export function MemoryView() { setMemoryLoading(true); setMemoryError(null); try { - const result = await gatewayAgentMemoryGet(selectedAgentId); + const result = await gatewayAgentMemoryGet(selectedAgentId, selectedSessionId); if (cancelled) { return; } @@ -406,14 +466,14 @@ export function MemoryView() { setTimelineLoading(true); setTimelineError(null); try { - const access = await gatewayAgentMemoryTimelineAccessResolve(selectedAgentId); + const access = await gatewayAgentMemoryTimelineAccessResolve(selectedAgentId, selectedSessionId); if (cancelled) { return; } setTimelineAccess(access); const result = canLoadLocalTimeline(access) - ? await gatewayAgentMemoryTimelineLocalScan(selectedAgentId) - : await gatewayAgentMemoryTimelineGet(selectedAgentId); + ? await gatewayAgentMemoryTimelineLocalScan(selectedAgentId, selectedSessionId) + : await gatewayAgentMemoryTimelineGet(selectedAgentId, selectedSessionId); if (cancelled) { return; } @@ -434,7 +494,7 @@ export function MemoryView() { const loadStatus = async () => { try { - const result = await gatewayAgentMemoryStatus(selectedAgentId); + const result = await gatewayAgentMemoryStatus(selectedAgentId, selectedSessionId); if (cancelled) { return; } @@ -459,7 +519,7 @@ export function MemoryView() { } try { - const result = await gatewayAgentMemoryRuntimeStatus(selectedAgentId); + const result = await gatewayAgentMemoryRuntimeStatus(selectedAgentId, selectedSessionId); if (cancelled) { return; } @@ -479,7 +539,7 @@ export function MemoryView() { return () => { cancelled = true; }; - }, [selectedAgentId, isConnected, isLocalGatewaySession]); + }, [selectedAgentId, selectedSessionId, isConnected, isLocalGatewaySession]); useEffect(() => { if (!selectedAgentId || !selectedTimelineEntryName) { @@ -497,6 +557,7 @@ export function MemoryView() { const result = await gatewayAgentMemoryTimelineEntryRead( selectedAgentId, selectedTimelineEntryName, + selectedSessionId, ); if (!cancelled) { setTimelineEntryContent(result.file.content ?? ""); @@ -518,7 +579,7 @@ export function MemoryView() { return () => { cancelled = true; }; - }, [selectedAgentId, selectedTimelineEntryName]); + }, [selectedAgentId, selectedSessionId, selectedTimelineEntryName]); const selectedDocument = useMemo( () => resolveMemoryRootDocument(memoryResult?.documents ?? [], selectedDocumentName), @@ -615,7 +676,10 @@ export function MemoryView() { () => hasSharedWorkspaceMemory(memoryResult?.sharedAgents ?? []), [memoryResult?.sharedAgents], ); - const canEdit = canEditMemory(grantedScopes); + const selectedNodeGrantedScopes = + selectedNodeEntry?.grantedScopes ?? + (selectedNodeEntry?.isActive ? grantedScopes : []); + const canEdit = canEditMemory(selectedNodeGrantedScopes); const searchGroups = useMemo(() => { const counts: Record = { all: searchResult?.results.length ?? 0, @@ -711,23 +775,68 @@ export function MemoryView() { } }; - const handleRefreshKnowledge = async () => { + const handleRefreshKnowledge = async (): Promise => { if (!selectedAgentId || !isConnected) { - return; + return null; } const [memory, status, runtime] = await Promise.all([ - gatewayAgentMemoryGet(selectedAgentId), - gatewayAgentMemoryStatus(selectedAgentId).catch(() => null), + withTimeout( + gatewayAgentMemoryGet(selectedAgentId, selectedSessionId), + 5000, + ), + withTimeout( + gatewayAgentMemoryStatus(selectedAgentId, selectedSessionId).catch(() => null), + 4000, + ), isLocalGatewaySession - ? gatewayAgentMemoryRuntimeStatus(selectedAgentId).catch(() => null) + ? withTimeout( + gatewayAgentMemoryRuntimeStatus(selectedAgentId, selectedSessionId).catch(() => null), + 3000, + ) : Promise.resolve(null), ]); - setMemoryResult(memory); - setDrafts(createMemoryDrafts(memory)); - setMemoryStatus(status); - setMemoryRuntimeStatus(runtime); + const nextMemory = memory ?? memoryResult; + const nextStatus = status ?? memoryStatus; + const nextRuntime = runtime ?? memoryRuntimeStatus; + + if (nextMemory) { + setMemoryResult(nextMemory); + if (memory) { + setDrafts(createMemoryDrafts(nextMemory)); + } + } + setMemoryStatus(nextStatus); + setMemoryRuntimeStatus(nextRuntime); + + if (!nextMemory) { + return null; + } + + return { + memoryResult: nextMemory, + memoryStatus: nextStatus, + runtimeStatus: nextRuntime, + }; + }; + + const handleNodeSelect = (nodeId: string) => { + setSelectedNodeId(nodeId); + + const nextAgentId = resolveSelectedMemoryAgentIdForNode( + selectedAgentId, + nodeId, + memoryNodeEntries, + ); + if (nextAgentId !== selectedAgentId) { + setSelectedAgentId(nextAgentId); + } + + const nextSessionId = resolveMemorySessionIdToActivate(nodeId, memoryNodeEntries); + if (nextSessionId) { + void setActiveSession(nextSessionId); + } }; useEffect(() => { @@ -822,13 +931,10 @@ export function MemoryView() { runtimeStatusSummary, memoryResult, }); - const shouldForceReindex = (runtimeStatusSummary?.indexedFiles ?? 0) === 0; - const resolvedIndexStrategy: MemoryIndexStrategy = shouldForceReindex ? "full" : "incremental"; + const resolvedIndexStrategy: MemoryIndexStrategy = "incremental"; const resolvedAgentIdForGuide = selectedAgentId || ""; const commandGuide = useMemo(() => { - const indexCommand = resolvedIndexStrategy === "full" - ? `openclaw memory index --agent ${resolvedAgentIdForGuide} --force` - : `openclaw memory index --agent ${resolvedAgentIdForGuide}`; + const indexCommand = `openclaw memory index --agent ${resolvedAgentIdForGuide}`; const statusCommand = `openclaw memory status --agent ${resolvedAgentIdForGuide} --deep --index`; if (isOllamaProvider) { @@ -858,9 +964,7 @@ export function MemoryView() { return t("memory.documents.index.remote"); } - return resolvedIndexStrategy === "full" - ? t("memory.documents.index.full") - : t("memory.documents.index.incremental"); + return t("memory.documents.index.incremental"); }, [documentIndexRefreshState, isLocalGatewaySession, resolvedIndexStrategy, t]); const memoryConfigStatus = buildMemoryConfigStatusSummary({ selectedAgentId, @@ -875,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); @@ -895,7 +1353,13 @@ export function MemoryView() { setSearchRunning(true); setMemoryLoading(true); try { - const result = await gatewayAgentMemorySearch(selectedAgentId, searchQuery, 20, "all"); + const result = await gatewayAgentMemorySearch( + selectedAgentId, + searchQuery, + 20, + "all", + selectedSessionId, + ); setSearchResult(result); setSearchError(null); setActiveSection("search"); @@ -983,12 +1447,13 @@ export function MemoryView() { }); try { const result = missingDates.length > 0 - ? await gatewayAgentMemoryTimelineRemoteProbeDates(selectedAgentId, missingDates) + ? await gatewayAgentMemoryTimelineRemoteProbeDates(selectedAgentId, missingDates, selectedSessionId) : await gatewayAgentMemoryTimelineRemoteProbe( - selectedAgentId, - timelineProbeRange.startDate, - timelineProbeRange.endDate, - ); + selectedAgentId, + timelineProbeRange.startDate, + timelineProbeRange.endDate, + selectedSessionId, + ); const merged = mergeTimelineProbeResults({ current: timelineResult, retryResult: result, @@ -1055,7 +1520,7 @@ export function MemoryView() { })); try { - const result = await gatewayAgentMemoryTimelineRemoteProbeDates(selectedAgentId, [date]); + const result = await gatewayAgentMemoryTimelineRemoteProbeDates(selectedAgentId, [date], selectedSessionId); const merged = mergeTimelineProbeResults({ current: timelineResult, retryResult: result, @@ -1135,7 +1600,7 @@ export function MemoryView() { } try { - const result = await gatewayAgentMemoryGet(selectedAgentId); + const result = await gatewayAgentMemoryGet(selectedAgentId, selectedSessionId); setMemoryResult(result); setDrafts(createMemoryDrafts(result)); setSelectedDocumentName((current) => @@ -1161,14 +1626,20 @@ export function MemoryView() { setDocumentIndexRefreshState("idle"); try { - await gatewayAgentMemorySet(selectedAgentId, selectedDocument.name, selectedDocumentContent); + await gatewayAgentMemorySet( + selectedAgentId, + selectedDocument.name, + selectedDocumentContent, + selectedSessionId, + ); if (isLocalGatewaySession) { await gatewayAgentMemoryIndex( selectedAgentId, - resolvedIndexStrategy === "full", + false, + selectedSessionId, ); } - const result = await gatewayAgentMemoryGet(selectedAgentId); + const result = await gatewayAgentMemoryGet(selectedAgentId, selectedSessionId); setMemoryResult(result); setDrafts(createMemoryDrafts(result)); setSelectedDocumentName((current) => @@ -1241,7 +1712,7 @@ export function MemoryView() { const normalizedName = entry.path.includes("/sessions/") ? entry.path.split("/sessions/")[1] : entry.path.split("/").slice(-2).join("/"); - const result = await gatewayAgentFileRead(selectedAgentId, normalizedName); + const result = await gatewayAgentFileRead(selectedAgentId, normalizedName, selectedSessionId); setSearchDetail({ title: entry.path.split("/").pop() ?? entry.path, path: entry.path, @@ -1442,6 +1913,8 @@ export function MemoryView() { externalSources={externalSources} isLocalGatewaySession={isLocalGatewaySession} selectedAgentId={selectedAgentId} + selectedNodeName={selectedNodeEntry?.name ?? ""} + selectedSessionId={selectedSessionId ?? null} model={semanticMindMapModel} t={t} showDebug={mindMapDebugVisible} @@ -1449,6 +1922,14 @@ export function MemoryView() { onOpenEvidence={openMindMapEvidence} 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 })} /> ), @@ -1461,25 +1942,53 @@ export function MemoryView() { description={t("memory.desc")} leadingIcon={} actions={( -
+
- {t("memory.header.agents")} + {t("memory.header.nodes")} - {agents.length} {t("common.available")} + {memoryNodeEntries.length} {t("common.available")}
+
+ {t("memory.header.agents")} + + {selectedNodeAgents.length} {t("common.available")} + +
+
+ + +
)} /> @@ -1544,7 +2053,13 @@ export function MemoryView() { onClose={() => setDiagnosticsDrawer((current) => ({ ...current, open: false }))} /> - {activeSection === "overview" && selectedAgentId && isConnected && !memoryResult && !timelineResult ? ( + {!selectedAgentId && selectedNodeEntry ? ( + + + {t("memory.node.empty", selectedNodeEntry.name)} + + + ) : activeSection === "overview" && selectedAgentId && isConnected && !memoryResult && !timelineResult ? ( {t("memory.overview.pending")} diff --git a/src/app/components/views/ProfileView.tsx b/src/app/components/views/ProfileView.tsx index 4a8d458..44bcb1b 100644 --- a/src/app/components/views/ProfileView.tsx +++ b/src/app/components/views/ProfileView.tsx @@ -52,6 +52,7 @@ import { } from "../../contexts/OpenClawContext"; import { applyIdentityMetaToDocument } from "./profileIdentityDocument"; import { + buildVisibleProfileNodeEntries, groupAgentsByNode, resolveSelectedAgentIdForNode, resolveSelectedProfileNodeName, @@ -2169,6 +2170,7 @@ export function ProfileView() { isConnected, connectedOrigin, grantedScopes, + setActiveSession, } = useOpenClaw(); const MOCK_AGENTS_BACKUP: DisplayAgent[] = [ @@ -2617,9 +2619,22 @@ export function ProfileView() { () => groupAgentsByNode(displayAgents), [displayAgents], ); - const nodeEntries = useMemo(() => Object.entries(groupedAgents), [groupedAgents]); + const nodeEntries = useMemo( + () => + hasRealAgents + ? buildVisibleProfileNodeEntries(realNodes, groupedAgents) + : Object.entries(groupedAgents).map(([nodeName, agents]) => ({ + id: nodeName, + name: nodeName, + status: "online" as const, + sessionId: undefined, + isActive: undefined, + agentCount: agents.length, + })), + [groupedAgents, hasRealAgents, realNodes], + ); const nodeNames = useMemo( - () => nodeEntries.map(([nodeName]) => nodeName), + () => nodeEntries.map((entry) => entry.name), [nodeEntries], ); const nodeCount = nodeNames.length; @@ -2635,14 +2650,9 @@ export function ProfileView() { } }, [nodeNames, selectedNodeName]); - useEffect(() => { - if (activeAgent?.node && activeAgent.node !== selectedNodeName) { - setSelectedNodeName(activeAgent.node); - } - }, [activeAgent?.node, selectedNodeName]); - const handleNodeSelect = (nodeName: string) => { setSelectedNodeName(nodeName); + const selectedNodeEntry = nodeEntries.find((entry) => entry.name === nodeName); const nextSelectedAgentId = resolveSelectedAgentIdForNode( selectedAgentId, groupedAgents[nodeName] ?? [], @@ -2651,6 +2661,14 @@ export function ProfileView() { if (nextSelectedAgentId !== selectedAgentId) { setSelectedAgentId(nextSelectedAgentId); } + + if ( + selectedNodeEntry?.sessionId && + hasRealAgents && + !selectedNodeEntry.isActive + ) { + void setActiveSession(selectedNodeEntry.sessionId); + } }; const activeNodeAgents = selectedNodeName @@ -2668,9 +2686,9 @@ export function ProfileView() { disabled={nodeCount <= 1} className="w-full cursor-pointer appearance-none rounded-2xl border border-slate-200 bg-slate-50 py-2.5 pl-10 pr-10 text-sm font-medium text-slate-700 outline-none transition-colors focus:border-sky-400 focus:bg-white dark:border-slate-800 dark:bg-slate-950/40 dark:text-slate-200 dark:focus:border-sky-500 dark:focus:bg-slate-900 disabled:cursor-default disabled:opacity-70" > - {nodeEntries.map(([nodeName, agents]) => ( - ))} diff --git a/src/app/components/views/evolutionTargetState.test.ts b/src/app/components/views/evolutionTargetState.test.ts index 285a333..ac35105 100644 --- a/src/app/components/views/evolutionTargetState.test.ts +++ b/src/app/components/views/evolutionTargetState.test.ts @@ -3,13 +3,26 @@ import { describe, expect, it } from "vitest"; import type { Agent, Node } from "../../contexts/OpenClawContext"; import { buildEvolutionTargetNodeEntries, + resolveEvolutionSessionIdToActivate, resolveSelectedEvolutionAgentId, resolveSelectedEvolutionNodeId, } from "./evolutionTargetState"; const sampleNodes: Node[] = [ - { id: "node-local", name: "OpenClaw Local", status: "online" }, - { id: "node-west", name: "OpenClaw West", status: "offline" }, + { + id: "node-local", + name: "OpenClaw Local", + status: "online", + sessionId: "ws://127.0.0.1:18789", + isActive: true, + }, + { + id: "node-west", + name: "OpenClaw West", + status: "offline", + sessionId: "ws://192.168.1.112:18789", + isActive: false, + }, ]; const sampleAgents: Agent[] = [ @@ -29,6 +42,8 @@ describe("evolutionTargetState", () => { expect(entries).toHaveLength(2); expect(entries.map((entry) => entry.id)).toEqual(["node-local", "node-west"]); expect(entries[0]?.name).toBe("OpenClaw Local"); + expect(entries[0]?.sessionId).toBe("ws://127.0.0.1:18789"); + expect(entries[1]?.sessionId).toBe("ws://192.168.1.112:18789"); expect(entries[0]?.agents.map((agent) => agent.id)).toEqual(["agent-alpha", "agent-beta"]); expect(entries[1]?.agents.map((agent) => agent.id)).toEqual(["agent-gamma"]); }); @@ -82,4 +97,16 @@ describe("evolutionTargetState", () => { expect(resolveSelectedEvolutionAgentId("", "node-west", entries)).toBe("agent-gamma"); expect(resolveSelectedEvolutionAgentId("agent-alpha", "missing-node", entries)).toBe(""); }); + + it("returns the target session id only when selecting a different non-active node", () => { + const entries = buildEvolutionTargetNodeEntries({ + isConnected: true, + nodes: sampleNodes, + agents: sampleAgents, + }); + + expect(resolveEvolutionSessionIdToActivate("node-local", entries)).toBeNull(); + expect(resolveEvolutionSessionIdToActivate("node-west", entries)).toBe("ws://192.168.1.112:18789"); + expect(resolveEvolutionSessionIdToActivate("missing-node", entries)).toBeNull(); + }); }); diff --git a/src/app/components/views/evolutionTargetState.ts b/src/app/components/views/evolutionTargetState.ts index bceaddb..3c3b1b0 100644 --- a/src/app/components/views/evolutionTargetState.ts +++ b/src/app/components/views/evolutionTargetState.ts @@ -4,6 +4,8 @@ export interface EvolutionTargetNodeEntry { id: string; name: string; status: Node["status"]; + sessionId?: string; + isActive?: boolean; agents: Agent[]; } @@ -47,6 +49,8 @@ export function buildEvolutionTargetNodeEntries({ id: node.id, name: node.name, status: node.status, + sessionId: node.sessionId, + isActive: node.isActive, agents: [], })); } @@ -68,6 +72,8 @@ export function buildEvolutionTargetNodeEntries({ id: node.id, name: node.name, status: node.status, + sessionId: node.sessionId, + isActive: node.isActive, agents: groupedAgents[node.id] ?? [], })); @@ -77,6 +83,8 @@ export function buildEvolutionTargetNodeEntries({ id: nodeId, name: nodeId, status: deriveNodeStatus(nodeAgents), + sessionId: undefined, + isActive: undefined, agents: nodeAgents, })); @@ -109,3 +117,15 @@ export function resolveSelectedEvolutionAgentId( return nodeEntry.agents[0]?.id ?? ""; } + +export function resolveEvolutionSessionIdToActivate( + selectedNodeId: string, + nodeEntries: EvolutionTargetNodeEntry[], +) { + const nodeEntry = nodeEntries.find((entry) => entry.id === selectedNodeId); + if (!nodeEntry?.sessionId || nodeEntry.isActive) { + return null; + } + + return nodeEntry.sessionId; +} diff --git a/src/app/components/views/memoryConfigStatus.test.ts b/src/app/components/views/memoryConfigStatus.test.ts index 25deae4..9ba8766 100644 --- a/src/app/components/views/memoryConfigStatus.test.ts +++ b/src/app/components/views/memoryConfigStatus.test.ts @@ -58,6 +58,7 @@ describe("buildMemoryConfigStatusSummary", () => { expect(summary.configuredButNotIndexed).toBe(true); expect(summary.reindexMode).toBe("auto"); + expect(summary.reindexStrategy).toBe("incremental"); expect(summary.statusKey).toBe("configured_only"); expect(summary.searchAvailabilityReasonKey).toBe("memory.search.reason.configuredOnly"); expect(summary.runtimeMatchState).toBe("missing"); @@ -113,6 +114,7 @@ describe("buildMemoryConfigStatusSummary", () => { expect(summary.reindexRequired).toBe(true); expect(summary.reindexMode).toBe("auto"); + expect(summary.reindexStrategy).toBe("incremental"); expect(summary.statusKey).toBe("configured_stale"); expect(summary.searchAvailabilityReasonKey).toBe("memory.search.reason.stale"); expect(summary.runtimeMatchState).toBe("partial"); diff --git a/src/app/components/views/memoryConfigStatus.ts b/src/app/components/views/memoryConfigStatus.ts index 45b64b1..7cee05a 100644 --- a/src/app/components/views/memoryConfigStatus.ts +++ b/src/app/components/views/memoryConfigStatus.ts @@ -25,7 +25,6 @@ export type MemoryConfigStatusSummary = { commandDescriptionKey: | "memory.search.commands.ollama" | "memory.search.commands.localIncremental" - | "memory.search.commands.localForce" | "memory.search.commands.openai" | "memory.search.commands.generic"; searchAvailabilityReasonKey: @@ -109,9 +108,9 @@ export function buildMemoryConfigStatusSummary({ const isOllama = detectOllama(hints); const isHosted = detectHosted(hints); - const indexedFiles = runtimeStatus?.status.files ?? 0; const runtimeAvailable = runtimeStatus !== null; - const reindexStrategy: MemoryIndexStrategy = indexedFiles === 0 ? "full" : "incremental"; + const indexedFiles = runtimeStatus?.status.files ?? 0; + const reindexStrategy: MemoryIndexStrategy = "incremental"; const reindexMode: MemoryReindexMode = isLocalGatewaySession ? "auto" : "manual"; const reindexRequired = Boolean(runtimeStatus?.status.dirty); const hasExternalKnowledge = Boolean( @@ -138,9 +137,7 @@ export function buildMemoryConfigStatusSummary({ : "matched"; const indexCommand = - reindexStrategy === "full" - ? `openclaw memory index --agent ${selectedAgentId || ""} --force` - : `openclaw memory index --agent ${selectedAgentId || ""}`; + `openclaw memory index --agent ${selectedAgentId || ""}`; const statusCommand = `openclaw memory status --agent ${selectedAgentId || ""} --deep --index`; const commandGuide = isOllama @@ -159,9 +156,7 @@ export function buildMemoryConfigStatusSummary({ const commandDescriptionKey = isOllama ? "memory.search.commands.ollama" : isLocalGatewaySession - ? reindexStrategy === "full" - ? "memory.search.commands.localForce" - : "memory.search.commands.localIncremental" + ? "memory.search.commands.localIncremental" : isHosted ? "memory.search.commands.openai" : "memory.search.commands.generic"; diff --git a/src/app/components/views/memoryKnowledgeActions.test.ts b/src/app/components/views/memoryKnowledgeActions.test.ts index 2967b66..eeaea50 100644 --- a/src/app/components/views/memoryKnowledgeActions.test.ts +++ b/src/app/components/views/memoryKnowledgeActions.test.ts @@ -46,6 +46,7 @@ describe("memoryKnowledgeActions", () => { expect(gatewayConfigSetLocal).toHaveBeenCalledWith( "agents.defaults.memorySearch.sources", '["memory","sessions"]', + undefined, ); expect(result.stdout).toBe("ok"); }); @@ -62,19 +63,20 @@ describe("memoryKnowledgeActions", () => { expect(gatewayConfigSetLocal).toHaveBeenCalledWith( "agents.defaults.memorySearch.experimental.sessionMemory", "true", + undefined, ); }); - it("runs reindex with full strategy when requested", async () => { + it("coerces requested full reindex into incremental mode", async () => { vi.mocked(gatewayAgentMemoryIndex).mockResolvedValueOnce({ agentId: "agent-main", - forced: true, + forced: false, stdout: "reindexed", }); const result = await runExternalKnowledgeReindex("agent-main", "full", t); - expect(gatewayAgentMemoryIndex).toHaveBeenCalledWith("agent-main", true); + expect(gatewayAgentMemoryIndex).toHaveBeenCalledWith("agent-main", false, undefined); expect(result.stdout).toBe("reindexed"); }); @@ -87,7 +89,7 @@ describe("memoryKnowledgeActions", () => { const result = await runExternalKnowledgeReindex("agent-main", "incremental", t); - expect(gatewayAgentMemoryIndex).toHaveBeenCalledWith("agent-main", false); + expect(gatewayAgentMemoryIndex).toHaveBeenCalledWith("agent-main", false, undefined); expect(result.stdout).toBe("reindexed incremental"); }); }); diff --git a/src/app/components/views/memoryKnowledgeActions.ts b/src/app/components/views/memoryKnowledgeActions.ts index 7216464..0f76dab 100644 --- a/src/app/components/views/memoryKnowledgeActions.ts +++ b/src/app/components/views/memoryKnowledgeActions.ts @@ -87,9 +87,14 @@ async function runConfigAction( key: string, value: string, t: (key: string, ...args: (string | number)[]) => string, + sessionId?: string, ): Promise { try { - const result: GatewayConfigSetResult = await gatewayConfigSetLocal(key, value); + const result: GatewayConfigSetResult = await gatewayConfigSetLocal( + key, + value, + sessionId, + ); return { kind, stdout: result.stdout, @@ -109,48 +114,56 @@ async function runConfigAction( export async function setExternalKnowledgePaths( paths: string[], t: (key: string, ...args: (string | number)[]) => string, + sessionId?: string, ) { return runConfigAction( "set_extra_paths", "agents.defaults.memorySearch.extraPaths", JSON.stringify(paths), t, + sessionId, ); } export async function setSessionMemoryEnabled( enabled: boolean, t: (key: string, ...args: (string | number)[]) => string, + sessionId?: string, ) { return runConfigAction( "set_session_memory", "agents.defaults.memorySearch.experimental.sessionMemory", enabled ? "true" : "false", t, + sessionId, ); } export async function setExternalKnowledgeSources( sources: string[], t: (key: string, ...args: (string | number)[]) => string, + sessionId?: string, ) { return runConfigAction( "set_sources", "agents.defaults.memorySearch.sources", JSON.stringify(sources), t, + sessionId, ); } export async function runExternalKnowledgeReindex( agentId: string, - strategy: MemoryIndexStrategy, + _strategy: MemoryIndexStrategy, t: (key: string, ...args: (string | number)[]) => string, + sessionId?: string, ): Promise { try { const result: GatewayAgentMemoryIndexResult = await gatewayAgentMemoryIndex( agentId, - strategy === "full", + false, + sessionId, ); return { kind: "reindex", @@ -166,4 +179,3 @@ export async function runExternalKnowledgeReindex( } satisfies MemoryKnowledgeActionFailure; } } - diff --git a/src/app/components/views/memoryKnowledgeReindexState.test.ts b/src/app/components/views/memoryKnowledgeReindexState.test.ts new file mode 100644 index 0000000..a19c1cd --- /dev/null +++ b/src/app/components/views/memoryKnowledgeReindexState.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; + +import { + captureMemoryKnowledgeReindexSnapshot, + describeMemoryKnowledgeReindexDelta, + hasMemoryKnowledgeReindexProgress, + isMemoryKnowledgeReindexSettled, +} from "./memoryKnowledgeReindexState"; + +describe("memoryKnowledgeReindexState", () => { + it("captures a runtime snapshot from config summary and runtime status", () => { + const snapshot = captureMemoryKnowledgeReindexSnapshot({ + statusSummary: { + hasExternalKnowledge: true, + localWritable: true, + reindexRequired: true, + reindexStrategy: "incremental", + reindexMode: "auto", + configuredButNotIndexed: false, + runtimeAvailable: true, + statusKey: "configured_stale", + commandGuide: "", + commandDescriptionKey: "memory.search.commands.localIncremental", + searchAvailabilityReasonKey: "memory.search.reason.stale", + providerAvailabilityReasonKey: "memory.search.providerReason.ready", + runtimeMatchState: "partial", + }, + runtimeStatus: { + agentId: "agent-main", + embeddingOk: true, + vectorOk: true, + status: { + backend: "builtin", + files: 2, + totalFiles: 2, + chunks: 11, + dirty: true, + workspaceDir: null, + dbPath: null, + provider: "openai", + model: "text-embedding-3-large", + requestedProvider: "openai", + sources: ["memory"], + extraPaths: ["D:/shared/notes"], + sourceCounts: [], + }, + rawPayload: "{}", + }, + }); + + expect(snapshot).toEqual({ + runtimeAvailable: true, + files: 2, + chunks: 11, + dirty: true, + runtimeMatchState: "partial", + statusKey: "configured_stale", + }); + }); + + it("detects meaningful runtime progress", () => { + expect( + hasMemoryKnowledgeReindexProgress( + { + runtimeAvailable: true, + files: 1, + chunks: 4, + dirty: true, + runtimeMatchState: "missing", + statusKey: "configured_only", + }, + { + runtimeAvailable: true, + files: 3, + chunks: 9, + dirty: false, + runtimeMatchState: "matched", + statusKey: "configured_indexed", + }, + ), + ).toBe(true); + }); + + it("marks settled only when runtime is clean and matched", () => { + expect( + isMemoryKnowledgeReindexSettled({ + runtimeAvailable: true, + files: 5, + chunks: 18, + dirty: false, + runtimeMatchState: "matched", + statusKey: "configured_indexed", + }), + ).toBe(true); + + expect( + isMemoryKnowledgeReindexSettled({ + runtimeAvailable: true, + files: 5, + chunks: 18, + dirty: true, + runtimeMatchState: "matched", + statusKey: "configured_stale", + }), + ).toBe(false); + }); + + it("describes snapshot deltas for timeline display", () => { + expect( + describeMemoryKnowledgeReindexDelta( + { + runtimeAvailable: true, + files: 0, + chunks: 0, + dirty: true, + runtimeMatchState: "missing", + statusKey: "configured_only", + }, + { + runtimeAvailable: true, + files: 2, + chunks: 8, + dirty: false, + runtimeMatchState: "matched", + statusKey: "configured_indexed", + }, + ), + ).toContain("files 0 -> 2"); + }); +}); diff --git a/src/app/components/views/memoryKnowledgeReindexState.ts b/src/app/components/views/memoryKnowledgeReindexState.ts new file mode 100644 index 0000000..2f8cd79 --- /dev/null +++ b/src/app/components/views/memoryKnowledgeReindexState.ts @@ -0,0 +1,115 @@ +import type { + GatewayAgentMemoryResult, + GatewayAgentMemoryRuntimeStatusResult, + GatewayAgentMemoryStatusResult, +} from "../../contexts/OpenClawContext"; +import type { MemoryConfigStatusSummary } from "./memoryConfigStatus"; + +export type MemoryKnowledgeRefreshResult = { + memoryResult: GatewayAgentMemoryResult; + memoryStatus: GatewayAgentMemoryStatusResult | null; + runtimeStatus: GatewayAgentMemoryRuntimeStatusResult | null; +}; + +export type MemoryKnowledgeReindexSnapshot = { + runtimeAvailable: boolean; + files: number | null; + chunks: number | null; + dirty: boolean | null; + runtimeMatchState: MemoryConfigStatusSummary["runtimeMatchState"]; + statusKey: MemoryConfigStatusSummary["statusKey"]; +}; + +export type MemoryKnowledgeReindexPhase = + | "starting" + | "running" + | "syncing" + | "settled" + | "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, +}: { + statusSummary: MemoryConfigStatusSummary; + runtimeStatus: GatewayAgentMemoryRuntimeStatusResult | null; +}): MemoryKnowledgeReindexSnapshot { + return { + runtimeAvailable: runtimeStatus !== null, + files: runtimeStatus?.status.files ?? null, + chunks: runtimeStatus?.status.chunks ?? null, + dirty: runtimeStatus?.status.dirty ?? null, + runtimeMatchState: statusSummary.runtimeMatchState, + statusKey: statusSummary.statusKey, + }; +} + +export function hasMemoryKnowledgeReindexProgress( + before: MemoryKnowledgeReindexSnapshot, + after: MemoryKnowledgeReindexSnapshot, +) { + return ( + before.runtimeAvailable !== after.runtimeAvailable || + before.files !== after.files || + before.chunks !== after.chunks || + before.dirty !== after.dirty || + before.runtimeMatchState !== after.runtimeMatchState || + before.statusKey !== after.statusKey + ); +} + +export function isMemoryKnowledgeReindexSettled( + snapshot: MemoryKnowledgeReindexSnapshot, +) { + return ( + snapshot.runtimeAvailable && + snapshot.dirty === false && + snapshot.runtimeMatchState === "matched" + ); +} + +export function describeMemoryKnowledgeReindexDelta( + before: MemoryKnowledgeReindexSnapshot, + after: MemoryKnowledgeReindexSnapshot, +) { + const changes: string[] = []; + + if (before.files !== after.files && after.files !== null) { + changes.push(`files ${before.files ?? 0} -> ${after.files}`); + } + if (before.chunks !== after.chunks && after.chunks !== null) { + changes.push(`chunks ${before.chunks ?? 0} -> ${after.chunks}`); + } + if (before.dirty !== after.dirty && after.dirty !== null) { + changes.push(`dirty ${before.dirty === null ? "?" : before.dirty ? "yes" : "no"} -> ${after.dirty ? "yes" : "no"}`); + } + if (before.runtimeMatchState !== after.runtimeMatchState) { + changes.push(`match ${before.runtimeMatchState} -> ${after.runtimeMatchState}`); + } + + return changes.join(" · "); +} diff --git a/src/app/components/views/memoryNodeState.test.ts b/src/app/components/views/memoryNodeState.test.ts new file mode 100644 index 0000000..8dcc235 --- /dev/null +++ b/src/app/components/views/memoryNodeState.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; + +import { + buildMemoryNodeEntries, + isLocalNodeOrigin, + resolveMemorySessionIdToActivate, + resolveSelectedMemoryAgentIdForNode, + resolveSelectedMemoryNodeId, +} from "./memoryNodeState"; + +describe("memoryNodeState", () => { + const nodes = [ + { + id: "gateway:http://127.0.0.1:3100", + name: "OpenClaw Local", + status: "online" as const, + sessionId: "session-local", + origin: "http://127.0.0.1:3100", + grantedScopes: ["operator.admin"], + isActive: true, + }, + { + id: "gateway:http://192.168.1.8:3100", + name: "OpenClaw 192.168.1.8:3100", + status: "online" as const, + sessionId: "session-lan", + origin: "http://192.168.1.8:3100", + grantedScopes: ["operator.read"], + isActive: false, + }, + ]; + + const agents = [ + { + id: "agent-alpha", + name: "Alpha", + nodeId: "gateway:http://127.0.0.1:3100", + status: "active" as const, + }, + { + id: "agent-beta", + name: "Beta", + nodeId: "gateway:http://127.0.0.1:3100", + status: "standby" as const, + }, + ]; + + it("keeps visible nodes even when a node has no current agent roster", () => { + const entries = buildMemoryNodeEntries({ + isConnected: true, + nodes, + agents, + }); + + expect(entries).toHaveLength(2); + expect(entries[1]?.id).toBe("gateway:http://192.168.1.8:3100"); + expect(entries[1]?.agents).toHaveLength(0); + }); + + it("falls back to the first node with agents when current node is missing", () => { + const entries = buildMemoryNodeEntries({ + isConnected: true, + nodes, + agents, + }); + + expect(resolveSelectedMemoryNodeId("missing", entries)).toBe( + "gateway:http://127.0.0.1:3100", + ); + }); + + it("returns empty agent id when selected node has no agents", () => { + const entries = buildMemoryNodeEntries({ + isConnected: true, + nodes, + agents, + }); + + expect( + resolveSelectedMemoryAgentIdForNode( + "agent-alpha", + "gateway:http://192.168.1.8:3100", + entries, + ), + ).toBe(""); + }); + + it("returns a session to activate only for inactive nodes with a session", () => { + const entries = buildMemoryNodeEntries({ + isConnected: true, + nodes, + agents, + }); + + expect( + resolveMemorySessionIdToActivate( + "gateway:http://192.168.1.8:3100", + entries, + ), + ).toBe("session-lan"); + expect( + resolveMemorySessionIdToActivate( + "gateway:http://127.0.0.1:3100", + entries, + ), + ).toBeNull(); + }); + + it("detects loopback node origins", () => { + expect(isLocalNodeOrigin("http://127.0.0.1:3100")).toBe(true); + expect(isLocalNodeOrigin("ws://localhost:4100")).toBe(true); + expect(isLocalNodeOrigin("http://192.168.1.8:3100")).toBe(false); + }); +}); diff --git a/src/app/components/views/memoryNodeState.ts b/src/app/components/views/memoryNodeState.ts new file mode 100644 index 0000000..45ba0e0 --- /dev/null +++ b/src/app/components/views/memoryNodeState.ts @@ -0,0 +1,115 @@ +import type { Agent, Node } from "../../contexts/OpenClawContext"; + +export interface MemoryNodeEntry { + id: string; + name: string; + status: Node["status"]; + sessionId?: string; + origin?: string | null; + grantedScopes?: string[]; + isActive?: boolean; + agents: Agent[]; +} + +function deriveNodeStatus(agents: Agent[]): Node["status"] { + return agents.some((agent) => agent.status !== "sleeping") ? "online" : "offline"; +} + +export function buildMemoryNodeEntries({ + isConnected, + nodes, + agents, +}: { + isConnected: boolean; + nodes: Node[]; + agents: Agent[]; +}) { + const groupedAgents = agents.reduce( + (acc, agent) => { + if (!acc[agent.nodeId]) { + acc[agent.nodeId] = []; + } + acc[agent.nodeId].push(agent); + return acc; + }, + {} as Record, + ); + + if (!isConnected && nodes.length === 0 && agents.length === 0) { + return [] as MemoryNodeEntry[]; + } + + const nodeEntries = nodes.map((node) => ({ + id: node.id, + name: node.name, + status: node.status, + sessionId: node.sessionId, + origin: node.origin, + grantedScopes: node.grantedScopes ?? [], + isActive: node.isActive, + agents: groupedAgents[node.id] ?? [], + })); + + const derivedNodeEntries = Object.entries(groupedAgents) + .filter(([nodeId]) => !nodes.some((node) => node.id === nodeId)) + .map(([nodeId, nodeAgents]) => ({ + id: nodeId, + name: nodeId, + status: deriveNodeStatus(nodeAgents), + sessionId: undefined, + origin: null, + grantedScopes: [], + isActive: undefined, + agents: nodeAgents, + })); + + return [...nodeEntries, ...derivedNodeEntries]; +} + +export function resolveSelectedMemoryNodeId( + selectedNodeId: string, + nodeEntries: MemoryNodeEntry[], +) { + if (nodeEntries.some((entry) => entry.id === selectedNodeId)) { + return selectedNodeId; + } + + return nodeEntries.find((entry) => entry.agents.length > 0)?.id ?? nodeEntries[0]?.id ?? ""; +} + +export function resolveSelectedMemoryAgentIdForNode( + selectedAgentId: string, + selectedNodeId: string, + nodeEntries: MemoryNodeEntry[], +) { + const nodeEntry = nodeEntries.find((entry) => entry.id === selectedNodeId); + if (!nodeEntry || nodeEntry.agents.length === 0) { + return ""; + } + + if (nodeEntry.agents.some((agent) => agent.id === selectedAgentId)) { + return selectedAgentId; + } + + return nodeEntry.agents[0]?.id ?? ""; +} + +export function resolveMemorySessionIdToActivate( + selectedNodeId: string, + nodeEntries: MemoryNodeEntry[], +) { + const nodeEntry = nodeEntries.find((entry) => entry.id === selectedNodeId); + if (!nodeEntry?.sessionId || nodeEntry.isActive) { + return null; + } + + return nodeEntry.sessionId; +} + +export function isLocalNodeOrigin(origin?: string | null) { + if (!origin) { + return false; + } + + return /^(ws|http):\/\/(127\.0\.0\.1|localhost)(:\d+)?$/i.test(origin); +} diff --git a/src/app/components/views/profileNodeState.test.ts b/src/app/components/views/profileNodeState.test.ts index 0dcb240..216551c 100644 --- a/src/app/components/views/profileNodeState.test.ts +++ b/src/app/components/views/profileNodeState.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + buildVisibleProfileNodeEntries, groupAgentsByNode, resolveSelectedAgentIdForNode, resolveSelectedProfileNodeName, @@ -52,4 +53,33 @@ describe("profileNodeState", () => { expect(resolveSelectedAgentIdForNode("agent-a", [])).toBe(""); }); + + it("keeps real nodes visible even when no agents are currently loaded for that session", () => { + const visibleNodes = buildVisibleProfileNodeEntries([ + { + id: "gateway:ws://127.0.0.1:18789", + name: "OpenClaw Local", + status: "online" as const, + sessionId: "ws://127.0.0.1:18789", + isActive: true, + }, + { + id: "gateway:ws://192.168.1.112:18789", + name: "OpenClaw 192.168.1.112:18789", + status: "online" as const, + sessionId: "ws://192.168.1.112:18789", + isActive: false, + }, + ], groupAgentsByNode([ + { id: "agent-a", node: "OpenClaw Local" }, + ])); + + expect(visibleNodes.map((entry) => entry.name)).toEqual([ + "OpenClaw Local", + "OpenClaw 192.168.1.112:18789", + ]); + expect(visibleNodes[0]?.agentCount).toBe(1); + expect(visibleNodes[1]?.agentCount).toBe(0); + expect(visibleNodes[1]?.sessionId).toBe("ws://192.168.1.112:18789"); + }); }); diff --git a/src/app/components/views/profileNodeState.ts b/src/app/components/views/profileNodeState.ts index db3c0ca..25eb533 100644 --- a/src/app/components/views/profileNodeState.ts +++ b/src/app/components/views/profileNodeState.ts @@ -1,3 +1,5 @@ +import type { Node } from "../../contexts/OpenClawContext"; + export function groupAgentsByNode(agents: T[]) { return agents.reduce( (acc, agent) => { @@ -32,3 +34,41 @@ export function resolveSelectedAgentIdForNode( return nodeAgents[0]?.id ?? ""; } + +export interface ProfileNodeEntry { + id: string; + name: string; + status: Node["status"]; + sessionId?: string; + isActive?: boolean; + agentCount: number; +} + +export function buildVisibleProfileNodeEntries( + nodes: Pick[], + groupedAgents: Record, +) { + const entries: ProfileNodeEntry[] = nodes.map((node) => ({ + id: node.id, + name: node.name, + status: node.status, + sessionId: node.sessionId, + isActive: node.isActive, + agentCount: groupedAgents[node.name]?.length ?? 0, + })); + + for (const [nodeName, nodeAgents] of Object.entries(groupedAgents)) { + if (entries.some((entry) => entry.name === nodeName)) { + continue; + } + + entries.push({ + id: nodeName, + name: nodeName, + status: "online", + agentCount: nodeAgents.length, + }); + } + + return entries; +} diff --git a/src/app/contexts/I18nContext.tsx b/src/app/contexts/I18nContext.tsx index eafbc23..cfdaa5f 100644 --- a/src/app/contexts/I18nContext.tsx +++ b/src/app/contexts/I18nContext.tsx @@ -2515,8 +2515,22 @@ const BASE_DICT: Record = { "memory.knowledge.reindexClean": ["Index state looks clean", "索引状态正常", "索引狀態正常"], "memory.knowledge.present": ["Configured", "已配置", "已配置"], "memory.knowledge.none": ["None", "无", "無"], + "memory.knowledge.nodeScope": ["Selected node", "当前节点", "目前節點"], + "memory.knowledge.nodeScopeFallback": ["Node unresolved", "节点未解析", "節點未解析"], + "memory.knowledge.sessionScopeFallback": ["Session unresolved", "session 未解析", "session 未解析"], "memory.knowledge.externalPaths": ["Extra paths", "额外路径", "額外路徑"], "memory.knowledge.externalPathsDesc": ["Shared paths included in recall, but not canonical memory roots.", "这些路径会进入检索,但不是 canonical memory 真源。", "這些路徑會進入檢索,但不是 canonical memory 真源。"], + "memory.knowledge.extraPathsGuideTitle": ["What does extra path mean?", "额外路径是什么意思?", "額外路徑是什麼意思?"], + "memory.knowledge.extraPathsGuideDesc": ["Extra paths point to shared knowledge folders that should join recall. They are retrieval inputs only, not replacements for MEMORY.md or daily memory roots.", "额外路径指向“要一起参与 recall 的共享知识目录”。它们只是检索输入,不会替代 MEMORY.md 或 daily memory 真源。", "額外路徑指向「要一起參與 recall 的共享知識目錄」。它們只是檢索輸入,不會取代 MEMORY.md 或 daily memory 真源。"], + "memory.knowledge.extraPathsDoTitle": ["Recommended input", "推荐这样填", "建議這樣填"], + "memory.knowledge.extraPathsDoExample": ["Example: `D:/team-knowledge/playbooks` or a folder of shared Markdown notes.", "示例:`D:/team-knowledge/playbooks`,或一整个共享 Markdown 知识目录。", "範例:`D:/team-knowledge/playbooks`,或一整個共享 Markdown 知識目錄。"], + "memory.knowledge.extraPathsDontTitle": ["Do not add", "不要这样填", "不要這樣填"], + "memory.knowledge.extraPathsDontExample": ["Do not add SQLite stores, `qmd/`, `sessions/`, or runtime cache directories.", "不要添加 SQLite 库、`qmd/`、`sessions/` 或 runtime cache 目录。", "不要加入 SQLite 庫、`qmd/`、`sessions/` 或 runtime cache 目錄。"], + "memory.knowledge.howToTitle": ["How to use it", "操作指引", "操作指引"], + "memory.knowledge.howToStep1": ["Prepare one stable shared folder and put reusable documents there.", "先准备一个稳定的共享目录,把可复用文档集中放进去。", "先準備一個穩定的共享目錄,把可重用文件集中放進去。"], + "memory.knowledge.howToStep2": ["Paste that folder path into `Extra paths`, then save the config.", "把这个目录路径填到 `额外路径`,然后保存配置。", "把這個目錄路徑填到 `額外路徑`,然後儲存設定。"], + "memory.knowledge.howToStep3": ["Run the incremental reindex shown below, or let the local auto-follow-up finish.", "执行下面的增量索引,或者等待本地自动补的 reindex 完成。", "執行下方的增量索引,或等待本地自動補上的 reindex 完成。"], + "memory.knowledge.howToStep4": ["Check `Runtime matches configured inputs` before assuming search has really picked up the new folder.", "看到 `runtime 已与配置对齐` 之后,再认为搜索真正接入了这个目录。", "看到 `runtime 已與設定對齊` 之後,再判定搜尋真的接入了這個目錄。"], "memory.knowledge.qmdPaths": ["QMD paths", "QMD 路径", "QMD 路徑"], "memory.knowledge.qmdPathsDesc": ["QMD-related sidecar paths observed from diagnostics.", "从 diagnostics 观测到的 QMD sidecar 路径。", "從 diagnostics 觀測到的 QMD sidecar 路徑。"], "memory.knowledge.sessionRetrieval": ["Session retrieval", "会话检索", "會話檢索"], @@ -2524,6 +2538,8 @@ const BASE_DICT: Record = { "memory.knowledge.sectionEmpty": ["No entries in this section yet.", "这个分组下暂时没有条目。", "這個分組下暫時沒有條目。"], "memory.knowledge.sessionMemoryEnabled": ["Session memory enabled", "已开启会话记忆", "已開啟會話記憶"], "memory.knowledge.sessionMemoryDisabled": ["Session memory disabled", "未开启会话记忆", "未開啟會話記憶"], + "memory.knowledge.sourceMemoryLabel": ["Canonical memory", "主记忆文档", "主記憶文件"], + "memory.knowledge.sourceSessionsLabel": ["Session transcripts", "会话转录", "會話轉錄"], "memory.knowledge.sessionsSourceEnabled": ["`sessions` source is enabled for recall.", "`sessions` source 已纳入 recall。", "`sessions` source 已納入 recall。"], "memory.knowledge.sessionsSourceMissing": ["Session memory is configured, but `sessions` is not in sources.", "已配置 session memory,但 `sessions` 尚未进入 sources。", "已配置 session memory,但 `sessions` 尚未進入 sources。"], "memory.knowledge.qmdActive": ["QMD backend is active.", "QMD backend 当前已启用。", "QMD backend 目前已啟用。"], @@ -2545,6 +2561,7 @@ const BASE_DICT: Record = { "memory.knowledge.pathBlocked": ["This path looks like runtime/index state and should not be added as external knowledge.", "这个路径看起来像 runtime/index 状态目录,不应作为 external knowledge 接入。", "這個路徑看起來像 runtime/index 狀態目錄,不應作為 external knowledge 接入。"], "memory.knowledge.pathAdded": ["External path added.", "已新增 external path。", "已新增 external path。"], "memory.knowledge.pathRemoved": ["External path removed.", "已移除 external path。", "已移除 external path。"], + "memory.knowledge.pathInputHint": ["Use a folder path, not a single cache file. If the folder is worth searching but not worth editing as canonical memory, it belongs here.", "这里更适合填“目录路径”,而不是单个缓存文件。凡是“值得被搜索,但不应该当 canonical memory 直接编辑”的共享资料,才放这里。", "這裡更適合填「目錄路徑」,而不是單一快取文件。凡是「值得被搜尋,但不應該當 canonical memory 直接編輯」的共享資料,才放在這裡。"], "memory.knowledge.configUpdated": ["Configuration updated. Recheck index status next.", "配置已更新,下一步请确认索引状态。", "設定已更新,下一步請確認索引狀態。"], "memory.knowledge.recallControls": ["Recall controls", "Recall 开关", "Recall 開關"], "memory.knowledge.sessionMemoryLabel": ["Session memory", "会话记忆", "會話記憶"], @@ -2560,6 +2577,56 @@ const BASE_DICT: Record = { "memory.knowledge.sourcesUpdated": ["Recall sources updated.", "Recall sources 已更新。", "Recall sources 已更新。"], "memory.knowledge.reindexCard": ["Reindex status", "索引闭环", "索引閉環"], "memory.knowledge.reindexNow": ["Reindex now", "立即重建索引", "立即重建索引"], + "memory.knowledge.reindexLive.title": ["Reindex activity", "索引过程", "索引過程"], + "memory.knowledge.reindexLive.desc": ["This panel tracks the command, runtime polling, and final convergence of the current reindex task.", "这里会持续展示本次索引命令、runtime 轮询和最终收口情况。", "這裡會持續顯示本次索引命令、runtime 輪詢與最終收口情況。"], + "memory.knowledge.reindexLive.taskbarTitle": ["Reindex task", "后台索引任务", "背景索引任務"], + "memory.knowledge.reindexLive.phase.starting": ["Starting", "已发起", "已發起"], + "memory.knowledge.reindexLive.phase.running": ["Running", "执行中", "執行中"], + "memory.knowledge.reindexLive.phase.syncing": ["Syncing runtime", "同步运行态", "同步執行態"], + "memory.knowledge.reindexLive.phase.settled": ["Settled", "已收口", "已收口"], + "memory.knowledge.reindexLive.phase.warning": ["Needs attention", "仍需关注", "仍需關注"], + "memory.knowledge.reindexLive.phase.failed": ["Failed", "失败", "失敗"], + "memory.knowledge.reindexLive.elapsedLabel": ["Elapsed", "耗时", "耗時"], + "memory.knowledge.reindexLive.elapsedValue": ["{0}s", "{0} 秒", "{0} 秒"], + "memory.knowledge.reindexLive.pollsLabel": ["Runtime polls", "轮询次数", "輪詢次數"], + "memory.knowledge.reindexLive.afterCommandPolls": ["{0} checks after command", "命令结束后检查 {0} 次", "命令結束後檢查 {0} 次"], + "memory.knowledge.reindexLive.lastCheckedLabel": ["Last checked", "最近检查", "最近檢查"], + "memory.knowledge.reindexLive.lastCheckedValue": ["{0}s ago", "{0} 秒前", "{0} 秒前"], + "memory.knowledge.reindexLive.pendingCheck": ["Waiting for first refresh", "等待首次刷新", "等待首次刷新"], + "memory.knowledge.reindexLive.commandLabel": ["CLI command", "命令状态", "命令狀態"], + "memory.knowledge.reindexLive.commandRunning": ["Still running", "仍在执行", "仍在執行"], + "memory.knowledge.reindexLive.commandFinished": ["Finished", "已完成", "已完成"], + "memory.knowledge.reindexLive.commandPending": ["Waiting for command output", "等待命令输出", "等待命令輸出"], + "memory.knowledge.reindexLive.beforeLabel": ["Before reindex", "索引前", "索引前"], + "memory.knowledge.reindexLive.latestLabel": ["Latest runtime", "最新运行态", "最新執行態"], + "memory.knowledge.reindexLive.snapshotFiles": ["{0} indexed files", "{0} 个已索引文件", "{0} 個已索引文件"], + "memory.knowledge.reindexLive.snapshotChunks": ["{0} chunks", "{0} 个分块", "{0} 個分塊"], + "memory.knowledge.reindexLive.snapshotDirty": ["Dirty: {0}", "脏状态:{0}", "髒狀態:{0}"], + "memory.knowledge.reindexLive.syncIssue": ["Runtime refresh issue:", "运行态刷新异常:", "執行態刷新異常:"], + "memory.knowledge.reindexLive.atSeconds": ["+{0}s", "+{0} 秒", "+{0} 秒"], + "memory.knowledge.reindexLive.event.submitted": ["Sent incremental reindex request.", "已发送增量索引命令。", "已送出增量索引命令。"], + "memory.knowledge.reindexLive.event.autoSubmitted": ["Config updated. Auto reindex has started.", "配置已更新,自动索引已启动。", "設定已更新,自動索引已啟動。"], + "memory.knowledge.reindexLive.event.submittedDetail": ["ClawScope will keep polling runtime status while the command runs.", "ClawScope 会在命令执行期间持续轮询 runtime 状态。", "ClawScope 會在命令執行期間持續輪詢 runtime 狀態。"], + "memory.knowledge.reindexLive.event.progress": ["Observed runtime changes.", "已观察到 runtime 变化。", "已觀察到 runtime 變化。"], + "memory.knowledge.reindexLive.event.commandDone": ["Index command finished. Verifying runtime convergence.", "索引命令已完成,正在验证 runtime 是否收口。", "索引命令已完成,正在驗證 runtime 是否收口。"], + "memory.knowledge.reindexLive.event.settled": ["Runtime converged successfully.", "runtime 已成功收口。", "runtime 已成功收口。"], + "memory.knowledge.reindexLive.event.warning": ["Command finished, but runtime has not fully converged yet.", "命令已完成,但 runtime 还没有完全收口。", "命令已完成,但 runtime 還沒有完全收口。"], + "memory.knowledge.reindexLive.event.failed": ["Reindex command failed.", "索引命令执行失败。", "索引命令執行失敗。"], + "memory.knowledge.reindexLive.event.refreshFailed": ["Could not refresh runtime status during reindex.", "索引过程中刷新 runtime 状态失败。", "索引過程中刷新 runtime 狀態失敗。"], + "memory.knowledge.reindexLive.noDelta": ["No runtime change observed yet.", "暂时还没观察到 runtime 变化。", "暫時還沒觀察到 runtime 變化。"], + "memory.knowledge.reindexLive.noRefreshPayload": ["Refresh returned no knowledge payload.", "刷新时没有拿到有效的知识页数据。", "刷新時沒有拿到有效的知識頁資料。"], + "memory.knowledge.reindexLive.commandAccepted": ["Reindex command accepted. Runtime verification continues in the panel below.", "索引命令已发出,下面的过程卡会继续验证 runtime 收口。", "索引命令已送出,下方的過程卡會繼續驗證 runtime 收口。"], + "memory.knowledge.reindexLive.syncingFeedback": ["Index command finished. ClawScope is still checking whether runtime has caught up.", "索引命令已完成,ClawScope 正在继续检查 runtime 是否已跟上。", "索引命令已完成,ClawScope 正在繼續檢查 runtime 是否已跟上。"], + "memory.knowledge.reindexLive.settledFeedback": ["Runtime is now aligned with the latest reindex run.", "runtime 已与本次索引结果对齐。", "runtime 已與本次索引結果對齊。"], + "memory.knowledge.reindexLive.warningFeedback": ["The command finished, but runtime is still not fully aligned. Review the latest runtime snapshot below.", "命令已完成,但 runtime 仍未完全对齐,请检查下面的最新运行态。", "命令已完成,但 runtime 仍未完全對齊,請檢查下方的最新執行態。"], + "memory.knowledge.reindexLive.failedFeedback": ["Reindex stopped with an error. Check the activity timeline below.", "索引过程以错误结束,请查看下面的过程时间线。", "索引過程以錯誤結束,請查看下方的過程時間線。"], + "memory.knowledge.reindexLive.retry": ["Retry reindex", "重试索引", "重試索引"], + "memory.knowledge.reindexLive.openDiagnostics": ["Open diagnostics", "打开诊断", "打開診斷"], + "memory.knowledge.reindexLive.collapse": ["Collapse details", "收起详情", "收起詳情"], + "memory.knowledge.reindexLive.expand": ["Expand details", "展开详情", "展開詳情"], + "memory.knowledge.incrementalOnlyTitle": ["Incremental index only", "仅允许增量索引", "僅允許增量索引"], + "memory.knowledge.incrementalOnlyDesc": ["Full reindex is intentionally disabled in this page because it is too easy to freeze the host. Every config follow-up here is incremental-only.", "这个页面故意禁用了全量索引,因为它太容易把宿主机拖死。这里所有配置后的收口动作都只走增量索引。", "這個頁面刻意停用了全量索引,因為它太容易把主機拖死。這裡所有設定後的收口動作都只走增量索引。"], + "memory.knowledge.incrementalOnlyInline": ["This workflow never issues `openclaw memory index --force`; only incremental indexing is allowed here.", "这个工作流不会发出 `openclaw memory index --force`;这里始终只允许增量索引。", "這個工作流不會發出 `openclaw memory index --force`;這裡始終只允許增量索引。"], "memory.knowledge.copyRemoteGuide": ["Copy remote guide", "复制远程命令", "複製遠端命令"], "memory.knowledge.remoteReadonlyDetail": ["This session can inspect external knowledge state, but all writes stay disabled until you switch back to the OpenClaw host connection.", "当前会话只能查看 external knowledge 状态,所有写操作都保持禁用,直到你切回 OpenClaw 宿主机连接。", "目前工作階段只能查看 external knowledge 狀態,所有寫入操作都會保持停用,直到你切回 OpenClaw 主機連線。"], "memory.knowledge.remoteCommandHint": ["Run these commands on the OpenClaw host to apply the same changes remotely.", "如果要在远程环境生效,请在 OpenClaw 宿主机执行下面这些命令。", "如果要在遠端環境生效,請在 OpenClaw 主機上執行下面這些命令。"], @@ -2867,9 +2934,119 @@ 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.deviceTokenReady": [ + "Device token received. Click Save to finish setup.", + "已拿到 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", + "已配对网关", + "已配對網關", + ], + "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", @@ -3668,7 +3845,44 @@ const BASE_DICT: Record = { "当前填写的 Gateway Token 与宿主机配置不一致。请先在宿主机核对 token,再回到这里重新测试连接。", "目前填寫的 Gateway Token 與主機設定不一致。請先在主機核對 token,再回到這裡重新測試連線。", ], + "setup.pairing.requestNotQueuedTitle": [ + "This pairing attempt did not reach the host approval queue", + "这次配对尝试没有进入宿主机待批准队列", + "這次配對嘗試沒有進入主機待批准佇列", + ], + "setup.pairing.requestNotQueuedDesc": [ + "If `openclaw devices approve --latest` returns `no device`, this attempt was rejected before host approval. Check the Gateway URL and Gateway Token first, then start pairing again.", + "如果宿主机执行 `openclaw devices approve --latest` 返回 `no device`,说明这次尝试在进入宿主机批准队列之前就被拒绝了。请先检查 Gateway URL 和 Gateway Token,再重新开始配对。", + "如果主機執行 `openclaw devices approve --latest` 回傳 `no device`,表示這次嘗試在進入主機批准佇列之前就被拒絕了。請先檢查 Gateway URL 和 Gateway Token,再重新開始配對。", + ], + "setup.pairing.connectedWithoutPairingTitle": [ + "Connection succeeded, but first pairing is not complete yet", + "连接成功,但首次配对尚未完成", + "連線成功,但首次配對尚未完成", + ], + "setup.pairing.connectedWithoutPairingDesc": [ + "This attempt established a shared-token connection, but the gateway did not return a device token. Stay on this step and do not continue yet. If the host shows `openclaw devices approve --latest` -> `no pending device`, the request never entered the approval queue.", + "这次尝试只建立了共享 token 连接,但网关没有返回 device token。请停留在当前步骤,不要继续下一页。如果宿主机执行 `openclaw devices approve --latest` 后显示 `no pending device`,说明这次请求根本没有进入待批准配对队列。", + "這次嘗試只建立了共享 token 連線,但網關沒有回傳 device token。請停留在目前步驟,不要繼續下一頁。如果主機執行 `openclaw devices approve --latest` 後顯示 `no pending device`,表示這次請求根本沒有進入待批准配對佇列。", + ], + "setup.pairing.loopbackTitle": [ + "Loopback pairing is unavailable here", + "当前不支持本机 loopback 配对", + "目前不支援本機 loopback 配對", + ], + "setup.pairing.loopbackDesc": [ + "For `127.0.0.1` / `localhost`, use Gateway Token or Password for local access. If you need device pairing, switch this URL to a LAN IP first.", + "对于 `127.0.0.1` / `localhost`,请使用 Gateway Token 或 Password 做本地连接;如需设备配对,请先把当前地址切换为局域网 IP。", + "對於 `127.0.0.1` / `localhost`,請使用 Gateway Token 或 Password 做本機連線;如需裝置配對,請先把目前位址切換為區域網路 IP。", + ], + "memory.header.nodes": ["Nodes", "节点", "節點"], "memory.header.agents": ["Agents", "Agents", "Agents"], + "memory.header.noAgents": ["No agents on this node", "该节点暂无 agent", "此節點目前沒有 agent"], + "memory.node.empty": [ + "Node \"{0}\" is visible, but it does not expose an agent roster yet. Switch to another node or wait for the session to finish loading.", + "节点 “{0}” 已可见,但当前还没有 agent roster。请切到其他节点,或等待该 session 完成加载。", + "節點「{0}」已可見,但目前還沒有 agent roster。請切到其他節點,或等待該 session 完成載入。" + ], "memory.overview.pending": [ "Overview is waiting for agent resolution and the first batch of memory data.", "Overview 正在等待 agent 解析与首批记忆数据初始化。", diff --git a/src/app/contexts/OpenClawContext.tsx b/src/app/contexts/OpenClawContext.tsx index 04643f2..a9e1f35 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, @@ -1207,8 +1241,13 @@ export async function gatewayAgentMemoryIndex( }); } -export async function gatewayConfigSetLocal(key: string, value: string) { +export async function gatewayConfigSetLocal( + key: string, + value: string, + sessionId?: string, +) { return invokeGateway('gateway_config_set_local', { + sessionId, key, value, }); diff --git a/src/app/contexts/openClawConnectionPolicy.test.ts b/src/app/contexts/openClawConnectionPolicy.test.ts index e9bfa3b..0e5e670 100644 --- a/src/app/contexts/openClawConnectionPolicy.test.ts +++ b/src/app/contexts/openClawConnectionPolicy.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'; import { isLoopbackGatewayUrl, + resolveAuthModeForGatewayUrl, resolvePersistedAuthModeAfterConnect, + shouldAllowPairingUiForGatewayUrl, shouldRetryWithPairedDeviceOnLocalGateway, } from './openClawConnectionPolicy'; @@ -18,6 +20,19 @@ describe('openClawConnectionPolicy', () => { expect(isLoopbackGatewayUrl('not-a-url')).toBe(false); }); + it('disables pairing ui for loopback gateway urls and keeps it for lan/remote urls', () => { + expect(shouldAllowPairingUiForGatewayUrl('http://127.0.0.1:18789')).toBe(false); + expect(shouldAllowPairingUiForGatewayUrl('http://localhost:18789')).toBe(false); + expect(shouldAllowPairingUiForGatewayUrl('http://[::1]:18789')).toBe(false); + expect(shouldAllowPairingUiForGatewayUrl('http://192.168.1.112:18789')).toBe(true); + }); + + it('coerces paired_device auth back to token on loopback urls', () => { + expect(resolveAuthModeForGatewayUrl('http://127.0.0.1:18789', 'paired_device')).toBe('token'); + expect(resolveAuthModeForGatewayUrl('http://localhost:18789', 'password')).toBe('password'); + expect(resolveAuthModeForGatewayUrl('http://192.168.1.112:18789', 'paired_device')).toBe('paired_device'); + }); + it('retries local token auth mismatches with paired device mode', () => { expect( shouldRetryWithPairedDeviceOnLocalGateway('http://127.0.0.1:18789', 'token', { @@ -50,7 +65,7 @@ describe('openClawConnectionPolicy', () => { ).toBe(false); }); - it('persists loopback token success as paired device when device auth is available', () => { + it('keeps explicit loopback token auth even when session reports paired', () => { expect( resolvePersistedAuthModeAfterConnect( 'http://127.0.0.1:18789', @@ -59,8 +74,8 @@ describe('openClawConnectionPolicy', () => { { isPaired: true }, ), ).toEqual({ - mode: 'paired_device', - secret: '', + mode: 'token', + secret: 'shared-token', }); }); @@ -78,6 +93,34 @@ 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('clears paired_device bootstrap secret after pairing is completed', () => { + expect( + resolvePersistedAuthModeAfterConnect( + 'http://127.0.0.1:18789', + 'paired_device', + 'shared-token', + { isPaired: true }, + ), + ).toEqual({ + mode: 'paired_device', + secret: '', + }); + }); + 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..88db32f 100644 --- a/src/app/contexts/openClawConnectionPolicy.ts +++ b/src/app/contexts/openClawConnectionPolicy.ts @@ -33,6 +33,18 @@ export function isLoopbackGatewayUrl(url: string) { } } +export function shouldAllowPairingUiForGatewayUrl(url: string) { + return !isLoopbackGatewayUrl(url); +} + +export function resolveAuthModeForGatewayUrl(url: string, mode: AuthMode): AuthMode { + if (isLoopbackGatewayUrl(url) && mode === 'paired_device') { + return 'token'; + } + + return mode; +} + export function shouldRetryWithPairedDeviceOnLocalGateway( url: string, mode: AuthMode, @@ -50,12 +62,12 @@ export function shouldRetryWithPairedDeviceOnLocalGateway( } export function resolvePersistedAuthModeAfterConnect( - url: string, + _url: string, requestedMode: AuthMode, requestedSecret: string, snapshot?: GatewayConnectionSnapshotLike | null, ) { - if (isLoopbackGatewayUrl(url) && requestedMode !== 'paired_device' && snapshot?.isPaired) { + if (requestedMode === 'paired_device' && snapshot?.isPaired) { return { mode: 'paired_device' as const, secret: '', @@ -64,6 +76,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; }