From dce4753488fe2264ebfb64315ef2854481f00b87 Mon Sep 17 00:00:00 2001 From: coso Date: Tue, 10 Mar 2026 11:34:55 +0800 Subject: [PATCH] feat: add OpenClaw commands and improve workbench functionality - Add OpenClaw command registration in runner.rs - Improve MarkdownRenderer with tests - Enhance video canvas and sidebar components - Update workbench controller and quick actions - Add video theme panel renderers - Update OpenClaw pages and types --- src-tauri/build.rs | 11 +- src-tauri/crates/agent/src/event_converter.rs | 262 +++- src-tauri/src/app/runner.rs | 3 + src-tauri/src/commands/openclaw_cmd.rs | 32 +- src-tauri/src/dev_bridge/dispatcher.rs | 36 + src-tauri/src/services/openclaw_service.rs | 1240 +++++++++++++++-- src/App.tsx | 6 + .../chat/components/MarkdownRenderer.test.tsx | 108 ++ .../chat/components/MarkdownRenderer.tsx | 28 +- .../agent/chat/hooks/useAsterAgentChat.ts | 83 +- src/components/agent/chat/index.test.tsx | 23 + src/components/agent/chat/index.tsx | 100 +- .../canvas/video/VideoCanvas.tsx | 76 - .../canvas/video/VideoSidebar.tsx | 69 +- .../openclaw/OpenClawInstallPage.tsx | 302 +++- src/components/openclaw/OpenClawPage.tsx | 269 +++- .../openclaw/OpenClawProgressPage.tsx | 26 +- src/components/openclaw/types.ts | 9 +- .../workspace/WorkbenchPage.test.tsx | 32 +- src/components/workspace/WorkbenchPage.tsx | 7 +- .../workspace/hooks/useWorkbenchController.ts | 18 +- .../hooks/useWorkbenchQuickActions.test.tsx | 22 + .../hooks/useWorkbenchQuickActions.ts | 11 +- .../panels/WorkbenchRightRail.test.tsx | 20 + .../workspace/panels/WorkbenchRightRail.tsx | 1 + .../WorkbenchRightRailExpandedPanel.tsx | 21 +- .../workbenchRightRailActionSections.tsx | 10 +- .../workbenchRightRailExpandedChrome.tsx | 4 +- .../panels/workbenchRightRailVideoConfig.ts | 58 +- src/features/themes/types.ts | 1 + src/features/themes/video/index.ts | 13 +- src/features/themes/video/panelRenderers.tsx | 322 +++++ src/lib/api/openclaw.ts | 34 + src/types/page.ts | 4 + 34 files changed, 2799 insertions(+), 462 deletions(-) create mode 100644 src/components/agent/chat/components/MarkdownRenderer.test.tsx create mode 100644 src/features/themes/video/panelRenderers.tsx diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 8853df992..b935607dd 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,5 @@ +const WINDOWS_STACK_SIZE_BYTES: usize = 10 * 1024 * 1024; + fn main() { configure_windows_stack_size(); @@ -16,10 +18,13 @@ fn main() { } fn configure_windows_stack_size() { - #[cfg(target_os = "windows")] + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + match std::env::var("CARGO_CFG_TARGET_ENV").as_deref() { - Ok("msvc") => println!("cargo:rustc-link-arg=/STACK:8388608"), - Ok("gnu") => println!("cargo:rustc-link-arg=-Wl,--stack,8388608"), + Ok("msvc") => println!("cargo:rustc-link-arg=/STACK:{WINDOWS_STACK_SIZE_BYTES}"), + Ok("gnu") => println!("cargo:rustc-link-arg=-Wl,--stack,{WINDOWS_STACK_SIZE_BYTES}"), _ => {} } } diff --git a/src-tauri/crates/agent/src/event_converter.rs b/src-tauri/crates/agent/src/event_converter.rs index 571b69bbe..24aeb5047 100644 --- a/src-tauri/crates/agent/src/event_converter.rs +++ b/src-tauri/crates/agent/src/event_converter.rs @@ -9,19 +9,11 @@ use regex::Regex; use serde::{Deserialize, Serialize}; const JSON_RECURSION_LIMIT: usize = 50; - -/// 从工具结果中提取文本内容 -/// -/// 使用 serde_json 来处理,避免直接依赖 rmcp 类型 -fn push_non_empty(target: &mut Vec, value: Option<&str>) { - let Some(raw) = value else { - return; - }; - let trimmed = raw.trim(); - if !trimmed.is_empty() { - target.push(trimmed.to_string()); - } -} +const JSON_TRAVERSAL_NODE_LIMIT: usize = 4_096; +const TOOL_RESULT_MAX_TEXT_PARTS: usize = 256; +const TOOL_RESULT_MAX_OUTPUT_CHARS: usize = 16_000; +const TOOL_RESULT_MAX_IMAGES: usize = 12; +const TOOL_RESULT_TRUNCATED_NOTICE: &str = "\n\n[event_converter] 工具输出已截断"; fn enhance_execution_error_text(raw: &str) -> String { if !raw.contains("Execution error: No such file or directory (os error 2)") { @@ -48,48 +40,119 @@ fn dedupe_preserve_order(items: Vec) -> Vec { deduped } -fn collect_tool_result_text(value: &serde_json::Value, target: &mut Vec) { - collect_tool_result_text_with_depth(value, target, 0); +#[derive(Debug, Default)] +struct TextCollectState { + collected_chars: usize, + truncated: bool, } -fn collect_tool_result_text_with_depth( - value: &serde_json::Value, +fn truncate_chars(text: &str, max_chars: usize) -> (String, bool) { + if max_chars == 0 { + return (String::new(), !text.is_empty()); + } + + let mut char_count = 0usize; + for (idx, _) in text.char_indices() { + if char_count == max_chars { + return (text[..idx].to_string(), true); + } + char_count += 1; + } + + (text.to_string(), false) +} + +fn push_non_empty_limited( target: &mut Vec, - depth: usize, + value: Option<&str>, + state: &mut TextCollectState, ) { - if depth >= JSON_RECURSION_LIMIT { + let Some(raw) = value else { + return; + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return; + } + if target.len() >= TOOL_RESULT_MAX_TEXT_PARTS + || state.collected_chars >= TOOL_RESULT_MAX_OUTPUT_CHARS + { + state.truncated = true; return; } - match value { - serde_json::Value::String(text) => push_non_empty(target, Some(text)), - serde_json::Value::Array(items) => { - for item in items { - collect_tool_result_text_with_depth(item, target, depth + 1); - } - } - serde_json::Value::Object(obj) => { - if let Some(content) = obj.get("content") { - collect_tool_result_text_with_depth(content, target, depth + 1); - } - if let Some(value) = obj.get("value") { - collect_tool_result_text_with_depth(value, target, depth + 1); - } - for key in ["text", "output", "stdout", "stderr", "message", "error"] { - push_non_empty(target, obj.get(key).and_then(|v| v.as_str())); - } - } - _ => {} + let remaining = TOOL_RESULT_MAX_OUTPUT_CHARS.saturating_sub(state.collected_chars); + let (snippet, was_truncated) = truncate_chars(trimmed, remaining); + if snippet.is_empty() { + state.truncated = true; + return; } + + state.collected_chars += snippet.chars().count(); + state.truncated |= was_truncated; + target.push(snippet); +} + +fn collect_tool_result_text(value: &serde_json::Value, target: &mut Vec) -> bool { + let mut stack = vec![(value, 0usize)]; + let mut visited_nodes = 0usize; + let mut state = TextCollectState::default(); + + while let Some((current, depth)) = stack.pop() { + visited_nodes += 1; + if visited_nodes > JSON_TRAVERSAL_NODE_LIMIT { + state.truncated = true; + break; + } + if depth >= JSON_RECURSION_LIMIT { + state.truncated = true; + continue; + } + + match current { + serde_json::Value::String(text) => { + push_non_empty_limited(target, Some(text), &mut state); + } + serde_json::Value::Array(items) => { + for item in items.iter().rev() { + stack.push((item, depth + 1)); + } + } + serde_json::Value::Object(obj) => { + for key in ["text", "output", "stdout", "stderr", "message", "error"] { + push_non_empty_limited( + target, + obj.get(key).and_then(|v| v.as_str()), + &mut state, + ); + } + if let Some(value) = obj.get("value") { + stack.push((value, depth + 1)); + } + if let Some(content) = obj.get("content") { + stack.push((content, depth + 1)); + } + } + _ => {} + } + } + + state.truncated } fn extract_tool_result_text(result: &T) -> String { if let Ok(json) = serde_json::to_value(result) { let mut parts = Vec::new(); - collect_tool_result_text(&json, &mut parts); + let traversal_truncated = collect_tool_result_text(&json, &mut parts); let deduped = dedupe_preserve_order(parts); if !deduped.is_empty() { - return maybe_filter_web_content(&deduped.join("\n")); + let filtered = maybe_filter_web_content(&deduped.join("\n")); + let (mut limited, output_truncated) = + truncate_chars(&filtered, TOOL_RESULT_MAX_OUTPUT_CHARS); + if traversal_truncated || output_truncated { + limited.push_str(TOOL_RESULT_TRUNCATED_NOTICE); + } + return limited; } } String::new() @@ -256,51 +319,68 @@ fn collect_tool_result_images( value: &serde_json::Value, target: &mut Vec, seen_sources: &mut std::collections::HashSet, -) { - collect_tool_result_images_with_depth(value, target, seen_sources, 0); -} +) -> bool { + let mut stack = vec![(value, 0usize)]; + let mut visited_nodes = 0usize; + let mut truncated = false; -fn collect_tool_result_images_with_depth( - value: &serde_json::Value, - target: &mut Vec, - seen_sources: &mut std::collections::HashSet, - depth: usize, -) { - if depth >= JSON_RECURSION_LIMIT { - return; - } + while let Some((current, depth)) = stack.pop() { + visited_nodes += 1; + if visited_nodes > JSON_TRAVERSAL_NODE_LIMIT { + truncated = true; + break; + } + if depth >= JSON_RECURSION_LIMIT { + truncated = true; + continue; + } + if target.len() >= TOOL_RESULT_MAX_IMAGES { + truncated = true; + break; + } - match value { - serde_json::Value::String(text) => { - for data_url in extract_data_urls_from_text(text) { - push_tool_image_if_new( - target, - seen_sources, - build_tool_image_from_data_url(&data_url, "data_url"), - ); - } - } - serde_json::Value::Array(items) => { - for item in items { - collect_tool_result_images_with_depth(item, target, seen_sources, depth + 1); - } - } - serde_json::Value::Object(obj) => { - for key in ["image_url", "url", "data"] { - if let Some(serde_json::Value::String(raw)) = obj.get(key) { + match current { + serde_json::Value::String(text) => { + for data_url in extract_data_urls_from_text(text) { + if target.len() >= TOOL_RESULT_MAX_IMAGES { + truncated = true; + break; + } push_tool_image_if_new( target, seen_sources, - build_tool_image_from_data_url(raw, "tool_payload"), + build_tool_image_from_data_url(&data_url, "data_url"), ); } } - for nested in obj.values() { - collect_tool_result_images_with_depth(nested, target, seen_sources, depth + 1); + serde_json::Value::Array(items) => { + for item in items.iter().rev() { + stack.push((item, depth + 1)); + } } + serde_json::Value::Object(obj) => { + for key in ["image_url", "url", "data"] { + if target.len() >= TOOL_RESULT_MAX_IMAGES { + truncated = true; + break; + } + if let Some(serde_json::Value::String(raw)) = obj.get(key) { + push_tool_image_if_new( + target, + seen_sources, + build_tool_image_from_data_url(raw, "tool_payload"), + ); + } + } + for nested in obj.values() { + stack.push((nested, depth + 1)); + } + } + _ => {} } - _ => {} } + + truncated } fn extract_tool_result_data(result: &T) -> ExtractedToolResult { @@ -317,7 +397,7 @@ fn extract_tool_result_data(result: &T) -> ExtractedToolRes } if let Ok(json) = serde_json::to_value(result) { - collect_tool_result_images(&json, &mut images, &mut seen_sources); + let _ = collect_tool_result_images(&json, &mut images, &mut seen_sources); } ExtractedToolResult { output, images } @@ -911,4 +991,36 @@ mod tests { let text = extract_tool_result_text(&nested); assert_eq!(text, ""); } + + #[test] + fn test_extract_tool_result_text_should_truncate_large_payload() { + let payload = serde_json::json!({ + "content": [ + { + "type": "text", + "text": "A".repeat(TOOL_RESULT_MAX_OUTPUT_CHARS + 128) + } + ] + }); + + let text = extract_tool_result_text(&payload); + assert!(text.contains("[event_converter] 工具输出已截断")); + assert!(text.chars().count() <= TOOL_RESULT_MAX_OUTPUT_CHARS + 64); + } + + #[test] + fn test_extract_tool_result_data_should_limit_image_count() { + let payload = serde_json::json!({ + "images": (0..(TOOL_RESULT_MAX_IMAGES + 4)) + .map(|index| { + serde_json::json!({ + "data": format!("data:image/png;base64,image{index}") + }) + }) + .collect::>() + }); + + let extracted = extract_tool_result_data(&payload); + assert_eq!(extracted.images.len(), TOOL_RESULT_MAX_IMAGES); + } } diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 769af4fb0..711e3a082 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -983,6 +983,7 @@ pub fn run() { commands::config_cmd::download_update, // OpenClaw commands commands::openclaw_cmd::openclaw_check_installed, + commands::openclaw_cmd::openclaw_get_environment_status, commands::openclaw_cmd::openclaw_check_node_version, commands::openclaw_cmd::openclaw_check_git_available, commands::openclaw_cmd::openclaw_get_node_download_url, @@ -990,7 +991,9 @@ pub fn run() { commands::openclaw_cmd::openclaw_get_command_preview, commands::openclaw_cmd::openclaw_get_progress_logs, commands::openclaw_cmd::openclaw_install, + commands::openclaw_cmd::openclaw_install_dependency, commands::openclaw_cmd::openclaw_uninstall, + commands::openclaw_cmd::openclaw_cleanup_temp_artifacts, commands::openclaw_cmd::openclaw_start_gateway, commands::openclaw_cmd::openclaw_stop_gateway, commands::openclaw_cmd::openclaw_restart_gateway, diff --git a/src-tauri/src/commands/openclaw_cmd.rs b/src-tauri/src/commands/openclaw_cmd.rs index 9bda64c1e..ec1d571c3 100644 --- a/src-tauri/src/commands/openclaw_cmd.rs +++ b/src-tauri/src/commands/openclaw_cmd.rs @@ -2,8 +2,8 @@ use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState; use crate::database::DbConnection; use crate::services::openclaw_service::{ openclaw_install_event_name, ActionResult, BinaryAvailabilityStatus, BinaryInstallStatus, - ChannelInfo, CommandPreview, GatewayStatusInfo, HealthInfo, InstallProgressEvent, - NodeCheckResult, OpenClawServiceState, SyncModelEntry, + ChannelInfo, CommandPreview, EnvironmentStatus, GatewayStatusInfo, HealthInfo, + InstallProgressEvent, NodeCheckResult, OpenClawServiceState, SyncModelEntry, }; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, State}; @@ -25,6 +25,14 @@ pub async fn openclaw_check_installed( service.check_installed().await } +#[tauri::command] +pub async fn openclaw_get_environment_status( + service: State<'_, OpenClawServiceState>, +) -> Result { + let service = service.0.lock().await; + service.get_environment_status().await +} + #[tauri::command] pub async fn openclaw_check_node_version( service: State<'_, OpenClawServiceState>, @@ -78,6 +86,26 @@ pub async fn openclaw_install( service.install(&app).await } +#[tauri::command] +pub async fn openclaw_install_dependency( + app: AppHandle, + service: State<'_, OpenClawServiceState>, + kind: String, +) -> Result { + let mut service = service.0.lock().await; + service.clear_progress_logs(); + service.install_dependency(&app, &kind).await +} + +#[tauri::command] +pub async fn openclaw_cleanup_temp_artifacts( + app: AppHandle, + service: State<'_, OpenClawServiceState>, +) -> Result { + let mut service = service.0.lock().await; + service.cleanup_temp_artifacts(Some(&app)).await +} + #[tauri::command] pub async fn openclaw_uninstall( app: AppHandle, diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index b7869f5f6..3c8d64344 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -1249,6 +1249,17 @@ pub async fn handle_command( Ok(serde_json::to_value(service.check_installed().await?)?) } + "openclaw_get_environment_status" => { + let app_handle = state + .app_handle + .as_ref() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; + let service = + app_handle.state::(); + let service = service.0.lock().await; + Ok(serde_json::to_value(service.get_environment_status().await?)?) + } + "openclaw_check_node_version" => { let app_handle = state .app_handle @@ -1313,6 +1324,20 @@ pub async fn handle_command( Ok(serde_json::to_value(service.install(app_handle).await?)?) } + "openclaw_install_dependency" => { + let app_handle = state + .app_handle + .as_ref() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; + let args = args.unwrap_or_default(); + let kind = get_string_arg(&args, "kind", "kind")?; + let service = + app_handle.state::(); + let mut service = service.0.lock().await; + service.clear_progress_logs(); + Ok(serde_json::to_value(service.install_dependency(app_handle, &kind).await?)?) + } + "openclaw_uninstall" => { let app_handle = state .app_handle @@ -1324,6 +1349,17 @@ pub async fn handle_command( Ok(serde_json::to_value(service.uninstall(app_handle).await?)?) } + "openclaw_cleanup_temp_artifacts" => { + let app_handle = state + .app_handle + .as_ref() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; + let service = + app_handle.state::(); + let mut service = service.0.lock().await; + Ok(serde_json::to_value(service.cleanup_temp_artifacts(Some(app_handle)).await?)?) + } + "openclaw_start_gateway" => { let app_handle = state .app_handle diff --git a/src-tauri/src/services/openclaw_service.rs b/src-tauri/src/services/openclaw_service.rs index 4bee646d7..fbb9ad946 100644 --- a/src-tauri/src/services/openclaw_service.rs +++ b/src-tauri/src/services/openclaw_service.rs @@ -6,6 +6,7 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::collections::{HashSet, VecDeque}; +use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::OnceLock; @@ -25,6 +26,8 @@ const OPENCLAW_DEFAULT_PACKAGE: &str = "openclaw@latest"; const NPM_MIRROR_CN: &str = "https://registry.npmmirror.com"; const NODE_MIN_VERSION: (u64, u64, u64) = (22, 0, 0); const OPENCLAW_PROGRESS_LOG_LIMIT: usize = 400; +const OPENCLAW_INSTALLER_USER_AGENT: &str = "ProxyCast-OpenClaw"; +const OPENCLAW_TEMP_CARGO_CHECK_DIR: &str = "/tmp/proxycast-cargo-check"; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -55,6 +58,28 @@ pub struct ActionResult { pub message: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DependencyStatus { + pub status: String, + pub version: Option, + pub path: Option, + pub message: String, + pub auto_install_supported: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentStatus { + pub node: DependencyStatus, + pub git: DependencyStatus, + pub openclaw: DependencyStatus, + pub recommended_action: String, + pub summary: String, + #[serde(default)] + pub temp_artifacts: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CommandPreview { @@ -111,6 +136,27 @@ pub struct SyncModelEntry { pub context_window: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DependencyKind { + Node, + Git, +} + +impl DependencyKind { + fn label(self) -> &'static str { + match self { + Self::Node => "Node.js", + Self::Git => "Git", + } + } +} + +#[derive(Debug, Clone)] +struct InstallerAsset { + filename: String, + download_url: String, +} + #[derive(Debug)] pub struct OpenClawService { gateway_process: Option, @@ -175,61 +221,40 @@ impl OpenClawService { } } + pub async fn get_environment_status(&self) -> Result { + let node = inspect_node_dependency_status().await?; + let git = inspect_git_dependency_status().await?; + let openclaw = inspect_openclaw_dependency_status().await?; + + Ok(build_environment_status(node, git, openclaw)) + } + pub async fn check_installed(&self) -> Result { - let path = find_command_in_shell("openclaw").await?; + let openclaw = inspect_openclaw_dependency_status().await?; Ok(BinaryInstallStatus { - installed: path.is_some(), - path, + installed: openclaw.status == "ok", + path: openclaw.path, }) } pub async fn check_git_available(&self) -> Result { - let path = find_command_in_shell("git").await?; + let git = inspect_git_dependency_status().await?; Ok(BinaryAvailabilityStatus { - available: path.is_some(), - path, + available: git.status == "ok", + path: git.path, }) } pub async fn check_node_version(&self) -> Result { - let Some(path) = find_command_in_shell("node").await? else { - return Ok(NodeCheckResult { - status: "not_found".to_string(), - version: None, - path: None, - }); - }; - - let output = Command::new(&path) - .arg("--version") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|e| format!("检查 Node.js 版本失败: {e}"))?; - - let version_text = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let Some(version) = parse_semver(&version_text) else { - return Ok(NodeCheckResult { - status: "version_low".to_string(), - version: Some(version_text), - path: Some(path), - }); - }; - - if version >= NODE_MIN_VERSION { - Ok(NodeCheckResult { - status: "ok".to_string(), - version: Some(format_semver(version)), - path: Some(path), - }) - } else { - Ok(NodeCheckResult { - status: "version_low".to_string(), - version: Some(format_semver(version)), - path: Some(path), - }) - } + let node = inspect_node_dependency_status().await?; + Ok(NodeCheckResult { + status: match node.status.as_str() { + "missing" => "not_found".to_string(), + other => other.to_string(), + }, + version: node.version, + path: node.path, + }) } pub fn get_node_download_url(&self) -> String { @@ -256,7 +281,23 @@ impl OpenClawService { } } - pub async fn install(&self, app: &AppHandle) -> Result { + pub async fn install(&mut self, app: &AppHandle) -> Result { + emit_install_progress(app, "开始准备 OpenClaw 环境。", "info"); + + let node_result = self + .ensure_dependency_ready(app, DependencyKind::Node) + .await?; + if !node_result.success { + return Ok(node_result); + } + + let git_result = self + .ensure_dependency_ready(app, DependencyKind::Git) + .await?; + if !git_result.success { + return Ok(git_result); + } + let (_, npm_path, npm_prefix, cleanup_command, install_command) = self.resolve_install_commands(app).await?; let command = format!("{cleanup_command}\n{install_command}"); @@ -268,7 +309,102 @@ impl OpenClawService { emit_install_progress(app, "安装前先清理已有 OpenClaw 全局包。", "info"); emit_install_progress(app, &format!("执行安装命令: {install_command}"), "info"); - run_shell_command_with_progress(app, &command).await + let result = run_shell_command_with_progress(app, &command).await?; + if !result.success { + return Ok(result); + } + + let installed = self.check_installed().await?; + if installed.installed { + emit_install_progress(app, "已检测到 OpenClaw 可执行文件。", "info"); + return Ok(ActionResult { + success: true, + message: installed + .path + .map(|path| format!("OpenClaw 安装完成:{path}")) + .unwrap_or_else(|| "OpenClaw 安装完成。".to_string()), + }); + } + + Ok(ActionResult { + success: false, + message: + "安装命令执行完成,但仍未检测到 OpenClaw 可执行文件,请检查 npm 全局目录或权限设置。" + .to_string(), + }) + } + + pub async fn install_dependency( + &mut self, + app: &AppHandle, + kind: &str, + ) -> Result { + let dependency = match kind { + "node" => DependencyKind::Node, + "git" => DependencyKind::Git, + _ => return Err(format!("不支持的依赖类型: {kind}")), + }; + + self.ensure_dependency_ready(app, dependency).await + } + + pub async fn cleanup_temp_artifacts( + &mut self, + app: Option<&AppHandle>, + ) -> Result { + let mut removed = Vec::new(); + let mut failed = Vec::new(); + + for target in collect_temp_artifact_paths(app) { + if !target.exists() { + continue; + } + + let result = if target.is_dir() { + std::fs::remove_dir_all(&target) + } else { + std::fs::remove_file(&target) + }; + + match result { + Ok(_) => { + if let Some(app) = app { + emit_install_progress( + app, + &format!("已清理临时文件:{}", target.display()), + "info", + ); + } + removed.push(target.display().to_string()); + } + Err(error) => { + if let Some(app) = app { + emit_install_progress( + app, + &format!("清理临时文件失败({}): {error}", target.display()), + "warn", + ); + } + failed.push(format!("{}: {error}", target.display())); + } + } + } + + if failed.is_empty() { + Ok(ActionResult { + success: true, + message: if removed.is_empty() { + "未发现需要清理的 OpenClaw 临时文件。".to_string() + } else { + format!("已清理 {} 项临时文件。", removed.len()) + }, + }) + } else { + Ok(ActionResult { + success: false, + message: format!("部分临时文件清理失败:{}", failed.join(";")), + }) + } } pub async fn uninstall(&mut self, app: &AppHandle) -> Result { @@ -286,6 +422,300 @@ impl OpenClawService { run_shell_command_with_progress(app, &command).await } + async fn ensure_dependency_ready( + &mut self, + app: &AppHandle, + dependency: DependencyKind, + ) -> Result { + let status = self.inspect_dependency_status(dependency).await?; + if status.status == "ok" { + emit_install_progress( + app, + &format!( + "{} 已就绪{}。", + dependency.label(), + status + .version + .as_deref() + .map(|version| format!(" · {version}")) + .unwrap_or_default() + ), + "info", + ); + return Ok(ActionResult { + success: true, + message: format!("{} 已满足要求。", dependency.label()), + }); + } + + emit_install_progress( + app, + &format!("{},开始修复 {} 环境。", status.message, dependency.label()), + "warn", + ); + + match dependency { + DependencyKind::Node => self.install_node_runtime(app).await, + DependencyKind::Git => self.install_git_runtime(app).await, + } + } + + async fn inspect_dependency_status( + &self, + dependency: DependencyKind, + ) -> Result { + match dependency { + DependencyKind::Node => inspect_node_dependency_status().await, + DependencyKind::Git => inspect_git_dependency_status().await, + } + } + + async fn install_node_runtime(&mut self, app: &AppHandle) -> Result { + #[cfg(target_os = "windows")] + { + if let Some(winget_path) = find_command_in_shell("winget").await? { + emit_install_progress(app, "检测到 winget,准备通过 winget 安装 Node.js。", "info"); + let command = format!( + "{}{} install --id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements", + shell_path_assignment(&winget_path), + shell_command_escape(&winget_path) + ); + let result = run_shell_command_with_progress(app, &command).await?; + if !result.success { + return Ok(result); + } + return self + .verify_dependency_after_install(app, DependencyKind::Node) + .await; + } + + emit_install_progress( + app, + "未检测到 winget,准备下载官方 Node.js 安装器。", + "warn", + ); + let asset = resolve_node_installer_asset().await?; + let installer_path = download_installer_asset(app, &asset).await?; + launch_installer(&installer_path)?; + return self + .wait_for_dependency_ready(app, DependencyKind::Node, 900) + .await; + } + + #[cfg(target_os = "macos")] + { + if let Some(brew_path) = find_command_in_shell("brew").await? { + emit_install_progress( + app, + "检测到 Homebrew,准备通过 Homebrew 安装 Node.js。", + "info", + ); + let brew_cmd = shell_command_escape(&brew_path); + let path_env = shell_path_assignment(&brew_path); + let command = format!( + "{path_env}{brew_cmd} install node || {path_env}{brew_cmd} upgrade node" + ); + let result = run_shell_command_with_progress(app, &command).await?; + if !result.success { + return Ok(result); + } + return self + .verify_dependency_after_install(app, DependencyKind::Node) + .await; + } + + emit_install_progress( + app, + "未检测到 Homebrew,准备下载官方 Node.js 安装器。", + "warn", + ); + let asset = resolve_node_installer_asset().await?; + let installer_path = download_installer_asset(app, &asset).await?; + launch_installer(&installer_path)?; + return self + .wait_for_dependency_ready(app, DependencyKind::Node, 900) + .await; + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + let message = "当前平台暂不支持应用内自动安装 Node.js,请手动安装 Node.js 22+ 后重试。" + .to_string(); + emit_install_progress(app, &message, "warn"); + Ok(ActionResult { + success: false, + message, + }) + } + } + + async fn install_git_runtime(&mut self, app: &AppHandle) -> Result { + #[cfg(target_os = "windows")] + { + if let Some(winget_path) = find_command_in_shell("winget").await? { + emit_install_progress(app, "检测到 winget,准备通过 winget 安装 Git。", "info"); + let command = format!( + "{}{} install --id Git.Git -e --accept-source-agreements --accept-package-agreements", + shell_path_assignment(&winget_path), + shell_command_escape(&winget_path) + ); + let result = run_shell_command_with_progress(app, &command).await?; + if !result.success { + return Ok(result); + } + return self + .verify_dependency_after_install(app, DependencyKind::Git) + .await; + } + + let message = + "当前系统缺少 winget,暂时无法一键安装 Git,请点击“手动下载 Git”完成安装后重试。" + .to_string(); + emit_install_progress(app, &message, "warn"); + return Ok(ActionResult { + success: false, + message, + }); + } + + #[cfg(target_os = "macos")] + { + if let Some(brew_path) = find_command_in_shell("brew").await? { + emit_install_progress(app, "检测到 Homebrew,准备通过 Homebrew 安装 Git。", "info"); + let brew_cmd = shell_command_escape(&brew_path); + let path_env = shell_path_assignment(&brew_path); + let command = + format!("{path_env}{brew_cmd} install git || {path_env}{brew_cmd} upgrade git"); + let result = run_shell_command_with_progress(app, &command).await?; + if !result.success { + return Ok(result); + } + return self + .verify_dependency_after_install(app, DependencyKind::Git) + .await; + } + + emit_install_progress( + app, + "未检测到 Homebrew,准备拉起 macOS Command Line Tools 安装器。", + "warn", + ); + let trigger_result = trigger_macos_command_line_tools_install().await?; + emit_install_progress(app, &trigger_result, "info"); + return self + .wait_for_dependency_ready(app, DependencyKind::Git, 1200) + .await; + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + let message = "当前平台暂不支持应用内自动安装 Git,请使用系统包管理器手动安装后重试。" + .to_string(); + emit_install_progress(app, &message, "warn"); + Ok(ActionResult { + success: false, + message, + }) + } + } + + async fn verify_dependency_after_install( + &self, + app: &AppHandle, + dependency: DependencyKind, + ) -> Result { + let status = self.inspect_dependency_status(dependency).await?; + if status.status == "ok" { + emit_install_progress( + app, + &format!( + "{} 已准备完成{}。", + dependency.label(), + status + .version + .as_deref() + .map(|version| format!(" · {version}")) + .unwrap_or_default() + ), + "info", + ); + return Ok(ActionResult { + success: true, + message: format!("{} 已安装完成。", dependency.label()), + }); + } + + Ok(ActionResult { + success: false, + message: format!( + "{} 安装完成后仍未通过校验:{}", + dependency.label(), + status.message + ), + }) + } + + async fn wait_for_dependency_ready( + &self, + app: &AppHandle, + dependency: DependencyKind, + timeout_secs: u64, + ) -> Result { + emit_install_progress( + app, + &format!( + "已拉起 {} 安装器,正在等待安装完成(最长 {} 秒)。", + dependency.label(), + timeout_secs + ), + "info", + ); + + let start = tokio::time::Instant::now(); + let mut last_notice_at = 0_u64; + while start.elapsed() < Duration::from_secs(timeout_secs) { + let elapsed = start.elapsed().as_secs(); + if elapsed >= last_notice_at + 15 { + last_notice_at = elapsed; + emit_install_progress( + app, + &format!("正在等待 {} 安装完成…", dependency.label()), + "info", + ); + } + + sleep(Duration::from_secs(2)).await; + let status = self.inspect_dependency_status(dependency).await?; + if status.status == "ok" { + emit_install_progress( + app, + &format!( + "{} 已检测通过{}。", + dependency.label(), + status + .version + .as_deref() + .map(|version| format!(" · {version}")) + .unwrap_or_default() + ), + "info", + ); + return Ok(ActionResult { + success: true, + message: format!("{} 已安装完成。", dependency.label()), + }); + } + } + + Ok(ActionResult { + success: false, + message: format!( + "等待 {} 安装完成超时,请完成安装后重新点击重试。", + dependency.label() + ), + }) + } + pub async fn start_gateway( &mut self, app: Option<&AppHandle>, @@ -342,6 +772,7 @@ impl OpenClawService { ); } let mut command = Command::new(&binary); + apply_binary_runtime_path(&mut command, &binary); command .arg("gateway") .arg("--port") @@ -438,6 +869,7 @@ impl OpenClawService { let binary = find_command_in_shell("openclaw").await?; if let Some(openclaw_path) = binary.as_deref() { let mut cmd = Command::new(openclaw_path); + apply_binary_runtime_path(&mut cmd, openclaw_path); cmd.arg("gateway") .arg("stop") .arg("--url") @@ -724,7 +1156,9 @@ impl OpenClawService { return Ok(false); }; - let output = Command::new(openclaw_path) + let mut command = Command::new(openclaw_path); + apply_binary_runtime_path(&mut command, &openclaw_path); + let output = command .arg("gateway") .arg("status") .arg("--url") @@ -755,7 +1189,9 @@ impl OpenClawService { return Ok(None); }; - let output = Command::new(binary) + let mut command = Command::new(&binary); + apply_binary_runtime_path(&mut command, &binary); + let output = command .arg("--version") .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -805,7 +1241,9 @@ impl OpenClawService { return None; }; - let output = Command::new(openclaw_path) + let mut command = Command::new(&openclaw_path); + apply_binary_runtime_path(&mut command, &openclaw_path); + let output = command .arg("gateway") .arg("health") .arg("--url") @@ -925,18 +1363,21 @@ impl OpenClawService { } else { OPENCLAW_DEFAULT_PACKAGE }; + let path_env = shell_path_assignment(&npm_path); let prefix_env = npm_prefix .as_deref() - .map(shell_env_assignment) + .map(shell_npm_prefix_assignment) .unwrap_or_default(); - let npm_cmd = shell_escape(&npm_path); + let npm_cmd = shell_command_escape(&npm_path); let cleanup_command = format!( - "{prefix_env}{npm_cmd} uninstall -g openclaw @qingchencloud/openclaw-zh || true" + "{path_env}{prefix_env}{npm_cmd} uninstall -g openclaw @qingchencloud/openclaw-zh || true" ); let install_command = if should_use_china_package(app).await { - format!("{prefix_env}{npm_cmd} install -g {package} --registry={NPM_MIRROR_CN}") + format!( + "{path_env}{prefix_env}{npm_cmd} install -g {package} --registry={NPM_MIRROR_CN}" + ) } else { - format!("{prefix_env}{npm_cmd} install -g {package}") + format!("{path_env}{prefix_env}{npm_cmd} install -g {package}") }; Ok(( package.to_string(), @@ -952,14 +1393,16 @@ impl OpenClawService { .await? .ok_or_else(|| "未检测到 npm,可先安装或修复 Node.js 环境。".to_string())?; let npm_prefix = detect_npm_global_prefix(&npm_path).await; + let path_env = shell_path_assignment(&npm_path); let prefix_env = npm_prefix .as_deref() - .map(shell_env_assignment) + .map(shell_npm_prefix_assignment) .unwrap_or_default(); let command = format!( - "{}{} uninstall -g openclaw @qingchencloud/openclaw-zh", + "{}{}{} uninstall -g openclaw @qingchencloud/openclaw-zh", + path_env, prefix_env, - shell_escape(&npm_path) + shell_command_escape(&npm_path) ); Ok((npm_path, npm_prefix, command)) } @@ -1074,6 +1517,431 @@ fn openclaw_proxycast_config_path() -> PathBuf { openclaw_config_dir().join("openclaw.proxycast.json") } +fn openclaw_installer_download_dir(app: &AppHandle) -> Result { + let app_data_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("无法获取应用数据目录: {e}"))?; + let dir = app_data_dir.join("downloads").join("openclaw-installers"); + std::fs::create_dir_all(&dir).map_err(|e| format!("创建 OpenClaw 下载目录失败: {e}"))?; + Ok(dir) +} + +fn collect_temp_artifact_paths(app: Option<&AppHandle>) -> Vec { + let mut targets = Vec::new(); + + #[cfg(not(target_os = "windows"))] + { + targets.push(PathBuf::from(OPENCLAW_TEMP_CARGO_CHECK_DIR)); + } + + if let Some(app) = app { + if let Ok(dir) = openclaw_installer_download_dir(app) { + targets.push(dir); + } + } + + targets +} + +fn build_environment_status( + node: DependencyStatus, + git: DependencyStatus, + mut openclaw: DependencyStatus, +) -> EnvironmentStatus { + let node_ready = node.status == "ok"; + let git_ready = git.status == "ok"; + openclaw.auto_install_supported = node_ready && git_ready; + + let (recommended_action, summary) = if !node_ready { + ( + "install_node".to_string(), + "当前缺少可用的 Node.js 22+ 运行时,建议先一键安装或修复 Node.js。".to_string(), + ) + } else if !git_ready { + ( + "install_git".to_string(), + "当前缺少可用的 Git,建议先一键安装或修复 Git。".to_string(), + ) + } else if openclaw.status != "ok" { + ( + "install_openclaw".to_string(), + "运行环境已就绪,可以继续一键安装 OpenClaw。".to_string(), + ) + } else { + ( + "ready".to_string(), + "Node.js、Git 和 OpenClaw 均已就绪,可以继续配置与启动。".to_string(), + ) + }; + + EnvironmentStatus { + node, + git, + openclaw, + recommended_action, + summary, + temp_artifacts: collect_temp_artifact_paths(None) + .into_iter() + .filter(|path| path.exists()) + .map(|path| path.display().to_string()) + .collect(), + } +} + +async fn inspect_node_dependency_status() -> Result { + let Some(path) = find_command_in_shell("node").await? else { + return Ok(DependencyStatus { + status: "missing".to_string(), + version: None, + path: None, + message: format!( + "未检测到 Node.js,需要安装 {}+。", + format_semver(NODE_MIN_VERSION) + ), + auto_install_supported: cfg!(target_os = "windows") || cfg!(target_os = "macos"), + }); + }; + + let version_text = read_command_version_text(&path, &["--version"]).await?; + let Some(version) = parse_semver_from_text(&version_text) else { + return Ok(DependencyStatus { + status: "version_low".to_string(), + version: Some(version_text.clone()), + path: Some(path), + message: format!( + "检测到 Node.js,但无法识别版本:{version_text}。请安装 {}+。", + format_semver(NODE_MIN_VERSION) + ), + auto_install_supported: cfg!(target_os = "windows") || cfg!(target_os = "macos"), + }); + }; + + let normalized = format_semver(version); + if version >= NODE_MIN_VERSION { + Ok(DependencyStatus { + status: "ok".to_string(), + version: Some(normalized.clone()), + path: Some(path), + message: format!("Node.js 已就绪:{normalized}"), + auto_install_supported: cfg!(target_os = "windows") || cfg!(target_os = "macos"), + }) + } else { + Ok(DependencyStatus { + status: "version_low".to_string(), + version: Some(normalized.clone()), + path: Some(path), + message: format!( + "Node.js 版本过低:{normalized},需要 {}+。", + format_semver(NODE_MIN_VERSION) + ), + auto_install_supported: cfg!(target_os = "windows") || cfg!(target_os = "macos"), + }) + } +} + +async fn inspect_git_dependency_status() -> Result { + let Some(path) = find_command_in_shell("git").await? else { + return Ok(DependencyStatus { + status: "missing".to_string(), + version: None, + path: None, + message: "未检测到 Git。".to_string(), + auto_install_supported: git_auto_install_supported().await?, + }); + }; + + let version_text = read_command_version_text(&path, &["--version"]).await?; + let version = parse_semver_from_text(&version_text).map(format_semver); + let detail = version.clone().unwrap_or(version_text); + + Ok(DependencyStatus { + status: "ok".to_string(), + version, + path: Some(path), + message: format!("Git 已就绪:{detail}"), + auto_install_supported: git_auto_install_supported().await?, + }) +} + +async fn inspect_openclaw_dependency_status() -> Result { + let Some(path) = find_command_in_shell("openclaw").await? else { + return Ok(DependencyStatus { + status: "missing".to_string(), + version: None, + path: None, + message: "未检测到 OpenClaw,可在环境就绪后一键安装。".to_string(), + auto_install_supported: false, + }); + }; + + let version_text = read_command_version_text(&path, &["--version"]).await?; + Ok(DependencyStatus { + status: "ok".to_string(), + version: if version_text.is_empty() { + None + } else { + Some(version_text.clone()) + }, + path: Some(path), + message: if version_text.is_empty() { + "已检测到 OpenClaw。".to_string() + } else { + format!("已检测到 OpenClaw:{version_text}") + }, + auto_install_supported: false, + }) +} + +async fn git_auto_install_supported() -> Result { + #[cfg(target_os = "windows")] + { + Ok(find_command_in_shell("winget").await?.is_some()) + } + + #[cfg(target_os = "macos")] + { + Ok(true) + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + Ok(false) + } +} + +async fn read_command_version_text(command_path: &str, args: &[&str]) -> Result { + let mut command = Command::new(command_path); + apply_binary_runtime_path(&mut command, command_path); + for arg in args { + command.arg(arg); + } + let output = command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| format!("执行命令失败({command_path}): {e}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if !stdout.is_empty() { + Ok(stdout) + } else { + Ok(stderr) + } +} + +async fn resolve_node_installer_asset() -> Result { + let client = reqwest::Client::new(); + let response = client + .get("https://nodejs.org/dist/index.json") + .header("User-Agent", OPENCLAW_INSTALLER_USER_AGENT) + .send() + .await + .map_err(|e| format!("请求 Node.js 版本列表失败: {e}"))?; + + if !response.status().is_success() { + return Err(format!( + "获取 Node.js 版本列表失败: HTTP {}", + response.status() + )); + } + + let releases: Vec = response + .json() + .await + .map_err(|e| format!("解析 Node.js 版本列表失败: {e}"))?; + + let select_version = |only_lts: bool| -> Option { + releases.iter().find_map(|release| { + let version = release.get("version")?.as_str()?; + let parsed = parse_semver(version)?; + let is_lts = release + .get("lts") + .map(|value| match value { + Value::Bool(flag) => *flag, + Value::String(text) => !text.trim().is_empty() && text != "false", + _ => false, + }) + .unwrap_or(false); + if parsed >= NODE_MIN_VERSION && (!only_lts || is_lts) { + Some(version.to_string()) + } else { + None + } + }) + }; + + let version = select_version(true) + .or_else(|| select_version(false)) + .ok_or_else(|| "未找到满足要求的 Node.js 官方安装包版本。".to_string())?; + + #[cfg(target_os = "windows")] + let filename = { + #[cfg(target_arch = "aarch64")] + { + format!("node-{version}-arm64.msi") + } + #[cfg(not(target_arch = "aarch64"))] + { + format!("node-{version}-x64.msi") + } + }; + + #[cfg(target_os = "macos")] + let filename = format!("node-{version}.pkg"); + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + let filename = String::new(); + + if filename.is_empty() { + return Err("当前平台暂不支持自动下载官方 Node.js 安装器。".to_string()); + } + + Ok(InstallerAsset { + download_url: format!("https://nodejs.org/dist/{version}/{filename}"), + filename, + }) +} + +async fn download_installer_asset( + app: &AppHandle, + asset: &InstallerAsset, +) -> Result { + let download_dir = openclaw_installer_download_dir(app)?; + let installer_path = download_dir.join(&asset.filename); + if installer_path.exists() { + let _ = std::fs::remove_file(&installer_path); + } + + emit_install_progress( + app, + &format!("开始下载安装器:{}", asset.download_url), + "info", + ); + + let client = reqwest::Client::new(); + let response = client + .get(&asset.download_url) + .header("User-Agent", OPENCLAW_INSTALLER_USER_AGENT) + .send() + .await + .map_err(|e| format!("下载官方安装器失败: {e}"))?; + + if !response.status().is_success() { + return Err(format!("下载安装器失败: HTTP {}", response.status())); + } + + let bytes = response + .bytes() + .await + .map_err(|e| format!("读取安装器文件失败: {e}"))?; + std::fs::write(&installer_path, bytes) + .map_err(|e| format!("保存安装器失败({}): {e}", installer_path.display()))?; + + emit_install_progress( + app, + &format!("安装器已保存到:{}", installer_path.display()), + "info", + ); + + Ok(installer_path) +} + +fn launch_installer(file_path: &Path) -> Result<(), String> { + let extension = file_path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + + match extension.as_str() { + "exe" => { + #[cfg(target_os = "windows")] + { + std::process::Command::new(file_path) + .spawn() + .map_err(|e| format!("启动安装程序失败: {e}"))?; + } + + #[cfg(not(target_os = "windows"))] + { + return Err("EXE 安装器只能在 Windows 上运行。".to_string()); + } + } + "msi" => { + #[cfg(target_os = "windows")] + { + std::process::Command::new("msiexec") + .arg("/i") + .arg(file_path) + .spawn() + .map_err(|e| format!("启动 MSI 安装程序失败: {e}"))?; + } + + #[cfg(not(target_os = "windows"))] + { + return Err("MSI 安装器只能在 Windows 上运行。".to_string()); + } + } + "pkg" | "dmg" => { + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg(file_path) + .spawn() + .map_err(|e| format!("打开 macOS 安装器失败: {e}"))?; + } + + #[cfg(not(target_os = "macos"))] + { + return Err("该安装器只能在 macOS 上运行。".to_string()); + } + } + _ => return Err(format!("不支持的安装器文件类型: {extension}")), + } + + Ok(()) +} + +#[cfg(target_os = "macos")] +async fn trigger_macos_command_line_tools_install() -> Result { + let output = Command::new("/usr/bin/xcode-select") + .arg("--install") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| format!("拉起 macOS 开发者工具安装器失败: {e}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let combined = if !stderr.is_empty() { stderr } else { stdout }; + let lower = combined.to_ascii_lowercase(); + + if output.status.success() + || lower.contains("install requested") + || lower.contains("already been requested") + { + return Ok("已拉起 macOS 开发者工具安装器。".to_string()); + } + + if lower.contains("already installed") { + return Err( + "系统提示 Command Line Tools 已安装,但当前仍未检测到 Git,请先执行系统更新或安装 Homebrew 后重试。" + .to_string(), + ); + } + + Err(format!("拉起 macOS 开发者工具安装器失败: {combined}")) +} + +#[cfg(not(target_os = "macos"))] +async fn trigger_macos_command_line_tools_install() -> Result { + Err("当前平台不支持拉起 macOS 开发者工具安装器。".to_string()) +} + fn read_base_openclaw_config() -> Result { let proxycast_path = openclaw_proxycast_config_path(); if proxycast_path.exists() { @@ -1264,7 +2132,9 @@ async fn should_use_china_package(app: &AppHandle) -> bool { } async fn detect_npm_global_prefix(npm_path: &str) -> Option { - let output = Command::new(npm_path) + let mut command = Command::new(npm_path); + apply_binary_runtime_path(&mut command, npm_path); + let output = command .arg("config") .arg("get") .arg("prefix") @@ -1290,8 +2160,52 @@ fn shell_escape(value: &str) -> String { format!("'{}'", value.replace('\'', "'\"'\"'")) } -fn shell_env_assignment(value: &str) -> String { - format!("NPM_CONFIG_PREFIX={} ", shell_escape(value)) +fn shell_command_escape(value: &str) -> String { + if cfg!(target_os = "windows") { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + shell_escape(value) + } +} + +fn shell_npm_prefix_assignment(value: &str) -> String { + if cfg!(target_os = "windows") { + format!( + "set \"NPM_CONFIG_PREFIX={}\" && ", + value.replace('"', "\"\"") + ) + } else { + format!("NPM_CONFIG_PREFIX={} ", shell_escape(value)) + } +} + +fn shell_path_assignment(binary_path: &str) -> String { + let Some(bin_dir) = Path::new(binary_path).parent() else { + return String::new(); + }; + let bin_dir = bin_dir.to_string_lossy(); + if cfg!(target_os = "windows") { + format!("set \"PATH={};%PATH%\" && ", bin_dir.replace('"', "\"\"")) + } else { + format!("PATH={}:$PATH ", shell_escape(bin_dir.as_ref())) + } +} + +fn prepend_path(dir: &Path) -> Option { + let mut paths = vec![dir.to_path_buf()]; + if let Some(current) = std::env::var_os("PATH") { + paths.extend(std::env::split_paths(¤t)); + } + std::env::join_paths(paths).ok() +} + +fn apply_binary_runtime_path(command: &mut Command, binary_path: &str) { + let Some(bin_dir) = Path::new(binary_path).parent() else { + return; + }; + if let Some(path) = prepend_path(bin_dir) { + command.env("PATH", path); + } } async fn find_command_in_shell(command_name: &str) -> Result, String> { @@ -1318,37 +2232,33 @@ async fn find_command_in_shell(command_name: &str) -> Result, Str } return Ok(find_command_in_known_locations(command_name) + .await? .map(|path| path.to_string_lossy().to_string())); } - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); - let output = Command::new(shell) - .arg("-lc") - .arg(format!("command -v {command_name}")) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - .await - .map_err(|e| format!("查找命令失败: {e}"))?; - - if output.status.success() { - let result = String::from_utf8_lossy(&output.stdout) - .lines() - .map(str::trim) - .find(|line| !line.is_empty()) - .map(str::to_string); - if result.is_some() { - return Ok(result); - } - } - - Ok( - find_command_in_known_locations(command_name) - .map(|path| path.to_string_lossy().to_string()), - ) + Ok(find_command_in_known_locations(command_name) + .await? + .map(|path| path.to_string_lossy().to_string())) } -fn find_command_in_known_locations(command_name: &str) -> Option { +async fn find_command_in_known_locations(command_name: &str) -> Result, String> { + let candidates = find_all_commands_in_known_locations(command_name); + if candidates.is_empty() { + return Ok(None); + } + + if command_name == "node" { + return select_best_node_candidate(candidates).await; + } + + if matches!(command_name, "npm" | "npx" | "openclaw") { + return select_node_runtime_candidate(candidates).await; + } + + Ok(candidates.into_iter().next()) +} + +fn find_all_commands_in_known_locations(command_name: &str) -> Vec { let mut search_dirs = Vec::new(); let mut seen = HashSet::new(); @@ -1371,6 +2281,9 @@ fn find_command_in_known_locations(command_name: &str) -> Option { push_dir(home.join(".npm-global/bin")); push_dir(home.join(".local/bin")); push_dir(home.join(".bun/bin")); + push_dir(home.join(".volta/bin")); + push_dir(home.join(".asdf/shims")); + push_dir(home.join(".local/share/mise/shims")); push_dir(home.join("Library/PhpWebStudy/env/node/bin")); let nvm_versions = home.join(".nvm/versions/node"); @@ -1379,6 +2292,13 @@ fn find_command_in_known_locations(command_name: &str) -> Option { push_dir(entry.path().join("bin")); } } + + let fnm_versions = home.join(".fnm/node-versions"); + if let Ok(entries) = std::fs::read_dir(fnm_versions) { + for entry in entries.flatten() { + push_dir(entry.path().join("installation/bin")); + } + } } if cfg!(target_os = "macos") { @@ -1388,10 +2308,10 @@ fn find_command_in_known_locations(command_name: &str) -> Option { push_dir(PathBuf::from("/bin")); } - find_command_in_paths(command_name, &search_dirs) + find_all_commands_in_paths(command_name, &search_dirs) } -fn find_command_in_paths(command_name: &str, search_dirs: &[PathBuf]) -> Option { +fn find_all_commands_in_paths(command_name: &str, search_dirs: &[PathBuf]) -> Vec { #[cfg(target_os = "windows")] let candidates = [ format!("{command_name}.exe"), @@ -1403,16 +2323,103 @@ fn find_command_in_paths(command_name: &str, search_dirs: &[PathBuf]) -> Option< #[cfg(not(target_os = "windows"))] let candidates = [command_name.to_string()]; + let mut matches = Vec::new(); + let mut seen = HashSet::new(); for dir in search_dirs { for candidate in &candidates { let path = dir.join(candidate); - if path.is_file() { - return Some(path); + if path.is_file() && seen.insert(path.clone()) { + matches.push(path); } } } - None + matches +} + +async fn select_best_node_candidate(candidates: Vec) -> Result, String> { + let mut versioned = Vec::with_capacity(candidates.len()); + for candidate in candidates { + let version = read_binary_semver(&candidate).await; + versioned.push((candidate, version)); + } + Ok(select_best_semver_candidate(versioned)) +} + +async fn select_node_runtime_candidate( + candidates: Vec, +) -> Result, String> { + let preferred_node = + select_best_node_candidate(find_all_commands_in_known_locations("node")).await?; + if let Some(preferred_bin_dir) = preferred_node.as_deref().and_then(Path::parent) { + if let Some(candidate) = candidates + .iter() + .find(|candidate| candidate.parent() == Some(preferred_bin_dir)) + { + return Ok(Some(candidate.clone())); + } + } + + let mut versioned = Vec::with_capacity(candidates.len()); + for candidate in &candidates { + let version = match sibling_node_path(candidate) { + Some(node_path) => read_binary_semver(&node_path).await, + None => None, + }; + versioned.push((candidate.clone(), version)); + } + + Ok(select_best_semver_candidate(versioned).or_else(|| candidates.into_iter().next())) +} + +fn sibling_node_path(command_path: &Path) -> Option { + #[cfg(target_os = "windows")] + let node_name = "node.exe"; + + #[cfg(not(target_os = "windows"))] + let node_name = "node"; + + let node_path = command_path.parent()?.join(node_name); + node_path.is_file().then_some(node_path) +} + +async fn read_binary_semver(path: &Path) -> Option<(u64, u64, u64)> { + let output = Command::new(path) + .arg("--version") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .ok()?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + parse_semver(stdout.trim()).or_else(|| parse_semver(stderr.trim())) +} + +fn select_best_semver_candidate( + candidates: Vec<(PathBuf, Option<(u64, u64, u64)>)>, +) -> Option { + candidates + .iter() + .filter_map(|(path, version)| { + version + .filter(|version| *version >= NODE_MIN_VERSION) + .map(|version| (path.clone(), version)) + }) + .max_by_key(|(_, version)| *version) + .map(|(path, _)| path) + .or_else(|| { + candidates + .iter() + .filter_map(|(path, version)| version.map(|version| (path.clone(), version))) + .max_by_key(|(_, version)| *version) + .map(|(path, _)| path) + }) + .or_else(|| candidates.into_iter().next().map(|(path, _)| path)) } async fn run_shell_command_with_progress( @@ -1607,6 +2614,14 @@ fn parse_semver(value: &str) -> Option<(u64, u64, u64)> { Some((major, minor, patch)) } +fn parse_semver_from_text(value: &str) -> Option<(u64, u64, u64)> { + parse_semver(value).or_else(|| { + value + .split(|ch: char| ch.is_whitespace() || ch == ',' || ch == '(' || ch == ')') + .find_map(parse_semver) + }) +} + fn format_semver(version: (u64, u64, u64)) -> String { format!("{}.{}.{}", version.0, version.1, version.2) } @@ -1614,8 +2629,9 @@ fn format_semver(version: (u64, u64, u64)) -> String { #[cfg(test)] mod tests { use super::{ - determine_api_type, extract_gateway_auth_token, format_provider_base_url, has_api_version, - trim_trailing_slash, + build_environment_status, determine_api_type, extract_gateway_auth_token, + format_provider_base_url, has_api_version, parse_semver_from_text, trim_trailing_slash, + DependencyStatus, }; use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType, ProviderGroup}; use chrono::Utc; @@ -1747,4 +2763,42 @@ mod tests { assert_eq!(extract_gateway_auth_token(&config), None); } + + #[test] + fn parses_semver_from_git_version_text() { + assert_eq!( + parse_semver_from_text("git version 2.39.5 (Apple Git-154)"), + Some((2, 39, 5)) + ); + } + + #[test] + fn environment_status_prioritizes_missing_node() { + let env = build_environment_status( + DependencyStatus { + status: "missing".to_string(), + version: None, + path: None, + message: "missing node".to_string(), + auto_install_supported: true, + }, + DependencyStatus { + status: "ok".to_string(), + version: Some("2.43.0".to_string()), + path: Some("/usr/bin/git".to_string()), + message: "git ok".to_string(), + auto_install_supported: true, + }, + DependencyStatus { + status: "missing".to_string(), + version: None, + path: None, + message: "openclaw missing".to_string(), + auto_install_supported: false, + }, + ); + + assert_eq!(env.recommended_action, "install_node"); + assert_eq!(env.openclaw.auto_install_supported, false); + } } diff --git a/src/App.tsx b/src/App.tsx index b4e8cc22e..0796f2b85 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -539,6 +539,12 @@ function AppContent() { initialUserPrompt={ (pageParams as AgentPageParams).initialUserPrompt } + initialSessionName={ + (pageParams as AgentPageParams).initialSessionName + } + entryBannerMessage={ + (pageParams as AgentPageParams).entryBannerMessage + } theme={(pageParams as AgentPageParams).theme} lockTheme={(pageParams as AgentPageParams).lockTheme} fromResources={(pageParams as AgentPageParams).fromResources} diff --git a/src/components/agent/chat/components/MarkdownRenderer.test.tsx b/src/components/agent/chat/components/MarkdownRenderer.test.tsx new file mode 100644 index 000000000..0ea5fc335 --- /dev/null +++ b/src/components/agent/chat/components/MarkdownRenderer.test.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MarkdownRenderer } from "./MarkdownRenderer"; + +vi.mock("react-syntax-highlighter", () => ({ + Prism: ({ + children, + className, + }: { + children: React.ReactNode; + className?: string; + }) => ( +
+      {children}
+    
+ ), +})); + +vi.mock("react-syntax-highlighter/dist/esm/styles/prism", () => ({ + oneDark: {}, +})); + +vi.mock("./ArtifactPlaceholder", () => ({ + ArtifactPlaceholder: ({ language }: { language: string }) => ( +
{language}
+ ), +})); + +vi.mock("./A2UITaskCard", () => ({ + A2UITaskCard: () =>
, + A2UITaskLoadingCard: () =>
, +})); + +interface MountedHarness { + container: HTMLDivElement; + root: Root; +} + +const mountedRoots: MountedHarness[] = []; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); +}); + +function render(content: string, isStreaming = false): HTMLDivElement { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + mountedRoots.push({ container, root }); + return container; +} + +describe("MarkdownRenderer", () => { + it("非流式时应保留 raw html 渲染能力", () => { + const content = [ + "前置文本", + "", + '
原始 HTML
', + "", + "后置文本", + ].join("\n"); + + const container = render(content, false); + + expect(container.querySelector(".rendered-html")).not.toBeNull(); + expect(container.textContent).toContain("原始 HTML"); + }); + + it("大段流式输出时应跳过 raw html 重解析", () => { + const content = [ + "A".repeat(2_200), + "", + '
原始 HTML
', + "", + "结尾文本", + ].join("\n"); + + const container = render(content, true); + + expect(container.querySelector(".rendered-html")).toBeNull(); + expect(container.textContent).toContain("结尾文本"); + }); +}); diff --git a/src/components/agent/chat/components/MarkdownRenderer.tsx b/src/components/agent/chat/components/MarkdownRenderer.tsx index a0210ad41..d57cb7c2a 100644 --- a/src/components/agent/chat/components/MarkdownRenderer.tsx +++ b/src/components/agent/chat/components/MarkdownRenderer.tsx @@ -14,6 +14,8 @@ import { CHAT_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/ta import { ArtifactPlaceholder } from "./ArtifactPlaceholder"; import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard"; +const STREAMING_LIGHT_RENDER_THRESHOLD = 2_000; + // Custom styles for markdown content to match Cherry Studio const MarkdownContainer = styled.div` font-size: 15px; @@ -245,6 +247,19 @@ export const MarkdownRenderer: React.FC = memo( isStreaming = false, }) => { const [copied, setCopied] = React.useState(null); + const useLightweightStreamingRender = + isStreaming && content.length >= STREAMING_LIGHT_RENDER_THRESHOLD; + + const remarkPlugins = React.useMemo( + () => + useLightweightStreamingRender ? [remarkGfm] : [remarkGfm, remarkMath], + [useLightweightStreamingRender], + ); + + const rehypePlugins = React.useMemo( + () => (useLightweightStreamingRender ? [] : [rehypeRaw, rehypeKatex]), + [useLightweightStreamingRender], + ); const handleCopy = (code: string) => { navigator.clipboard.writeText(code); @@ -353,8 +368,9 @@ export const MarkdownRenderer: React.FC = memo( {/* 如果还有其他内容,渲染 markdown */} {!hasOnlyPlaceholders && processedContent.text.trim() && ( = memo( ); } + if (useLightweightStreamingRender) { + return ( +
+                      {codeContent}
+                    
+ ); + } + // Block code - 完整显示 const isCopied = copied === codeContent; diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.ts b/src/components/agent/chat/hooks/useAsterAgentChat.ts index ca19df346..a8b32c94f 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.ts +++ b/src/components/agent/chat/hooks/useAsterAgentChat.ts @@ -1484,27 +1484,66 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { } }, [filterSessionsByWorkspace, workspaceId]); + const createFreshSession = useCallback( + async (sessionName?: string): Promise => { + try { + const resolvedWorkspaceId = getRequiredWorkspaceId(); + const newSessionId = await createAsterSession( + resolvedWorkspaceId, + undefined, + sessionName, + executionStrategy, + ); + + const now = new Date(); + setMessages([]); + setPendingActions([]); + setSessionId(newSessionId); + setTopics((prev) => [ + { + id: newSessionId, + title: sessionName?.trim() || "新话题", + createdAt: now, + updatedAt: now, + messagesCount: 0, + executionStrategy, + }, + ...prev.filter((topic) => topic.id !== newSessionId), + ]); + currentAssistantMsgIdRef.current = null; + currentStreamingSessionIdRef.current = null; + hydratedSessionRef.current = newSessionId; + skipAutoRestoreRef.current = false; + restoredWorkspaceRef.current = resolvedWorkspaceId; + + saveTransient(getScopedSessionKey(), newSessionId); + savePersisted(getScopedPersistedSessionKey(), newSessionId); + saveTransient(getScopedMessagesKey(), []); + + void loadTopics(); + return newSessionId; + } catch (error) { + console.error("[AsterChat] 创建新话题失败:", error); + toast.error(`创建新话题失败: ${error}`); + return null; + } + }, + [ + executionStrategy, + getRequiredWorkspaceId, + getScopedMessagesKey, + getScopedPersistedSessionKey, + getScopedSessionKey, + loadTopics, + ], + ); + // 确保有会话 const ensureSession = useCallback(async (): Promise => { if (sessionId) return sessionId; - try { - const resolvedWorkspaceId = getRequiredWorkspaceId(); - const newSessionId = await createAsterSession( - resolvedWorkspaceId, - undefined, - undefined, - executionStrategy, - ); - setSessionId(newSessionId); - skipAutoRestoreRef.current = false; - return newSessionId; - } catch (error) { - console.error("[AsterChat] 创建会话失败:", error); - toast.error(`创建会话失败: ${error}`); - return null; - } - }, [executionStrategy, getRequiredWorkspaceId, sessionId]); + return createFreshSession(); + }, [createFreshSession, sessionId]); // 辅助函数:追加文本到 contentParts const appendTextToParts = ( @@ -2192,7 +2231,13 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { if (unlisten) unlisten(); } }, - [ensureSession, executionStrategy, getRequiredWorkspaceId, onWriteFile, systemPrompt], + [ + ensureSession, + executionStrategy, + getRequiredWorkspaceId, + onWriteFile, + systemPrompt, + ], ); // 停止发送 @@ -2906,6 +2951,8 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { topics, sessionId, + createFreshSession, + ensureSession, switchTopic, deleteTopic, renameTopic, diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index 29872fd9e..bf77e8db0 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -1973,3 +1973,26 @@ describe("AgentChatPage 自动引导", () => { ); }); }); + +describe("AgentChatPage 视频主题工作台", () => { + it("视频主题工作台不应渲染底部通用输入条,也不应自动发送首条请求", async () => { + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + + const container = renderPage({ + projectId: "project-video", + contentId: "content-video", + theme: "video", + lockTheme: true, + }); + await flushEffects(10); + + expect(container.querySelector('[data-testid="inputbar"]')).toBeNull(); + expect(container.querySelector('[data-testid="theme-workbench-sidebar"]')).toBeNull(); + expect(sharedTriggerAIGuideMock).not.toHaveBeenCalled(); + expect(sharedSendMessageMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index b9c5847ad..bb1925a27 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -18,7 +18,7 @@ import { } from "react"; import { toast } from "sonner"; import styled from "styled-components"; -import { PanelLeftOpen } from "lucide-react"; +import { Info, PanelLeftOpen } from "lucide-react"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { invoke } from "@tauri-apps/api/core"; import { safeListen } from "@/lib/dev-bridge"; @@ -227,6 +227,28 @@ const ChatContainerInner = styled.div` overflow: hidden; `; +const EntryBanner = styled.div` + display: flex; + align-items: center; + gap: 8px; + margin: 8px 12px 0; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid hsl(var(--primary) / 0.18); + background: hsl(var(--primary) / 0.08); + color: hsl(var(--foreground)); + font-size: 13px; +`; + +const EntryBannerClose = styled.button` + margin-left: auto; + border: none; + background: transparent; + color: hsl(var(--muted-foreground)); + cursor: pointer; + font-size: 13px; +`; + const ChatContent = styled.div` display: flex; flex-direction: column; @@ -1542,6 +1564,8 @@ export function AgentChatPage({ hideInlineStepProgress = false, onWorkflowProgressChange, initialUserPrompt, + initialSessionName, + entryBannerMessage, onInitialUserPromptConsumed, newChatAt, onRecommendationClick: _onRecommendationClick, @@ -1565,6 +1589,8 @@ export function AgentChatPage({ snapshot: WorkflowProgressSnapshot | null, ) => void; initialUserPrompt?: string; + initialSessionName?: string; + entryBannerMessage?: string; onInitialUserPromptConsumed?: () => void; newChatAt?: number; onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void; @@ -1577,6 +1603,9 @@ export function AgentChatPage({ useState(false); const [input, setInput] = useState(""); const [selectedText, setSelectedText] = useState(""); + const [entryBannerVisible, setEntryBannerVisible] = useState( + Boolean(entryBannerMessage), + ); const [chatToolPreferences, setChatToolPreferences] = useState(() => loadChatToolPreferences()); @@ -1609,6 +1638,10 @@ export function AgentChatPage({ setCreationMode(initialCreationMode); }, [initialCreationMode]); + useEffect(() => { + setEntryBannerVisible(Boolean(entryBannerMessage)); + }, [entryBannerMessage]); + useEffect(() => { saveChatToolPreferences(chatToolPreferences); }, [chatToolPreferences]); @@ -2124,6 +2157,7 @@ export function AgentChatPage({ triggerAIGuide, topics, sessionId, + createFreshSession, switchTopic: originalSwitchTopic, deleteTopic, renameTopic, @@ -3988,7 +4022,27 @@ export function AgentChatPage({ setActiveTheme(normalizeInitialTheme(initialTheme)); setCreationMode(initialCreationMode ?? "guided"); } + + const toastId = initialSessionName + ? "openclaw-agent-handoff" + : "agent-new-chat"; + + void (async () => { + const newSessionId = await createFreshSession(initialSessionName); + if (newSessionId) { + toast.success( + initialSessionName + ? `已创建新话题:${initialSessionName}` + : "已创建新话题", + { id: toastId }, + ); + } else { + toast.error("创建新话题失败,请重试。", { id: toastId }); + } + })(); }, [ + createFreshSession, + initialSessionName, newChatAt, clearMessages, externalProjectId, @@ -4832,6 +4886,10 @@ export function AgentChatPage({ // 当从项目进入且有 contentId 时,自动启动创作引导 useEffect(() => { + if (mappedTheme === "video") { + return; + } + // 条件: // - 有 contentId(从项目创建内容进入) // - 没有消息(messages.length === 0) @@ -4941,8 +4999,10 @@ export function AgentChatPage({ useEffect(() => { const pendingInitialPrompt = (initialUserPrompt || "").trim(); if ( + mappedTheme === "video" || !pendingInitialPrompt || contentId || + !sessionId || messages.length > 0 || isSending ) { @@ -4969,8 +5029,10 @@ export function AgentChatPage({ handleSend, initialUserPrompt, isSending, + mappedTheme, messages.length, onInitialUserPromptConsumed, + sessionId, ]); // 当 contentId 变化时重置引导状态 @@ -5025,14 +5087,19 @@ export function AgentChatPage({ // 主题工作台始终使用聊天布局与浮层输入,不走旧 EmptyState 输入流程 const showChatLayout = hasMessages || isThemeWorkbench; + const shouldHideThemeWorkbenchInputForTheme = + isThemeWorkbench && mappedTheme === "video"; + const shouldShowThemeWorkbenchSidebarForTheme = mappedTheme !== "video"; const showThemeWorkbenchSidebar = showChatPanel && showSidebar && isThemeWorkbench && + shouldShowThemeWorkbenchSidebarForTheme && (!enableThemeWorkbenchPanelCollapse || !themeWorkbenchSidebarCollapsed); const showThemeWorkbenchLeftExpandButton = showChatPanel && showSidebar && + shouldShowThemeWorkbenchSidebarForTheme && enableThemeWorkbenchPanelCollapse && themeWorkbenchSidebarCollapsed; const handleThemeWorkbenchDeleteTopic = useCallback(() => {}, []); @@ -5437,6 +5504,19 @@ export function AgentChatPage({ () => ( + {entryBannerVisible && entryBannerMessage ? ( + + + {entryBannerMessage} + setEntryBannerVisible(false)} + aria-label="关闭入口提示" + > + 关闭 + + + ) : null} {!hideInlineStepProgress && isContentCreationMode && hasMessages && @@ -5582,7 +5662,10 @@ export function AgentChatPage({
)} - {!contextWorkspace.enabled ? inputbarNode : null} + {!contextWorkspace.enabled && + !shouldHideThemeWorkbenchInputForTheme + ? inputbarNode + : null} )} @@ -5598,6 +5681,8 @@ export function AgentChatPage({ currentStepIndex, deleteMessage, dismissWorkspacePathError, + entryBannerMessage, + entryBannerVisible, editMessage, executionStrategy, generalCanvasState.content, @@ -5632,6 +5717,7 @@ export function AgentChatPage({ setWorkspaceHealthError, shouldCollapseCodeBlocks, selectedText, + setEntryBannerVisible, showChatLayout, handleRunStyleAudit, handleRunStyleRewrite, @@ -5643,6 +5729,7 @@ export function AgentChatPage({ workspaceHealthError, workspacePathMissing, resolvedCanvasState, + shouldHideThemeWorkbenchInputForTheme, ], ); @@ -5875,7 +5962,9 @@ export function AgentChatPage({ - {isThemeWorkbench && showChatLayout ? ( + {isThemeWorkbench && + showChatLayout && + !shouldHideThemeWorkbenchInputForTheme ? ( @@ -5930,6 +6021,7 @@ export function AgentChatPage({ onBackToProjectManagement, pendingA2UIForm, projectId, + shouldHideThemeWorkbenchInputForTheme, showChatLayout, showChatPanel, showNovelNavbarControls, diff --git a/src/components/content-creator/canvas/video/VideoCanvas.tsx b/src/components/content-creator/canvas/video/VideoCanvas.tsx index a2b59a30c..525e1e534 100644 --- a/src/components/content-creator/canvas/video/VideoCanvas.tsx +++ b/src/components/content-creator/canvas/video/VideoCanvas.tsx @@ -2,9 +2,6 @@ import React, { memo, useEffect, useMemo, useState } from "react"; import styled from "styled-components"; import { toast } from "sonner"; import { - ChevronLeft, - ChevronRight, - LayoutGrid, PanelLeftClose, PanelLeftOpen, } from "lucide-react"; @@ -151,60 +148,6 @@ const WorkspaceFrame = styled.div` overflow: hidden; `; -const TopicPanel = styled.div<{ $collapsed: boolean }>` - position: relative; - width: ${({ $collapsed }) => ($collapsed ? "0px" : "90px")}; - min-width: ${({ $collapsed }) => ($collapsed ? "0px" : "90px")}; - height: 100%; - border-left: ${({ $collapsed }) => - $collapsed ? "none" : "1px solid hsl(var(--border))"}; - background: hsl(var(--background)); - overflow: visible; - transition: - width 0.2s ease, - min-width 0.2s ease; -`; - -const TopicPanelHandle = styled.button` - position: absolute; - left: -14px; - top: 50%; - transform: translateY(-50%); - width: 28px; - height: 50px; - border: 1px solid hsl(var(--border)); - border-right: none; - border-radius: 12px 0 0 12px; - background: hsl(var(--background)); - color: hsl(var(--muted-foreground)); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - z-index: 6; - - &:hover { - color: hsl(var(--foreground)); - } -`; - -const MainAction = styled.button` - position: absolute; - top: 10px; - right: 10px; - z-index: 5; - width: 20px; - height: 20px; - border-radius: 6px; - border: none; - background: transparent; - color: hsl(var(--muted-foreground)); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: default; -`; - export const VideoCanvas: React.FC = memo( ({ state, @@ -214,7 +157,6 @@ export const VideoCanvas: React.FC = memo( onClose: _onClose, }) => { const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const [topicPanelCollapsed, setTopicPanelCollapsed] = useState(false); const [providers, setProviders] = useState([]); useEffect(() => { @@ -373,30 +315,12 @@ export const VideoCanvas: React.FC = memo( - - - - - - setTopicPanelCollapsed((previous) => !previous) - } - > - {topicPanelCollapsed ? ( - - ) : ( - - )} - - diff --git a/src/components/content-creator/canvas/video/VideoSidebar.tsx b/src/components/content-creator/canvas/video/VideoSidebar.tsx index 2f72f9760..4ed7cf02a 100644 --- a/src/components/content-creator/canvas/video/VideoSidebar.tsx +++ b/src/components/content-creator/canvas/video/VideoSidebar.tsx @@ -246,6 +246,24 @@ const UploadPrompt = styled.div` } `; +const UploadActionButton = styled.button` + margin-top: 8px; + height: 30px; + border-radius: 999px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + color: hsl(var(--foreground)); + padding: 0 12px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + + &:hover { + border-color: hsl(var(--primary)); + color: hsl(var(--primary)); + } +`; + const PreviewBox = styled.div` width: 100%; min-height: 116px; @@ -291,6 +309,24 @@ const RemovePreviewButton = styled.button` cursor: pointer; `; +const ReplacePreviewButton = styled.button` + position: absolute; + top: 8px; + left: 8px; + height: 24px; + border: none; + border-radius: 999px; + background: hsl(var(--background) / 0.92); + color: hsl(var(--foreground)); + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 10px; + font-size: 11px; + font-weight: 600; + cursor: pointer; +`; + const RatioGrid = styled.div` display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); @@ -759,6 +795,12 @@ export const VideoSidebar: React.FC = memo( onStateChange({ ...state, endImage: value }); }; + const openFramePicker = (field: FrameImageField) => { + const inputRef = + field === "startImage" ? startFileInputRef : endFileInputRef; + inputRef.current?.click(); + }; + const handleUploadFiles = async ( field: FrameImageField, files: FileList | null, @@ -863,7 +905,6 @@ export const VideoSidebar: React.FC = memo( {frame.title} inputRef.current?.click()} onDragOver={(event) => { event.preventDefault(); setDraggingArea(frame.area); @@ -890,6 +931,15 @@ export const VideoSidebar: React.FC = memo( {previewImage ? ( {`${frame.title}预览`} + { + event.stopPropagation(); + openFramePicker(frame.field); + }} + > + 更换 + { @@ -897,15 +947,24 @@ export const VideoSidebar: React.FC = memo( setFrameImage(frame.field, undefined); }} > - - - 点击或拖拽替换图片 + + + 拖拽上传可替换图片 ) : (
添加图片
-
点击或拖拽上传
+
拖拽上传参考图
+ { + event.stopPropagation(); + openFramePicker(frame.field); + }} + > + 选择图片 +
)}
diff --git a/src/components/openclaw/OpenClawInstallPage.tsx b/src/components/openclaw/OpenClawInstallPage.tsx index 06ece99b9..cc5a92ee2 100644 --- a/src/components/openclaw/OpenClawInstallPage.tsx +++ b/src/components/openclaw/OpenClawInstallPage.tsx @@ -1,52 +1,222 @@ -import { Download, ExternalLink, Link2, Loader2, Package } from "lucide-react"; +import type { ReactNode } from "react"; +import { + Download, + ExternalLink, + GitBranch, + Loader2, + Package, + RefreshCw, + TerminalSquare, + Trash2, +} from "lucide-react"; +import type { + OpenClawDependencyStatus, + OpenClawEnvironmentStatus, +} from "@/lib/api/openclaw"; import { OpenClawMark } from "./OpenClawMark"; interface OpenClawInstallPageProps { - binaryPath?: string | null; - nodeStatusText: string; - gitStatusText: string; + environmentStatus: OpenClawEnvironmentStatus | null; + busy: boolean; installing: boolean; + installingNode: boolean; + installingGit: boolean; + cleaningTemp: boolean; onInstall: () => void; + onInstallNode: () => void; + onInstallGit: () => void; + onRefresh: () => void; + onCleanupTemp: () => void; onOpenDocs: () => void; onDownloadNode: () => void; onDownloadGit: () => void; } +function resolveStatusTone(status: OpenClawDependencyStatus["status"]): string { + switch (status) { + case "ok": + return "text-emerald-600"; + case "version_low": + return "text-amber-600"; + case "missing": + return "text-rose-600"; + default: + return "text-muted-foreground"; + } +} + +function resolveStatusLabel( + status: OpenClawDependencyStatus["status"], +): string { + switch (status) { + case "ok": + return "已就绪"; + case "version_low": + return "版本过低"; + case "missing": + return "未检测到"; + default: + return status; + } +} + +function DependencyCard({ + title, + icon, + status, + busy, + primaryLabel, + onPrimaryAction, + secondaryLabel, + onSecondaryAction, +}: { + title: string; + icon: ReactNode; + status: OpenClawDependencyStatus | null; + busy: boolean; + primaryLabel?: string; + onPrimaryAction?: () => void; + secondaryLabel?: string; + onSecondaryAction?: () => void; +}) { + const resolvedStatus = status || { + status: "unknown", + version: null, + path: null, + message: "尚未检测", + autoInstallSupported: false, + }; + const pathText = resolvedStatus.path || "当前未检测到路径"; + + return ( +
+
+
+ {icon} + {title} +
+ + {resolveStatusLabel(resolvedStatus.status)} + +
+ +

+ {resolvedStatus.message} +

+ +
+
版本:{resolvedStatus.version || "未检测到"}
+
路径:{pathText}
+
+ + {(primaryLabel || secondaryLabel) && ( +
+ {primaryLabel && onPrimaryAction ? ( + + ) : null} + {secondaryLabel && onSecondaryAction ? ( + + ) : null} +
+ )} +
+ ); +} + export function OpenClawInstallPage({ - binaryPath, - nodeStatusText, - gitStatusText, + environmentStatus, + busy, installing, + installingNode, + installingGit, + cleaningTemp, onInstall, + onInstallNode, + onInstallGit, + onRefresh, + onCleanupTemp, onOpenDocs, onDownloadNode, onDownloadGit, }: OpenClawInstallPageProps) { + const nodeReady = environmentStatus?.node.status === "ok"; + const gitReady = environmentStatus?.git.status === "ok"; + const openclawReady = environmentStatus?.openclaw.status === "ok"; + const installLabel = openclawReady + ? "重新安装 OpenClaw" + : nodeReady && gitReady + ? "安装 OpenClaw" + : "一键修复环境并安装 OpenClaw"; + return (
-
+

- OpenClaw 未安装 + OpenClaw 环境安装

- 先完成本地安装后,才可以继续配置模型、启动 Gateway,并进入 Dashboard。 + {environmentStatus?.summary || + "正在检查 Node.js、Git 与 OpenClaw 环境,稍后会给出一键修复建议。"}

+ + -
+ } + status={environmentStatus?.git ?? null} + busy={busy && installingGit} + primaryLabel={ + environmentStatus?.git.autoInstallSupported && + environmentStatus?.git.status !== "ok" + ? "一键安装 Git" + : undefined + } + onPrimaryAction={onInstallGit} + secondaryLabel="手动下载 Git" + onSecondaryAction={onDownloadGit} + /> -
-
- - Git -
-

- {gitStatusText} -

- -
+ } + status={environmentStatus?.openclaw ?? null} + busy={busy && installing} + primaryLabel={!openclawReady ? "安装 OpenClaw" : undefined} + onPrimaryAction={!openclawReady ? onInstall : undefined} + />
+ +
+
当前安装策略
+
+

- 优先复用系统里已满足要求的 Node.js / Git,避免重复安装。

+

+ - + 缺失依赖时,优先尝试应用内一键安装;若当前平台不支持,则自动降级到手动下载引导。 +

+

- 安装完成后会自动重新检测环境,并继续执行 OpenClaw 安装。

+
+ + {environmentStatus?.tempArtifacts?.length ? ( +
+
+ 可清理的临时文件 +
+
+ {environmentStatus.tempArtifacts.map((item) => ( +
+ {item} +
+ ))} +
+
+ ) : null} +
); diff --git a/src/components/openclaw/OpenClawPage.tsx b/src/components/openclaw/OpenClawPage.tsx index 4ad335b9a..1834feff5 100644 --- a/src/components/openclaw/OpenClawPage.tsx +++ b/src/components/openclaw/OpenClawPage.tsx @@ -17,6 +17,7 @@ import { type OpenClawBinaryAvailabilityStatus, type OpenClawBinaryInstallStatus, type OpenClawChannelInfo, + type OpenClawEnvironmentStatus, type OpenClawGatewayStatus, type OpenClawHealthInfo, type OpenClawInstallProgressEvent, @@ -56,6 +57,7 @@ const SUPPORTED_PROVIDER_TYPES = new Set([ const progressSubpageByAction: Record = { install: "installing", + repair: "installing", uninstall: "uninstalling", restart: "restarting", }; @@ -138,6 +140,8 @@ function openClawOperationLabel(kind: OpenClawOperationKind | null): string { switch (kind) { case "install": return "安装"; + case "repair": + return "修复环境"; case "uninstall": return "卸载"; case "restart": @@ -299,6 +303,8 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { const [statusResolved, setStatusResolved] = useState(false); const [installedStatus, setInstalledStatus] = useState(null); + const [environmentStatus, setEnvironmentStatus] = + useState(null); const [nodeStatus, setNodeStatus] = useState( null, ); @@ -315,9 +321,14 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { const [starting, setStarting] = useState(false); const [stopping, setStopping] = useState(false); const [checkingHealth, setCheckingHealth] = useState(false); + const [cleaningTemp, setCleaningTemp] = useState(false); + const [handingOffToAgent, setHandingOffToAgent] = useState(false); const [operationState, setOperationState] = useState({ kind: null, + target: null, running: false, + title: null, + description: null, message: null, returnSubpage: "install", }); @@ -521,14 +532,24 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { const refreshAll = useCallback(async () => { try { - const [installedResult, nodeResult, gitResult] = await Promise.all([ - openclawApi.checkInstalled(), - openclawApi.checkNodeVersion(), - openclawApi.checkGitAvailable(), - ]); - setInstalledStatus(installedResult); - setNodeStatus(nodeResult); - setGitStatus(gitResult); + const environment = await openclawApi.getEnvironmentStatus(); + setEnvironmentStatus(environment); + setInstalledStatus({ + installed: environment.openclaw.status === "ok", + path: environment.openclaw.path, + }); + setNodeStatus({ + status: + environment.node.status === "missing" + ? "not_found" + : environment.node.status, + version: environment.node.version, + path: environment.node.path, + }); + setGitStatus({ + available: environment.git.status === "ok", + path: environment.git.path, + }); await Promise.all([ refreshGatewayRuntime(), refreshDashboardWindowState(), @@ -645,18 +666,36 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { ); const runProgressOperation = useCallback( - async ( - kind: OpenClawOperationKind, - action: () => Promise<{ success: boolean; message: string }>, - successSubpage: OpenClawSubpage, - returnSubpage: OpenClawSubpage, - initialLogs: OpenClawInstallProgressEvent[] = [], - onSuccess?: () => void, - ) => { + async (options: { + kind: OpenClawOperationKind; + target?: OpenClawOperationState["target"]; + title?: string; + description?: string; + action: () => Promise<{ success: boolean; message: string }>; + successSubpage: OpenClawSubpage; + returnSubpage: OpenClawSubpage; + initialLogs?: OpenClawInstallProgressEvent[]; + onSuccess?: () => void; + }) => { + const { + kind, + target = "environment", + title = null, + description = null, + action, + successSubpage, + returnSubpage, + initialLogs = [], + onSuccess, + } = options; + setInstallLogs(initialLogs); setOperationState({ kind, + target, running: true, + title, + description, message: null, returnSubpage, }); @@ -667,7 +706,10 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { const result = await action(); setOperationState({ kind, + target, running: false, + title, + description, message: result.message, returnSubpage, }); @@ -686,7 +728,10 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { const message = error instanceof Error ? error.message : String(error); setOperationState({ kind, + target, running: false, + title, + description, message, returnSubpage, }); @@ -698,28 +743,22 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { ); const handleInstall = useCallback(async () => { - const preview = await openclawApi - .getCommandPreview("install") - .catch(() => null); - await runProgressOperation( - "install", - () => openclawApi.install(), - "runtime", - "install", - preview - ? [ - { level: "info", message: preview.title }, - ...preview.command - .split("\n") - .map((line) => ({ level: "info" as const, message: line })), - ] - : [ - { - level: "info", - message: "已发送安装请求,正在等待后端返回安装命令...", - }, - ], - ); + await runProgressOperation({ + kind: "install", + target: "openclaw", + title: "正在修复环境并安装 OpenClaw", + description: + "ProxyCast 会先自动检查并修复 Node.js / Git,再继续安装 OpenClaw。", + action: () => openclawApi.install(), + successSubpage: "runtime", + returnSubpage: "install", + initialLogs: [ + { + level: "info", + message: "已发送安装请求,正在检查并修复 OpenClaw 运行环境...", + }, + ], + }); }, [runProgressOperation]); const handleUninstall = useCallback(async () => { @@ -732,12 +771,13 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { .getCommandPreview("uninstall") .catch(() => null); - await runProgressOperation( - "uninstall", - () => openclawApi.uninstall(), - "install", - installed ? "configure" : "install", - preview + await runProgressOperation({ + kind: "uninstall", + target: "openclaw", + action: () => openclawApi.uninstall(), + successSubpage: "install", + returnSubpage: installed ? "configure" : "install", + initialLogs: preview ? [ { level: "info", message: preview.title }, ...preview.command @@ -750,11 +790,11 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { message: "已发送卸载请求,正在等待后端返回卸载命令...", }, ], - () => { + onSuccess: () => { clearLastSynced(); setSelectedModelId(""); }, - ); + }); }, [ clearLastSynced, closeDashboardWindowSilently, @@ -769,12 +809,13 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { .getCommandPreview("restart", gatewayPort) .catch(() => null); - await runProgressOperation( - "restart", - () => openclawApi.restartGateway(), - "runtime", - "runtime", - preview + await runProgressOperation({ + kind: "restart", + target: "openclaw", + action: () => openclawApi.restartGateway(), + successSubpage: "runtime", + returnSubpage: "runtime", + initialLogs: preview ? [ { level: "info", message: preview.title }, ...preview.command @@ -787,9 +828,45 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { message: "已发送重启请求,正在停止并重新拉起 Gateway...", }, ], - ); + }); }, [closeDashboardWindowSilently, gatewayPort, runProgressOperation]); + const handleInstallNode = useCallback(async () => { + await runProgressOperation({ + kind: "repair", + target: "node", + title: "正在安装 Node.js 环境", + description: "ProxyCast 会优先尝试应用内一键安装或修复 Node.js。", + action: () => openclawApi.installDependency("node"), + successSubpage: "install", + returnSubpage: "install", + initialLogs: [ + { + level: "info", + message: "已发送 Node.js 修复请求,正在准备安装流程...", + }, + ], + }); + }, [runProgressOperation]); + + const handleInstallGit = useCallback(async () => { + await runProgressOperation({ + kind: "repair", + target: "git", + title: "正在安装 Git 环境", + description: "ProxyCast 会优先尝试应用内一键安装或修复 Git。", + action: () => openclawApi.installDependency("git"), + successSubpage: "install", + returnSubpage: "install", + initialLogs: [ + { + level: "info", + message: "已发送 Git 修复请求,正在准备安装流程...", + }, + ], + }); + }, [runProgressOperation]); + const handleSync = useCallback(async () => { await syncProviderConfig(); }, [syncProviderConfig]); @@ -890,6 +967,23 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { } }, []); + const handleCleanupTempArtifacts = useCallback(async () => { + setCleaningTemp(true); + try { + const result = await openclawApi.cleanupTempArtifacts(); + if (result.success) { + toast.success(result.message); + } else { + toast.warning(result.message); + } + await refreshAll(); + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)); + } finally { + setCleaningTemp(false); + } + }, [refreshAll]); + const handleCopyPath = useCallback(async () => { const path = installedStatus?.path; if (!path) { @@ -1051,10 +1145,16 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { return; } + setHandingOffToAgent(true); + toast.info("正在创建新话题并转交给 AI...", { + id: "openclaw-agent-handoff", + }); + const project = await getOrCreateDefaultProject().catch((error) => { toast.error( error instanceof Error ? error.message : "创建默认项目失败。", ); + setHandingOffToAgent(false); return null; }); @@ -1065,10 +1165,13 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { onNavigate?.("agent", { projectId: project.id, initialUserPrompt: prompt, + initialSessionName: "OpenClaw 修复", + entryBannerMessage: "已从 OpenClaw 故障诊断进入,诊断请求已自动发送。", newChatAt: Date.now(), theme: "general", lockTheme: false, }); + setHandingOffToAgent(false); }, [onNavigate, openClawRepairPrompt]); if (!statusResolved && !operationState.running) { @@ -1090,11 +1193,29 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { if (currentSubpage === "install") { return ( void handleInstall()} + onInstallNode={() => void handleInstallNode()} + onInstallGit={() => void handleInstallGit()} + onRefresh={() => void refreshAll()} + onCleanupTemp={() => void handleCleanupTempArtifacts()} onOpenDocs={() => void openUrl(OPENCLAW_DOCS_URL)} onDownloadNode={() => void openclawApi @@ -1120,13 +1241,17 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { return ( void handleInstall()} + onInstallNode={() => void handleInstallNode()} + onInstallGit={() => void handleInstallGit()} + onRefresh={() => void refreshAll()} + onCleanupTemp={() => void handleCleanupTempArtifacts()} onOpenDocs={() => void openUrl(OPENCLAW_DOCS_URL)} onDownloadNode={() => void openclawApi diff --git a/src/components/openclaw/OpenClawProgressPage.tsx b/src/components/openclaw/OpenClawProgressPage.tsx index 78b701f3e..5887effa6 100644 --- a/src/components/openclaw/OpenClawProgressPage.tsx +++ b/src/components/openclaw/OpenClawProgressPage.tsx @@ -5,7 +5,10 @@ import { OpenClawMark } from "./OpenClawMark"; interface OpenClawProgressPageProps { kind: OpenClawOperationKind; + title?: string | null; + description?: string | null; running: boolean; + handingOffToAgent?: boolean; message: string | null; logs: OpenClawInstallProgressEvent[]; repairPrompt: string; @@ -17,20 +20,25 @@ interface OpenClawProgressPageProps { } const titleMap: Record = { - install: "正在安装 OpenClaw", + install: "正在修复环境并安装 OpenClaw", uninstall: "正在卸载 OpenClaw", restart: "正在重启 Gateway", + repair: "正在修复 OpenClaw 环境", }; const descriptionMap: Record = { - install: "正在安装 OpenClaw,请保持当前页面并等待安装完成。", + install: "正在准备 Node.js、Git 和 OpenClaw 环境,请保持当前页面并等待完成。", uninstall: "正在卸载 OpenClaw,请等待进度完成后返回安装页。", restart: "正在重启 Gateway,完成后将返回运行页。", + repair: "正在修复 OpenClaw 依赖环境,请保持当前页面并等待完成。", }; export function OpenClawProgressPage({ kind, + title, + description, running, + handingOffToAgent = false, message, logs, repairPrompt, @@ -46,10 +54,10 @@ export function OpenClawProgressPage({

- {titleMap[kind]} + {title || titleMap[kind]}

- {descriptionMap[kind]} + {description || descriptionMap[kind]}

@@ -92,11 +100,15 @@ export function OpenClawProgressPage({ + + +
+
总任务
+
{summary.total}
+
+
+
进行中
+
{summary.running}
+
+
+
已完成
+
{summary.success}
+
+
+
失败
+
{summary.error}
+
+
+ + + {loading ? ( + + + 正在加载视频任务... + + + ) : tasks.length === 0 ? ( + + +
+ 暂无视频任务 +
+
+ 请先回到「创作」视图提交视频生成,结果会自动沉淀到这里。 +
+
+
+ ) : ( + tasks.map((task) => { + const meta = STATUS_META[task.status]; + const canCancel = + task.status === "pending" || task.status === "processing"; + + return ( + + +
+
+ + {meta.label} + + + {task.providerId} · {task.model} + +
+ + {task.prompt || "未提供提示词"} + +
+
+ {task.resultUrl ? ( + + + + ) : null} + {canCancel ? ( + + ) : null} +
+
+ +
+
创建时间
+
+ {formatTime(task.createdAt)} +
+
+
+
更新时间
+
+ {formatTime(task.updatedAt)} +
+
+
+
完成时间
+
+ {formatTime(task.finishedAt)} +
+
+
+
任务 ID
+
+ {task.id} +
+
+ {task.errorMessage ? ( +
+
失败原因
+
+ {task.errorMessage} +
+
+ ) : null} +
+
+ ); + }) + )} +
+
+ ); +} diff --git a/src/lib/api/openclaw.ts b/src/lib/api/openclaw.ts index 3507e8c41..df0d3aead 100644 --- a/src/lib/api/openclaw.ts +++ b/src/lib/api/openclaw.ts @@ -22,6 +22,23 @@ export interface OpenClawNodeCheckResult { path?: string | null; } +export interface OpenClawDependencyStatus { + status: "ok" | "missing" | "version_low" | string; + version?: string | null; + path?: string | null; + message: string; + autoInstallSupported: boolean; +} + +export interface OpenClawEnvironmentStatus { + node: OpenClawDependencyStatus; + git: OpenClawDependencyStatus; + openclaw: OpenClawDependencyStatus; + recommendedAction: string; + summary: string; + tempArtifacts: string[]; +} + export interface OpenClawActionResult { success: boolean; message: string; @@ -74,6 +91,10 @@ export async function openclawCheckInstalled(): Promise { + return safeInvoke("openclaw_get_environment_status"); +} + export async function openclawCheckNodeVersion(): Promise { return safeInvoke("openclaw_check_node_version"); } @@ -94,6 +115,12 @@ export async function openclawInstall(): Promise { return safeInvoke("openclaw_install"); } +export async function openclawInstallDependency( + kind: "node" | "git", +): Promise { + return safeInvoke("openclaw_install_dependency", { kind }); +} + export async function openclawGetCommandPreview( operation: "install" | "uninstall" | "start" | "stop" | "restart", port?: number, @@ -105,6 +132,10 @@ export async function openclawUninstall(): Promise { return safeInvoke("openclaw_uninstall"); } +export async function openclawCleanupTempArtifacts(): Promise { + return safeInvoke("openclaw_cleanup_temp_artifacts"); +} + export async function openclawStartGateway( port?: number, ): Promise { @@ -158,13 +189,16 @@ export async function openclawGetProgressLogs(): Promise< export const openclawApi = { checkInstalled: openclawCheckInstalled, + getEnvironmentStatus: openclawGetEnvironmentStatus, checkNodeVersion: openclawCheckNodeVersion, checkGitAvailable: openclawCheckGitAvailable, getNodeDownloadUrl: openclawGetNodeDownloadUrl, getGitDownloadUrl: openclawGetGitDownloadUrl, install: openclawInstall, + installDependency: openclawInstallDependency, getCommandPreview: openclawGetCommandPreview, uninstall: openclawUninstall, + cleanupTempArtifacts: openclawCleanupTempArtifacts, startGateway: openclawStartGateway, stopGateway: openclawStopGateway, restartGateway: openclawRestartGateway, diff --git a/src/types/page.ts b/src/types/page.ts index 3236d9bcb..3e8b2f178 100644 --- a/src/types/page.ts +++ b/src/types/page.ts @@ -137,6 +137,10 @@ export interface AgentPageParams { contentId?: string; /** 进入 Agent 时自动发送的首条用户消息 */ initialUserPrompt?: string; + /** 进入 Agent 时优先创建的话题名称 */ + initialSessionName?: string; + /** 一次性入口提示文案 */ + entryBannerMessage?: string; /** 首屏主题(用于左侧导航直达创作主题) */ theme?: string; /** 是否锁定主题(锁定后不在首屏显示主题切换) */