From be3f4986d79dfd3d5c0c42c852ee74fffaee23b2 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 28 Mar 2026 20:14:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81Cline=20Agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/acp/connection.rs | 29 +- src-tauri/src/acp/registry.rs | 16 + src-tauri/src/acp/types.rs | 1 + src-tauri/src/commands/acp.rs | 313 +++++++++ src-tauri/src/commands/conversations.rs | 13 +- .../src/db/service/agent_setting_service.rs | 1 + src-tauri/src/db/service/import_service.rs | 2 + src-tauri/src/models/agent.rs | 2 + src-tauri/src/parsers/cline.rs | 634 ++++++++++++++++++ src-tauri/src/parsers/mod.rs | 1 + src/components/agent-icon.tsx | 2 + .../conversation-detail-panel.tsx | 5 +- .../message/content-parts-renderer.tsx | 49 ++ .../settings/acp-agent-settings.tsx | 269 ++++++++ src/i18n/messages/ar.json | 10 +- src/i18n/messages/de.json | 10 +- src/i18n/messages/en.json | 10 +- src/i18n/messages/es.json | 10 +- src/i18n/messages/fr.json | 10 +- src/i18n/messages/ja.json | 10 +- src/i18n/messages/ko.json | 10 +- src/i18n/messages/pt.json | 10 +- src/i18n/messages/zh-CN.json | 10 +- src/i18n/messages/zh-TW.json | 10 +- src/lib/tool-call-normalization.ts | 11 + src/lib/types.ts | 5 + 26 files changed, 1418 insertions(+), 35 deletions(-) create mode 100644 src-tauri/src/parsers/cline.rs diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 7155ac1..1348c1e 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -739,20 +739,29 @@ async fn run_connection( .await } Err(e) => { - // session/load failed (e.g. ephemeral forked session). + // session/load failed (e.g. agent doesn't support resume, + // or ephemeral forked session). // Fall back to session/new so the tab still works. + let err_str = e.to_string(); eprintln!( "[ACP] session/load failed ({}), falling back to session/new", - e - ); - crate::web::event_bridge::emit_event( - &handle, - "acp://event", - AcpEvent::Error { - connection_id: conn_id.clone(), - message: format!("Failed to load session, starting new: {e}"), - }, + err_str ); + // Only emit a visible error for unexpected failures; + // "Method not found" is expected for agents that don't + // support session resume (e.g. Cline). + if !err_str.contains("Method not found") { + crate::web::event_bridge::emit_event( + &handle, + "acp://event", + AcpEvent::Error { + connection_id: conn_id.clone(), + message: format!( + "Failed to load session, starting new: {e}" + ), + }, + ); + } let new_resp = cx .send_request_to(Agent, NewSessionRequest::new(cwd.clone())) .block_task() diff --git a/src-tauri/src/acp/registry.rs b/src-tauri/src/acp/registry.rs index 91443d5..a87310a 100644 --- a/src-tauri/src/acp/registry.rs +++ b/src-tauri/src/acp/registry.rs @@ -78,6 +78,7 @@ pub fn all_acp_agents() -> Vec { AgentType::Gemini, AgentType::OpenClaw, AgentType::OpenCode, + AgentType::Cline, ] } @@ -88,6 +89,7 @@ pub fn registry_id_for(agent_type: AgentType) -> &'static str { AgentType::Gemini => "gemini", AgentType::OpenClaw => "openclaw-acp", AgentType::OpenCode => "opencode", + AgentType::Cline => "cline", } } @@ -98,6 +100,7 @@ pub fn from_registry_id(id: &str) -> Option { "gemini" => Some(AgentType::Gemini), "openclaw-acp" => Some(AgentType::OpenClaw), "opencode" => Some(AgentType::OpenCode), + "cline" => Some(AgentType::Cline), _ => None, } } @@ -184,6 +187,19 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { node_required: Some("22.12.0"), }, }, + AgentType::Cline => AcpAgentMeta { + agent_type, + name: "Cline", + description: "Autonomous coding agent CLI", + distribution: AgentDistribution::Npx { + version: "2.11.0", + package: "cline@2.11.0", + cmd: "cline", + args: &["--acp"], + env: &[], + node_required: None, + }, + }, AgentType::OpenCode => AcpAgentMeta { agent_type, name: "OpenCode", diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index d75895c..c7b674d 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -242,6 +242,7 @@ pub struct AcpAgentInfo { pub opencode_auth_json: Option, pub codex_auth_json: Option, pub codex_config_toml: Option, + pub cline_secrets_json: Option, } /// Lightweight status info for a single agent, used by connect() pre-check. diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 72d71a4..a8acf34 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -298,6 +298,297 @@ fn load_opencode_auth_json_raw() -> Option { fs::read_to_string(opencode_auth_json_path()).ok() } +// --------------------------------------------------------------------------- +// Cline config helpers +// --------------------------------------------------------------------------- + +fn cline_data_dir() -> PathBuf { + if let Ok(custom) = std::env::var("CLINE_DIR") { + let trimmed = custom.trim(); + if !trimmed.is_empty() { + return PathBuf::from(trimmed); + } + } + home_dir_or_default().join(".cline").join("data") +} + +fn cline_global_state_path() -> PathBuf { + cline_data_dir().join("globalState.json") +} + +fn cline_secrets_path() -> PathBuf { + cline_data_dir().join("secrets.json") +} + +fn load_cline_secrets_json_raw() -> Option { + fs::read_to_string(cline_secrets_path()).ok() +} + +/// Cline provider → secrets.json field name for the API key. +fn cline_api_key_field_for_provider(provider: &str) -> &'static str { + match provider { + "anthropic" => "apiKey", + "openrouter" => "openRouterApiKey", + "openai-native" => "openAiNativeApiKey", + "openai" => "openAiApiKey", + "gemini" => "geminiApiKey", + "deepseek" => "deepSeekApiKey", + "mistral" => "mistralApiKey", + "xai" => "xaiApiKey", + _ => "openAiApiKey", + } +} + +/// Cline provider → globalState model ID key suffix. +/// Providers in ProviderKeyMap use `actMode{Suffix}` / `planMode{Suffix}`, +/// others use `actModeApiModelId` / `planModeApiModelId`. +fn cline_model_id_keys_for_provider(provider: &str) -> (&'static str, &'static str) { + match provider { + "openrouter" | "cline" => ("actModeOpenRouterModelId", "planModeOpenRouterModelId"), + "openai" => ("actModeOpenAiModelId", "planModeOpenAiModelId"), + "ollama" => ("actModeOllamaModelId", "planModeOllamaModelId"), + "lmstudio" => ("actModeLmStudioModelId", "planModeLmStudioModelId"), + "litellm" => ("actModeLiteLlmModelId", "planModeLiteLlmModelId"), + "requesty" => ("actModeRequestyModelId", "planModeRequestyModelId"), + "groq" => ("actModeGroqModelId", "planModeGroqModelId"), + _ => ("actModeApiModelId", "planModeApiModelId"), + } +} + +/// Read globalState.json + secrets.json and merge into a unified config JSON +/// with keys: apiProvider, model, apiKey, apiBaseUrl. +fn load_cline_local_config_json() -> Option { + let mut merged = serde_json::Map::new(); + + if let Ok(raw) = fs::read_to_string(cline_global_state_path()) { + if let Ok(state) = serde_json::from_str::(&raw) { + // Cline uses actModeApiProvider / planModeApiProvider (prefer actMode) + let provider = state + .get("actModeApiProvider") + .or_else(|| state.get("planModeApiProvider")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .unwrap_or("anthropic") + .to_string(); + + merged.insert( + "apiProvider".to_string(), + serde_json::Value::String(provider.clone()), + ); + + // Read model from provider-specific key + let (act_key, _plan_key) = cline_model_id_keys_for_provider(&provider); + if let Some(model_id) = state + .get(act_key) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + { + merged.insert( + "model".to_string(), + serde_json::Value::String(model_id.to_string()), + ); + } + + // Read provider-specific baseUrl key + let base_url_key = match provider.as_str() { + "anthropic" => "anthropicBaseUrl", + "gemini" => "geminiBaseUrl", + "ollama" => "ollamaBaseUrl", + "lmstudio" => "lmStudioBaseUrl", + "litellm" => "liteLlmBaseUrl", + "requesty" => "requestyBaseUrl", + _ => "openAiBaseUrl", + }; + if let Some(base_url) = state + .get(base_url_key) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + { + merged.insert( + "apiBaseUrl".to_string(), + serde_json::Value::String(base_url.to_string()), + ); + } + } + } + + // Read API key from secrets.json based on provider + if let Ok(raw) = fs::read_to_string(cline_secrets_path()) { + if let Ok(secrets) = serde_json::from_str::(&raw) { + let provider = merged + .get("apiProvider") + .and_then(|v| v.as_str()) + .unwrap_or("anthropic"); + let key_field = cline_api_key_field_for_provider(provider); + if let Some(api_key) = secrets + .get(key_field) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + { + merged.insert( + "apiKey".to_string(), + serde_json::Value::String(api_key.to_string()), + ); + } + } + } + + if merged.is_empty() { + return None; + } + serde_json::to_string_pretty(&serde_json::Value::Object(merged)).ok() +} + +/// Split merged config back into globalState.json + secrets.json. +/// Writes `actModeApiProvider`, `planModeApiProvider`, provider-specific model keys, +/// `openAiBaseUrl`, and `welcomeViewCompleted` to globalState.json, +/// and the provider-specific API key to secrets.json. +fn persist_cline_local_config(config_patch_json: Option<&str>) -> Result<(), AcpError> { + let Some(raw_patch) = config_patch_json else { + return Ok(()); + }; + let runtime = serde_json::from_str::(raw_patch) + .map_err(|e| AcpError::protocol(format!("invalid config_json: {e}")))?; + let patch = serde_json::from_str::(raw_patch) + .map_err(|e| AcpError::protocol(format!("invalid config_json: {e}")))?; + + let provider = patch + .get("apiProvider") + .and_then(|v| v.as_str()) + .unwrap_or("anthropic") + .to_string(); + + // --- Update globalState.json (merge) --- + let gs_path = cline_global_state_path(); + let mut gs = if gs_path.exists() { + match fs::read_to_string(&gs_path) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + { + Some(existing) if existing.is_object() => existing, + _ => serde_json::json!({}), + } + } else { + serde_json::json!({}) + }; + let gs_obj = gs + .as_object_mut() + .ok_or_else(|| AcpError::protocol("globalState root must be object"))?; + + // Cline checks welcomeViewCompleted first in isAuthConfigured() + gs_obj.insert( + "welcomeViewCompleted".to_string(), + serde_json::Value::Bool(true), + ); + + // Set both act/plan mode providers + gs_obj.insert( + "actModeApiProvider".to_string(), + serde_json::Value::String(provider.clone()), + ); + gs_obj.insert( + "planModeApiProvider".to_string(), + serde_json::Value::String(provider.clone()), + ); + + // Set provider-specific model ID keys + let (act_model_key, plan_model_key) = cline_model_id_keys_for_provider(&provider); + match trim_non_empty(runtime.model) { + Some(model) => { + gs_obj.insert( + act_model_key.to_string(), + serde_json::Value::String(model.clone()), + ); + gs_obj.insert( + plan_model_key.to_string(), + serde_json::Value::String(model), + ); + } + None => { + gs_obj.remove(act_model_key); + gs_obj.remove(plan_model_key); + } + } + + // Each provider uses its own baseUrl key in globalState + let base_url_key = match provider.as_str() { + "anthropic" => "anthropicBaseUrl", + "gemini" => "geminiBaseUrl", + "ollama" => "ollamaBaseUrl", + "lmstudio" => "lmStudioBaseUrl", + "litellm" => "liteLlmBaseUrl", + "requesty" => "requestyBaseUrl", + _ => "openAiBaseUrl", + }; + match trim_non_empty(runtime.api_base_url) { + Some(base_url) => { + gs_obj.insert( + base_url_key.to_string(), + serde_json::Value::String(base_url), + ); + } + None => { + gs_obj.remove(base_url_key); + } + } + + if let Some(parent) = gs_path.parent() { + fs::create_dir_all(parent).map_err(|e| { + AcpError::protocol(format!("create cline data directory failed: {e}")) + })?; + } + let serialized_gs = serde_json::to_string_pretty(&gs) + .map_err(|e| AcpError::protocol(format!("serialize cline globalState failed: {e}")))?; + fs::write(&gs_path, format!("{serialized_gs}\n")) + .map_err(|e| AcpError::protocol(format!("write cline globalState failed: {e}")))?; + + // --- Update secrets.json --- + let secrets_path = cline_secrets_path(); + let mut secrets = if secrets_path.exists() { + match fs::read_to_string(&secrets_path) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + { + Some(existing) if existing.is_object() => existing, + _ => serde_json::json!({}), + } + } else { + serde_json::json!({}) + }; + let secrets_obj = secrets + .as_object_mut() + .ok_or_else(|| AcpError::protocol("secrets root must be object"))?; + + let key_field = cline_api_key_field_for_provider(&provider); + match trim_non_empty(runtime.api_key) { + Some(api_key) => { + secrets_obj.insert( + key_field.to_string(), + serde_json::Value::String(api_key), + ); + } + None => { + secrets_obj.remove(key_field); + } + } + + if let Some(parent) = secrets_path.parent() { + fs::create_dir_all(parent).map_err(|e| { + AcpError::protocol(format!("create cline data directory failed: {e}")) + })?; + } + let serialized_secrets = serde_json::to_string_pretty(&secrets) + .map_err(|e| AcpError::protocol(format!("serialize cline secrets failed: {e}")))?; + fs::write(&secrets_path, format!("{serialized_secrets}\n")) + .map_err(|e| AcpError::protocol(format!("write cline secrets failed: {e}")))?; + + Ok(()) +} + fn load_codex_auth_json_raw() -> Option { fs::read_to_string(codex_auth_json_path()).ok() } @@ -620,6 +911,7 @@ fn agent_local_config_path(agent_type: AgentType) -> Option { AgentType::ClaudeCode => Some(home_dir_or_default().join(".claude").join("settings.json")), AgentType::Gemini => Some(home_dir_or_default().join(".gemini").join("settings.json")), AgentType::OpenCode => Some(resolve_opencode_config_path()), + AgentType::Cline => Some(cline_global_state_path()), _ => None, } } @@ -628,6 +920,9 @@ pub(crate) fn load_agent_local_config_json(agent_type: AgentType) -> Option Option { global_dirs: vec![home_dir_or_default().join(".openclaw").join("skills")], project_rel_dirs: vec!["skills"], }), + AgentType::Cline => None, } } @@ -1325,6 +1624,11 @@ pub async fn acp_list_agents( } else { None }; + let cline_secrets_json = if agent_type == AgentType::Cline { + load_cline_secrets_json_raw() + } else { + None + }; agents.push(AcpAgentInfo { agent_type, @@ -1344,6 +1648,7 @@ pub async fn acp_list_agents( opencode_auth_json, codex_auth_json, codex_config_toml, + cline_secrets_json, }); } @@ -1447,6 +1752,14 @@ pub(crate) async fn acp_update_agent_preferences_core( return Ok(()); } + if agent_type == AgentType::Cline { + if let Some(raw) = config_json.as_deref() { + persist_cline_local_config(Some(raw))?; + } + emit_acp_agents_updated(app, "preferences_updated", Some(agent_type)); + return Ok(()); + } + let mut local_patch_value = config_json .as_deref() .and_then(|raw| serde_json::from_str::(raw).ok()) diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 4a72a27..aefde52 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -6,6 +6,7 @@ use crate::db::service::{conversation_service, folder_service, import_service}; use crate::db::AppDatabase; use crate::models::*; use crate::parsers::claude::ClaudeParser; +use crate::parsers::cline::ClineParser; use crate::parsers::codex::CodexParser; use crate::parsers::gemini::GeminiParser; use crate::parsers::openclaw::OpenClawParser; @@ -42,6 +43,7 @@ fn list_conversations_sync( (AgentType::OpenCode, Box::new(OpenCodeParser::new())), (AgentType::Gemini, Box::new(GeminiParser::new())), (AgentType::OpenClaw, Box::new(OpenClawParser::new())), + (AgentType::Cline, Box::new(ClineParser::new())), ]; for (at, parser) in &parsers { @@ -142,6 +144,7 @@ pub async fn get_conversation( AgentType::OpenCode => Box::new(OpenCodeParser::new()), AgentType::Gemini => Box::new(GeminiParser::new()), AgentType::OpenClaw => Box::new(OpenClawParser::new()), + AgentType::Cline => Box::new(ClineParser::new()), }; parser @@ -275,14 +278,16 @@ pub async fn get_folder_conversation_core( AgentType::OpenCode => Box::new(OpenCodeParser::new()), AgentType::Gemini => Box::new(GeminiParser::new()), AgentType::OpenClaw => Box::new(OpenClawParser::new()), + AgentType::Cline => Box::new(ClineParser::new()), }; match parser.get_conversation(&eid) { Ok(d) => Ok((d.turns, d.session_stats, None)), Err(crate::parsers::ParseError::ConversationNotFound(_)) => { - // For OpenClaw, the external_id may be an ACP session UUID that - // doesn't correspond to any JSONL file. Fall back to matching - // by title and folder_path from the parsed conversation list. - if at == AgentType::OpenClaw { + // For agents like OpenClaw and Cline, the external_id is an + // ACP session UUID that doesn't correspond to any local file. + // Fall back to matching by folder_path and started_at from + // the parsed conversation list. + if at == AgentType::OpenClaw || at == AgentType::Cline { if let Ok(all) = parser.list_conversations() { // Filter by folder_path first, then find the closest // started_at match within 300 seconds of db_created_at. diff --git a/src-tauri/src/db/service/agent_setting_service.rs b/src-tauri/src/db/service/agent_setting_service.rs index 3f4dd14..59235d6 100644 --- a/src-tauri/src/db/service/agent_setting_service.rs +++ b/src-tauri/src/db/service/agent_setting_service.rs @@ -32,6 +32,7 @@ fn default_enabled(agent_type: AgentType) -> bool { | AgentType::Gemini | AgentType::OpenCode | AgentType::OpenClaw + | AgentType::Cline ) } diff --git a/src-tauri/src/db/service/import_service.rs b/src-tauri/src/db/service/import_service.rs index 409121b..c9cbc49 100644 --- a/src-tauri/src/db/service/import_service.rs +++ b/src-tauri/src/db/service/import_service.rs @@ -7,6 +7,7 @@ use crate::db::entities::conversation; use crate::db::error::DbError; use crate::models::{AgentType, ImportResult}; use crate::parsers::claude::ClaudeParser; +use crate::parsers::cline::ClineParser; use crate::parsers::codex::CodexParser; use crate::parsers::gemini::GeminiParser; use crate::parsers::openclaw::OpenClawParser; @@ -28,6 +29,7 @@ pub async fn import_local_conversations( (AgentType::OpenCode, Box::new(OpenCodeParser::new())), (AgentType::Gemini, Box::new(GeminiParser::new())), (AgentType::OpenClaw, Box::new(OpenClawParser::new())), + (AgentType::Cline, Box::new(ClineParser::new())), ]; let mut matched = Vec::new(); diff --git a/src-tauri/src/models/agent.rs b/src-tauri/src/models/agent.rs index 449f64a..abe4713 100644 --- a/src-tauri/src/models/agent.rs +++ b/src-tauri/src/models/agent.rs @@ -9,6 +9,7 @@ pub enum AgentType { OpenCode, Gemini, OpenClaw, + Cline, } impl fmt::Display for AgentType { @@ -19,6 +20,7 @@ impl fmt::Display for AgentType { AgentType::OpenCode => write!(f, "OpenCode"), AgentType::Gemini => write!(f, "Gemini CLI"), AgentType::OpenClaw => write!(f, "OpenClaw"), + AgentType::Cline => write!(f, "Cline"), } } } diff --git a/src-tauri/src/parsers/cline.rs b/src-tauri/src/parsers/cline.rs new file mode 100644 index 0000000..e78567e --- /dev/null +++ b/src-tauri/src/parsers/cline.rs @@ -0,0 +1,634 @@ +use std::fs; +use std::path::PathBuf; + +use chrono::{DateTime, TimeZone, Utc}; +use serde::Deserialize; + +use crate::models::{ + AgentType, ContentBlock, ConversationDetail, ConversationSummary, MessageTurn, TurnRole, + TurnUsage, +}; + +use super::{compute_session_stats, folder_name_from_path, truncate_str, AgentParser, ParseError}; + +// --------------------------------------------------------------------------- +// On-disk JSON structures +// --------------------------------------------------------------------------- + +/// One entry in `~/.cline/data/state/taskHistory.json`. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TaskHistoryEntry { + id: String, + ts: i64, + task: Option, + #[allow(dead_code)] + tokens_in: Option, + #[allow(dead_code)] + tokens_out: Option, + #[allow(dead_code)] + total_cost: Option, + cwd_on_task_initialization: Option, + #[serde(default)] + model_id: Option, +} + +/// `task_metadata.json` – we only need `model_usage`. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TaskMetadata { + #[serde(default)] + model_usage: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ModelUsageEntry { + model_id: Option, + #[allow(dead_code)] + model_provider_id: Option, +} + +/// One message in `api_conversation_history.json`. +#[derive(Debug, Deserialize)] +struct ApiMessage { + role: String, + #[serde(default)] + content: serde_json::Value, + ts: Option, + #[serde(default, rename = "modelInfo")] + model_info: Option, + #[serde(default)] + metrics: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ApiModelInfo { + model_id: Option, +} + +#[derive(Debug, Deserialize)] +struct ApiMetrics { + tokens: Option, +} + +#[derive(Debug, Deserialize)] +struct ApiTokenMetrics { + #[serde(default)] + prompt: Option, + #[serde(default)] + completion: Option, + #[serde(default)] + cached: Option, +} + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +fn cline_data_dir() -> PathBuf { + if let Ok(custom) = std::env::var("CLINE_DIR") { + let trimmed = custom.trim(); + if !trimmed.is_empty() { + return PathBuf::from(trimmed); + } + } + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".cline") + .join("data") +} + +fn ts_to_datetime(ts: i64) -> DateTime { + Utc.timestamp_millis_opt(ts).single().unwrap_or_default() +} + +pub struct ClineParser; + +impl ClineParser { + pub fn new() -> Self { + Self + } +} + +impl AgentParser for ClineParser { + fn list_conversations(&self) -> Result, ParseError> { + let history_path = cline_data_dir().join("state").join("taskHistory.json"); + if !history_path.exists() { + return Ok(vec![]); + } + + let raw = fs::read_to_string(&history_path)?; + let entries: Vec = serde_json::from_str(&raw)?; + + let mut summaries = Vec::new(); + for entry in entries { + let tasks_dir = cline_data_dir().join("tasks").join(&entry.id); + if !tasks_dir.exists() { + continue; + } + + // Read model from task_metadata.json or taskHistory entry + let model = entry.model_id.clone().or_else(|| { + let meta_path = tasks_dir.join("task_metadata.json"); + fs::read_to_string(meta_path) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .and_then(|meta| { + meta.model_usage + .first() + .and_then(|u| u.model_id.clone()) + }) + }); + + let folder_path = entry.cwd_on_task_initialization.clone(); + let folder_name = folder_path.as_deref().map(folder_name_from_path); + + let title = entry + .task + .as_deref() + .map(|t| truncate_str(t.trim(), 100)); + + // Count messages from api_conversation_history.json + let api_path = tasks_dir.join("api_conversation_history.json"); + let message_count = fs::read_to_string(&api_path) + .ok() + .and_then(|raw| serde_json::from_str::>(&raw).ok()) + .map(|msgs| msgs.len() as u32) + .unwrap_or(0); + + // started_at from task id (which is a timestamp), ended_at from ts + let started_at = ts_to_datetime(entry.id.parse::().unwrap_or(entry.ts)); + let ended_at = if entry.ts > 0 { + Some(ts_to_datetime(entry.ts)) + } else { + None + }; + + summaries.push(ConversationSummary { + id: entry.id, + agent_type: AgentType::Cline, + folder_path, + folder_name: folder_name.map(String::from), + title, + started_at, + ended_at, + message_count, + model, + git_branch: None, + }); + } + + Ok(summaries) + } + + fn get_conversation(&self, conversation_id: &str) -> Result { + let tasks_dir = cline_data_dir().join("tasks").join(conversation_id); + if !tasks_dir.exists() { + return Err(ParseError::ConversationNotFound( + conversation_id.to_string(), + )); + } + + let api_path = tasks_dir.join("api_conversation_history.json"); + if !api_path.exists() { + return Err(ParseError::ConversationNotFound( + conversation_id.to_string(), + )); + } + + let raw = fs::read_to_string(&api_path)?; + let messages: Vec = serde_json::from_str(&raw)?; + + // Read metadata for model/cwd + let meta_path = tasks_dir.join("task_metadata.json"); + let metadata = fs::read_to_string(&meta_path) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()); + + let default_model = metadata + .as_ref() + .and_then(|m| m.model_usage.first()) + .and_then(|u| u.model_id.clone()); + + // Read taskHistory for cwd and title + let history_path = cline_data_dir().join("state").join("taskHistory.json"); + let history_entry = fs::read_to_string(&history_path) + .ok() + .and_then(|raw| serde_json::from_str::>(&raw).ok()) + .and_then(|entries| entries.into_iter().find(|e| e.id == conversation_id)); + + let folder_path = history_entry + .as_ref() + .and_then(|e| e.cwd_on_task_initialization.clone()); + let folder_name = folder_path.as_deref().map(folder_name_from_path); + let title = history_entry + .as_ref() + .and_then(|e| e.task.as_deref()) + .map(|t| truncate_str(t.trim(), 100)); + + let mut turns: Vec = Vec::new(); + let mut turn_counter = 0u32; + + for msg in &messages { + let ts = msg.ts.unwrap_or(0); + let timestamp = if ts > 0 { + ts_to_datetime(ts) + } else { + Utc::now() + }; + + let model = msg + .model_info + .as_ref() + .and_then(|info| info.model_id.clone()) + .or_else(|| default_model.clone()); + + let usage = msg.metrics.as_ref().and_then(|m| { + m.tokens.as_ref().map(|t| TurnUsage { + input_tokens: t.prompt.unwrap_or(0), + output_tokens: t.completion.unwrap_or(0), + cache_creation_input_tokens: 0, + cache_read_input_tokens: t.cached.unwrap_or(0), + }) + }); + + match msg.role.as_str() { + "assistant" => { + let blocks = parse_content_blocks(&msg.content); + if blocks.is_empty() { + continue; + } + turn_counter += 1; + turns.push(MessageTurn { + id: format!("{}-{}", conversation_id, turn_counter), + role: TurnRole::Assistant, + blocks, + timestamp, + usage, + duration_ms: None, + model, + }); + } + "user" => { + // Cline packs tool results, user feedback, and automated + // messages into role:"user". Split them into proper turns. + let parsed = parse_user_message_parts(&msg.content); + + // Emit tool-result blocks as a system turn so they attach + // to the preceding assistant tool_use. + if !parsed.tool_results.is_empty() { + turn_counter += 1; + turns.push(MessageTurn { + id: format!("{}-{}", conversation_id, turn_counter), + role: TurnRole::System, + blocks: parsed.tool_results, + timestamp, + usage: None, + duration_ms: None, + model: None, + }); + } + + // Emit real user text (feedback / initial task) as a user turn. + if !parsed.user_blocks.is_empty() { + turn_counter += 1; + turns.push(MessageTurn { + id: format!("{}-{}", conversation_id, turn_counter), + role: TurnRole::User, + blocks: parsed.user_blocks, + timestamp, + usage: None, + duration_ms: None, + model: None, + }); + } + } + _ => continue, + } + } + + let started_at = turns + .first() + .map(|t| t.timestamp) + .unwrap_or_else(Utc::now); + let ended_at = turns.last().map(|t| t.timestamp); + + let session_stats = compute_session_stats(&turns); + + let summary = ConversationSummary { + id: conversation_id.to_string(), + agent_type: AgentType::Cline, + folder_path, + folder_name: folder_name.map(String::from), + title, + started_at, + ended_at, + message_count: turns.len() as u32, + model: default_model, + git_branch: None, + }; + + Ok(ConversationDetail { + summary, + turns, + session_stats, + }) + } +} + +// --------------------------------------------------------------------------- +// Content block parsing +// --------------------------------------------------------------------------- + +/// Result of splitting a Cline `role:"user"` message. +struct UserMessageParts { + /// Tool result blocks (e.g. `[read_file for ...] Result:`) + tool_results: Vec, + /// Real user content (initial task text or `` text) + user_blocks: Vec, +} + +/// Cline puts tool results, feedback, and automated prompts all into +/// `role:"user"` messages. This function splits them apart. +fn parse_user_message_parts(content: &serde_json::Value) -> UserMessageParts { + let texts = collect_text_parts(content); + let mut tool_results = Vec::new(); + let mut user_blocks = Vec::new(); + + for text in texts { + let cleaned = strip_environment_details(&text); + if cleaned.is_empty() { + continue; + } + + // Tool result pattern: `[tool_name ...] Result:` + if is_tool_result_text(&cleaned) { + let (tool_name, output, is_error) = parse_tool_result_text(&cleaned); + tool_results.push(ContentBlock::ToolResult { + tool_use_id: None, + output_preview: Some(truncate_str(&output, 2000)), + is_error, + }); + + // If the tool result also contains , extract it + if let Some(feedback) = extract_feedback(&text) { + let fb = feedback.trim(); + if !fb.is_empty() { + user_blocks.push(ContentBlock::Text { + text: fb.to_string(), + }); + } + } + // After extracting tool result, also check for non-feedback user + // text following the result (e.g. "The user has provided feedback...") + // — we intentionally skip these automated bridging messages. + let _ = tool_name; + continue; + } + + // Pure feedback without tool result prefix + if let Some(feedback) = extract_feedback(&cleaned) { + let fb = feedback.trim(); + if !fb.is_empty() { + user_blocks.push(ContentBlock::Text { + text: fb.to_string(), + }); + } + continue; + } + + // Regular user text (initial task, etc.) + user_blocks.push(ContentBlock::Text { text: cleaned }); + } + + UserMessageParts { + tool_results, + user_blocks, + } +} + +/// Collect all text strings from a content value (string or array of text blocks). +fn collect_text_parts(content: &serde_json::Value) -> Vec { + match content { + serde_json::Value::String(s) => vec![s.clone()], + serde_json::Value::Array(arr) => arr + .iter() + .filter_map(|item| { + let t = item.get("type").and_then(|v| v.as_str()).unwrap_or(""); + if t == "text" || t.is_empty() { + item.get("text") + .and_then(|v| v.as_str()) + .map(String::from) + .or_else(|| { + if t.is_empty() { + item.as_str().map(String::from) + } else { + None + } + }) + } else { + None + } + }) + .collect(), + _ => vec![], + } +} + +/// Check if text looks like a Cline tool result: `[tool_name ...] Result:` +fn is_tool_result_text(text: &str) -> bool { + let trimmed = text.trim_start(); + trimmed.starts_with('[') + && trimmed.contains("] Result:") +} + +/// Parse `[tool_name for 'arg'] Result:\ncontent` into (tool_name, output, is_error). +fn parse_tool_result_text(text: &str) -> (String, String, bool) { + let trimmed = text.trim_start(); + // Extract tool name from [tool_name ...] or [tool_name] prefix + let tool_name = trimmed + .strip_prefix('[') + .and_then(|s| { + s.find(|c: char| c == ']' || c == ' ') + .map(|i| s[..i].to_string()) + }) + .unwrap_or_default(); + + let is_error = trimmed.contains("[ERROR]") || trimmed.contains("Error:"); + + // Extract the content after "Result:\n" + let output = trimmed + .find("] Result:") + .map(|i| { + let after = &trimmed[i + "] Result:".len()..]; + after.trim().to_string() + }) + .unwrap_or_default(); + + // Strip automated bridging text that follows some results + let output = strip_automated_bridging(&output); + + (tool_name, output, is_error) +} + +/// Remove automated bridging messages that Cline appends after tool results. +fn strip_automated_bridging(text: &str) -> String { + let mut result = text.to_string(); + + // Remove "The user has provided feedback..." bridging + if let Some(pos) = result.find("The user has provided feedback") { + result = result[..pos].to_string(); + } + + // Remove "(This is an automated message...)" blocks + if let Some(pos) = result.find("(This is an automated message") { + result = result[..pos].to_string(); + } + + // Remove "# Next Steps" blocks + if let Some(pos) = result.find("# Next Steps") { + result = result[..pos].to_string(); + } + + result.trim().to_string() +} + +/// Extract text from `...` tags. +fn extract_feedback(text: &str) -> Option { + let start = text.find("")?; + let inner_start = start + "".len(); + let end = text.find("")?; + if end > inner_start { + Some(text[inner_start..end].to_string()) + } else { + None + } +} + +fn parse_content_blocks(content: &serde_json::Value) -> Vec { + match content { + serde_json::Value::String(text) => { + let cleaned = strip_environment_details(text); + if cleaned.is_empty() { + vec![] + } else { + vec![ContentBlock::Text { text: cleaned }] + } + } + serde_json::Value::Array(arr) => { + let mut blocks = Vec::new(); + for item in arr { + let block_type = item.get("type").and_then(|v| v.as_str()).unwrap_or(""); + match block_type { + "text" => { + if let Some(text) = item.get("text").and_then(|v| v.as_str()) { + let cleaned = strip_environment_details(text); + if !cleaned.is_empty() { + blocks.push(ContentBlock::Text { text: cleaned }); + } + } + } + "tool_use" => { + let tool_name = item + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let tool_use_id = + item.get("id").and_then(|v| v.as_str()).map(String::from); + let input_preview = item.get("input").map(|v| { + let s = v.to_string(); + truncate_str(&s, 2000) + }); + blocks.push(ContentBlock::ToolUse { + tool_use_id, + tool_name, + input_preview, + }); + } + "tool_result" => { + let tool_use_id = item + .get("tool_use_id") + .and_then(|v| v.as_str()) + .map(String::from); + let is_error = item + .get("is_error") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let output_preview = item + .get("content") + .and_then(|v| v.as_str()) + .map(|s| truncate_str(s, 500)); + blocks.push(ContentBlock::ToolResult { + tool_use_id, + output_preview, + is_error, + }); + } + "thinking" => { + if let Some(text) = item.get("thinking").and_then(|v| v.as_str()) { + if !text.is_empty() { + blocks.push(ContentBlock::Thinking { + text: text.to_string(), + }); + } + } + } + _ => {} + } + } + blocks + } + _ => vec![], + } +} + +/// Strip Cline's `...` blocks and +/// `...` wrappers from user messages to keep content clean. +fn strip_environment_details(text: &str) -> String { + let mut result = text.to_string(); + + // Remove ... + while let Some(start) = result.find("") { + if let Some(end) = result.find("") { + let end = end + "".len(); + result = format!("{}{}", &result[..start], &result[end..]); + } else { + // Unclosed tag — remove from start to end + result = result[..start].to_string(); + } + } + + // Remove ... wrappers, keeping inner content + while let Some(start) = result.find("") { + let tag_end = start + "".len(); + if let Some(close) = result.find("") { + let inner = result[tag_end..close].to_string(); + let after = &result[close + "".len()..]; + result = format!("{}{}{}", &result[..start], inner, after); + } else { + break; + } + } + + // Remove task_progress RECOMMENDED blocks + while let Some(start) = result.find("# task_progress RECOMMENDED") { + // Find the end: next section or end of string + let rest = &result[start..]; + let end = rest + .find("\n<") + .or_else(|| rest.find("\n#")) + .map(|i| start + i) + .unwrap_or(result.len()); + result = format!("{}{}", &result[..start], &result[end..]); + } + + // Remove [ERROR] automated retry messages + if result.contains("[ERROR] You did not use a tool") { + return String::new(); + } + + result.trim().to_string() +} diff --git a/src-tauri/src/parsers/mod.rs b/src-tauri/src/parsers/mod.rs index bb16962..2fb46a7 100644 --- a/src-tauri/src/parsers/mod.rs +++ b/src-tauri/src/parsers/mod.rs @@ -1,4 +1,5 @@ pub mod claude; +pub mod cline; pub mod codex; pub mod gemini; pub mod openclaw; diff --git a/src/components/agent-icon.tsx b/src/components/agent-icon.tsx index 8178de8..1149563 100644 --- a/src/components/agent-icon.tsx +++ b/src/components/agent-icon.tsx @@ -5,6 +5,7 @@ import { cn } from "@/lib/utils" import ClaudeColor from "@lobehub/icons/es/Claude/components/Color" import GeminiColor from "@lobehub/icons/es/Gemini/components/Color" import OpenClawColor from "@lobehub/icons/es/OpenClaw/components/Color" +import ClineMono from "@lobehub/icons/es/Cline/components/Mono" import { OpenAI, OpenCode } from "@lobehub/icons" interface AgentIconProps { @@ -24,6 +25,7 @@ const COLOR_ICONS: Partial> = { const MONO_ICONS: Partial> = { codex: OpenAI, open_code: OpenCode, + cline: ClineMono, } // Text-color versions for Mono icons diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index c482247..5ea1542 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -274,7 +274,10 @@ const ConversationTabView = memo(function ConversationTabView({ agentType: selectedAgent, isActive: isActive && canAutoConnect, workingDir: workingDirForConnection, - sessionId: dbConversationId != null ? externalId : undefined, + sessionId: + dbConversationId != null && selectedAgent !== "cline" + ? externalId + : undefined, }) const { status: connStatus, diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index 7961c18..fcaa8a3 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -878,6 +878,8 @@ function getToolIcon( name === "switch_mode" ) return + if (name === "attempt_completion") + return return undefined } @@ -909,6 +911,16 @@ function deriveToolTitle( return null } + // Cline: attempt_completion — show result summary as title + if (name === "attempt_completion") { + const result = getField("result") + if (result) { + const firstLine = result.split("\n")[0].trim() + return `${ellipsis(firstLine, 80)}` + } + return "Completion" + } + // File-based tools const filePath = getField("file_path") ?? @@ -2282,6 +2294,43 @@ const ToolCallPart = memo(function ToolCallPart({ toolNameLower === "enterplanmode" || toolNameLower === "exitplanmode") && !part.errorText + // Cline: attempt_completion — render as an expanded card with result + progress + if (toolNameLower === "attempt_completion") { + const parsedCompletion = tryParseJson(part.input ?? "") + const completionResult = + (parsedCompletion?.result as string | undefined)?.trim() ?? null + const taskProgress = + (parsedCompletion?.task_progress as string | undefined)?.trim() ?? null + return ( + + + + {completionResult && ( +
+ {completionResult} +
+ )} + {taskProgress && ( +
+
+ Progress +
+
+ {taskProgress} +
+
+ )} +
+
+ ) + } + const open = (isRunning && (isCommandTool || hasLiveOutput)) || manualOpen return ( diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index d446011..01e2eca 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -107,6 +107,10 @@ interface AgentDraft { openClawGatewayUrl: string openClawGatewayToken: string openClawSessionKey: string + clineProvider: ClineProvider + clineApiKey: string + clineModel: string + clineBaseUrl: string } type RunningActionKind = @@ -250,6 +254,20 @@ const OPENCLAW_ENV_KEYS = { sessionKey: "OPENCLAW_SESSION_KEY", } as const +const CLINE_PROVIDERS = [ + { value: "anthropic", label: "Anthropic" }, + { value: "openai-native", label: "OpenAI" }, + { value: "openai", label: "OpenAI Compatible" }, + { value: "openrouter", label: "OpenRouter" }, + { value: "gemini", label: "Gemini" }, + { value: "deepseek", label: "DeepSeek" }, + { value: "bedrock", label: "AWS Bedrock" }, + { value: "vertex", label: "GCP Vertex" }, + { value: "ollama", label: "Ollama" }, +] as const + +type ClineProvider = (typeof CLINE_PROVIDERS)[number]["value"] + type ClaudeModelKey = keyof typeof CLAUDE_MODEL_ENV_KEYS type ImportantConfigKey = "apiBaseUrl" | "apiKey" | "model" | ClaudeModelKey type ImportantDraftPatch = Partial> @@ -564,6 +582,26 @@ interface OpenClawImportantValues { sessionKey: string } +interface ClineImportantValues { + provider: ClineProvider + apiKey: string + model: string + baseUrl: string +} + +function extractClineImportantValues(configText: string): ClineImportantValues { + const parseResult = parseConfigJsonText(configText) + const config = parseResult.config + return { + provider: (typeof config.apiProvider === "string" && config.apiProvider + ? config.apiProvider + : "anthropic") as ClineProvider, + apiKey: typeof config.apiKey === "string" ? config.apiKey : "", + model: typeof config.model === "string" ? config.model : "", + baseUrl: typeof config.apiBaseUrl === "string" ? config.apiBaseUrl : "", + } +} + function extractOpenClawImportantValues( env: Record, configText: string @@ -1965,6 +2003,7 @@ function buildAgentDraft(agent: AcpAgentInfo): AgentDraft { configText, openCodeAuthJsonText ) + const clineImportant = extractClineImportantValues(configText) return { enabled: agent.enabled, envText: envMapToText(agent.env), @@ -2014,6 +2053,10 @@ function buildAgentDraft(agent: AcpAgentInfo): AgentDraft { openClawGatewayUrl: openClawImportant.gatewayUrl, openClawGatewayToken: openClawImportant.gatewayToken, openClawSessionKey: openClawImportant.sessionKey, + clineProvider: clineImportant.provider, + clineApiKey: clineImportant.apiKey, + clineModel: clineImportant.model, + clineBaseUrl: clineImportant.baseUrl, } } @@ -3059,6 +3102,19 @@ export function AcpAgentSettings() { return } + if (selectedAgent.agent_type === "cline") { + const cline = extractClineImportantValues(nextText) + updateSelectedDraft((current) => ({ + ...current, + configText: nextText, + clineProvider: cline.provider, + clineApiKey: cline.apiKey, + clineModel: cline.model, + clineBaseUrl: cline.baseUrl, + })) + return + } + const important = extractImportantConfigValues( selectedAgent.agent_type, parseEnvText(selectedDraft.envText), @@ -3321,6 +3377,37 @@ export function AcpAgentSettings() { [selectedAgent, selectedDraft, updateSelectedDraft] ) + const handleClineFieldChange = useCallback( + ( + key: "clineProvider" | "clineApiKey" | "clineModel" | "clineBaseUrl", + value: string + ) => { + if ( + !selectedAgent || + !selectedDraft || + selectedAgent.agent_type !== "cline" + ) + return + + updateSelectedDraft((current) => { + const next = { ...current, [key]: value } + // Rebuild config_json from Cline draft fields + const config: Record = {} + config.apiProvider = + key === "clineProvider" ? value : next.clineProvider + const apiKey = key === "clineApiKey" ? value : next.clineApiKey + if (apiKey.trim()) config.apiKey = apiKey.trim() + const model = key === "clineModel" ? value : next.clineModel + if (model.trim()) config.model = model.trim() + const baseUrl = key === "clineBaseUrl" ? value : next.clineBaseUrl + if (baseUrl.trim()) config.apiBaseUrl = baseUrl.trim() + next.configText = JSON.stringify(config, null, 2) + return next + }) + }, + [selectedAgent, selectedDraft, updateSelectedDraft] + ) + const handleOpenCodeConfigPatch = useCallback( (mutator: (config: Record) => void) => { if ( @@ -5694,6 +5781,188 @@ supports_websockets = true`} + ) : selectedAgent.agent_type === "cline" ? ( +
+
+ +

+ {t("cline.configDescription")} +

+
+ +
+ + +
+ +
+ +
+ { + handleClineFieldChange( + "clineApiKey", + event.target.value + ) + }} + placeholder="sk-..." + /> + +
+
+ +
+ + { + handleClineFieldChange( + "clineModel", + event.target.value + ) + }} + placeholder="claude-sonnet-4-5-20250514" + /> +
+ + {(selectedDraft.clineProvider === "anthropic" || + selectedDraft.clineProvider === "openai-native" || + selectedDraft.clineProvider === "openai" || + selectedDraft.clineProvider === "openrouter" || + selectedDraft.clineProvider === "ollama") && ( +
+ + { + handleClineFieldChange( + "clineBaseUrl", + event.target.value + ) + }} + placeholder="https://api.openai.com" + /> +
+ )} + +
+ +