diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/db.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/db.rs index f9b91a64c..3541c6894 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/db.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/db.rs @@ -18,7 +18,7 @@ fn default_project_dir() -> Result { Ok(dir) } -fn default_project_workdir() -> Result { +pub(crate) fn default_project_workdir() -> Result { Ok(default_project_dir()?.to_string_lossy().into_owned()) } diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_code_import.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_code_import.rs new file mode 100644 index 000000000..fe629a6ec --- /dev/null +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_code_import.rs @@ -0,0 +1,259 @@ +// Claude Code importer. Locates & parses `~/.claude/projects/**` depth-2 JSONL +// into [`ImportConversation`]. The shared struct, DB write and message builders +// live in `super::import`; this module just supplies the Claude Code-specific +// scan + parse plus two tauri commands wired to that core. + +use super::import::*; +use super::*; +use std::collections::HashMap; +use std::sync::Arc; + +fn claude_code_is_internal_user_message(row: &Value, content: &str) -> bool { + row.get("isMeta").and_then(Value::as_bool) == Some(true) + || content.starts_with("") + || content.starts_with("") + || content.starts_with("") + || content.starts_with("") + || content.starts_with("") +} + +fn claude_code_session_id(rows: &[(String, Value)], path: &std::path::Path) -> Option { + rows.iter() + .find_map(|(_, row)| { + import_string(row.get("sessionId")).or_else(|| import_string(row.get("session_id"))) + }) + .or_else(|| { + path.file_stem() + .and_then(|name| name.to_str()) + .map(str::to_string) + }) +} + +fn convert_claude_code_file( + path: &std::path::Path, +) -> Result<(Option, usize), String> { + let text = std::fs::read_to_string(path) + .map_err(|error| format!("读取 Claude Code 会话失败:{}: {error}", path.display()))?; + let (rows, skipped_lines) = parse_jsonl_lines(&text); + if rows.is_empty() { + return Ok((None, skipped_lines)); + } + let Some(session_id) = claude_code_session_id(&rows, path) else { + return Ok((None, skipped_lines)); + }; + + let mut model = None; + let mut cwd = None; + let mut created_at = None; + let mut title = None; + for (timestamp, row) in &rows { + cwd = cwd.or_else(|| import_string(row.get("cwd"))); + created_at = created_at.or_else(|| import_timestamp(Some(timestamp))); + match row.get("type").and_then(Value::as_str) { + Some("assistant") => { + model = model.or_else(|| { + row.get("message") + .and_then(|message| import_string(message.get("model"))) + }) + } + Some("ai-title") => title = import_string(row.get("aiTitle")).or(title), + _ => {} + } + } + let model = model.unwrap_or_else(|| "claude_code".to_string()); + let config = ImportProviderConfig::claude_code(&model); + let mut messages = Vec::new(); + let mut tool_names: HashMap = HashMap::new(); + let mut last_timestamp = created_at.unwrap_or_else(now_ms); + + for (index, (timestamp, row)) in rows.iter().enumerate() { + if row.get("isSidechain").and_then(Value::as_bool) == Some(true) { + continue; + } + let timestamp = import_timestamp(Some(timestamp)).unwrap_or(last_timestamp); + last_timestamp = timestamp; + let entry_id = + import_string(row.get("uuid")).unwrap_or_else(|| format!("{session_id}:{index}")); + let event_id = format!("{session_id}:{entry_id}"); + match row.get("type").and_then(Value::as_str) { + Some("user") => { + if row.get("isMeta").and_then(Value::as_bool) == Some(true) { + continue; + } + let Some(message) = row.get("message") else { + continue; + }; + let content = message.get("content"); + if let Some(text) = content.and_then(Value::as_str) { + if claude_code_is_internal_user_message(row, text) || text.trim().is_empty() { + continue; + } + messages.push(user_message( + event_id, + vec![serde_json::json!({ "type": "text", "text": text })], + timestamp, + )); + continue; + } + let Some(blocks) = content.and_then(Value::as_array) else { + continue; + }; + let text_blocks = import_text_blocks(content, &["text"]); + if !text_blocks.is_empty() { + messages.push(user_message(event_id.clone(), text_blocks, timestamp)); + } + for block in blocks { + if block.get("type").and_then(Value::as_str) != Some("tool_result") { + continue; + } + let tool_call_id = + import_string(block.get("tool_use_id")).unwrap_or_else(|| event_id.clone()); + let tool_name = tool_names + .get(&tool_call_id) + .cloned() + .unwrap_or_else(|| "claude_code_tool".to_string()); + messages.push(tool_result_message( + event_id.clone(), + tool_call_id, + tool_name, + import_text_blocks(block.get("content"), &["text"]), + block + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false), + timestamp, + )); + } + } + Some("assistant") => { + let Some(message) = row.get("message") else { + continue; + }; + let message_model = + import_string(message.get("model")).unwrap_or_else(|| model.clone()); + let stop_reason = + if message.get("stop_reason").and_then(Value::as_str) == Some("tool_use") { + "toolUse" + } else { + "stop" + }; + let Some(blocks) = message.get("content").and_then(Value::as_array) else { + continue; + }; + for (block_index, block) in blocks.iter().enumerate() { + let id = format!("{event_id}:{block_index}"); + match block.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = block + .get("text") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + { + messages.push(assistant_message( + id, + &config, + &message_model, + vec![serde_json::json!({ "type": "text", "text": text })], + stop_reason, + timestamp, + )); + } + } + Some("thinking") => { + if let Some(thinking) = block + .get("thinking") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + { + messages.push(assistant_message( + id, + &config, + &message_model, + vec![serde_json::json!({ "type": "thinking", "thinking": thinking })], + "stop", + timestamp, + )); + } + } + Some("tool_use") => { + let tool_call_id = + import_string(block.get("id")).unwrap_or_else(|| id.clone()); + let tool_name = import_string(block.get("name")) + .unwrap_or_else(|| "claude_code_tool".to_string()); + tool_names.insert(tool_call_id.clone(), tool_name.clone()); + messages.push(assistant_message(id, &config, &message_model, vec![serde_json::json!({ "type": "toolCall", "id": tool_call_id, "name": tool_name, "arguments": block.get("input").cloned().unwrap_or_else(|| serde_json::json!({})) })], "toolUse", timestamp)); + } + _ => {} + } + } + } + _ => {} + } + } + Ok(( + finalize_import_conversation( + &config, + session_id, + model, + cwd, + created_at.unwrap_or(last_timestamp), + last_timestamp, + title, + messages, + ), + skipped_lines, + )) +} + +fn scan_claude_code_sessions() -> Result<(Vec, usize, usize), String> { + let home = dirs::home_dir().ok_or_else(|| "无法定位用户主目录".to_string())?; + let root = home.join(".claude/projects"); + if !root.exists() { + return Ok((Vec::new(), 0, 0)); + } + let mut conversations = Vec::new(); + let mut scanned = 0; + let mut skipped = 0; + for entry in walkdir::WalkDir::new(root) + .max_depth(2) + .into_iter() + .filter_map(Result::ok) + { + let path = entry.path(); + if !entry.file_type().is_file() + || path.extension().and_then(|value| value.to_str()) != Some("jsonl") + { + continue; + } + scanned += 1; + match convert_claude_code_file(path) { + Ok((Some(conversation), skipped_lines)) => { + skipped += skipped_lines; + conversations.push(conversation); + } + Ok((None, skipped_lines)) => skipped += skipped_lines, + Err(_) => skipped += 1, + } + } + conversations.sort_by_key(|conversation| conversation.created_at); + Ok((conversations, scanned, skipped)) +} + +#[tauri::command] +pub async fn chat_history_scan_claude_code() -> Result { + scan_preview_command(scan_claude_code_sessions, "Claude Code").await +} + +#[tauri::command] +pub async fn chat_history_import_claude_code( + gateway_controller: tauri::State<'_, Arc>, + ids: Vec, +) -> Result { + import_selected_command( + scan_claude_code_sessions, + ImportProviderConfig::claude_code("claude_code"), + ids, + &gateway_controller, + ) + .await +} diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/codex_import.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/codex_import.rs new file mode 100644 index 000000000..6ea8cc77d --- /dev/null +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/codex_import.rs @@ -0,0 +1,321 @@ +// Codex importer. Knows only how to locate & parse `~/.codex/sessions/**/rollout-*.jsonl` +// into [`ImportConversation`]. The shared struct, DB write and message builders +// live in `super::import`; this module just supplies the Codex-specific scan + +// parse plus two tauri commands wired to that core. + +use super::import::*; +use super::*; +use std::collections::HashMap; +use std::sync::Arc; + +fn codex_arguments(value: Option<&Value>) -> Value { + let Some(raw) = value.and_then(Value::as_str) else { + return serde_json::json!({}); + }; + serde_json::from_str(raw).unwrap_or_else(|_| serde_json::json!({ "raw": raw })) +} + +fn codex_output_blocks(value: Option<&Value>) -> Vec { + let Some(value) = value else { + return Vec::new(); + }; + let values = value + .as_array() + .cloned() + .unwrap_or_else(|| vec![value.clone()]); + values + .into_iter() + .filter_map(|value| match value { + Value::String(text) if !text.is_empty() => { + Some(serde_json::json!({ "type": "text", "text": text })) + } + Value::Object(object) => object + .get("text") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + .map(|text| serde_json::json!({ "type": "text", "text": text })), + _ => None, + }) + .collect() +} + +fn codex_session_id(rows: &[(String, Value)]) -> Option { + rows.iter() + .filter_map(|(_, row)| { + (row.get("type").and_then(Value::as_str) == Some("session_meta")).then_some(row) + }) + .find(|row| { + row.get("payload") + .and_then(|p| p.get("thread_source")) + .and_then(Value::as_str) + != Some("subagent") + }) + .or_else(|| { + rows.iter().find_map(|(_, row)| { + (row.get("type").and_then(Value::as_str) == Some("session_meta")).then_some(row) + }) + }) + .and_then(|row| row.get("payload")) + .and_then(|payload| { + import_string(payload.get("session_id")).or_else(|| import_string(payload.get("id"))) + }) +} + +pub(crate) fn codex_remap_cwd( + cwd: Option, + codex_temp_root: &std::path::Path, + default_project: &std::path::Path, +) -> Option { + let cwd = cwd?; + if std::path::Path::new(&cwd).starts_with(codex_temp_root) { + return Some(default_project.to_string_lossy().into_owned()); + } + Some(cwd) +} + +pub(crate) fn convert_codex_file( + path: &std::path::Path, + titles: &HashMap, + codex_temp_root: &std::path::Path, + default_project: &std::path::Path, +) -> Result<(Option, usize), String> { + let text = std::fs::read_to_string(path) + .map_err(|e| format!("读取 Codex 会话失败:{}: {e}", path.display()))?; + let (rows, skipped_lines) = parse_jsonl_lines(&text); + if rows.is_empty() { + return Ok((None, skipped_lines)); + } + let Some(session_id) = codex_session_id(&rows) else { + return Ok((None, skipped_lines)); + }; + + let mut model = None; + let mut cwd = None; + let mut created_at = None; + for (timestamp, row) in &rows { + match row.get("type").and_then(Value::as_str) { + Some("session_meta") => { + if let Some(payload) = row.get("payload") { + model = model.or_else(|| import_string(payload.get("model"))); + cwd = cwd.or_else(|| import_string(payload.get("cwd"))); + created_at = created_at.or_else(|| import_timestamp(Some(timestamp))); + } + } + Some("turn_context") => { + if let Some(payload) = row.get("payload") { + model = import_string(payload.get("model")).or(model); + cwd = import_string(payload.get("cwd")).or(cwd); + } + } + _ => {} + } + } + let model = model.unwrap_or_else(|| "codex".to_string()); + let config = ImportProviderConfig::codex(&model); + let mut messages = Vec::new(); + let mut call_names: HashMap = HashMap::new(); + let mut last_timestamp = created_at.unwrap_or_else(now_ms); + + for (index, (timestamp, row)) in rows.iter().enumerate() { + let timestamp = import_timestamp(Some(timestamp)).unwrap_or(last_timestamp); + last_timestamp = timestamp; + if row.get("type").and_then(Value::as_str) != Some("response_item") { + continue; + } + let Some(payload) = row.get("payload") else { + continue; + }; + let item_type = payload + .get("type") + .and_then(Value::as_str) + .unwrap_or_default(); + let item_id = + import_string(payload.get("id")).unwrap_or_else(|| format!("{session_id}:{index}")); + let event_id = format!("{session_id}:{item_id}"); + match item_type { + "message" => { + let role = payload + .get("role") + .and_then(Value::as_str) + .unwrap_or_default(); + if role == "user" { + let content = import_text_blocks(payload.get("content"), &["input_text"]); + if !content.is_empty() { + messages.push(user_message(event_id, content, timestamp)); + } + } else if role == "assistant" { + let content = + import_text_blocks(payload.get("content"), &["output_text", "input_text"]); + if !content.is_empty() { + messages.push(assistant_message( + event_id, &config, &model, content, "stop", timestamp, + )); + } + } + } + "reasoning" => { + let summary = payload + .get("summary") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| item.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + if !summary.is_empty() { + messages.push(assistant_message( + event_id, + &config, + &model, + vec![serde_json::json!({ "type": "thinking", "thinking": summary })], + "stop", + timestamp, + )); + } + } + "function_call" | "custom_tool_call" => { + let call_id = + import_string(payload.get("call_id")).unwrap_or_else(|| item_id.clone()); + let name = + import_string(payload.get("name")).unwrap_or_else(|| "codex_tool".to_string()); + call_names.insert(call_id.clone(), name.clone()); + let arguments = if item_type == "function_call" { + codex_arguments(payload.get("arguments")) + } else { + codex_arguments(payload.get("input")) + }; + messages.push(assistant_message( + event_id, + &config, + &model, + vec![ + serde_json::json!({ "type": "toolCall", "id": call_id, "name": name, "arguments": arguments }), + ], + "toolUse", + timestamp, + )); + } + "function_call_output" | "custom_tool_call_output" => { + let call_id = + import_string(payload.get("call_id")).unwrap_or_else(|| item_id.clone()); + let tool_name = call_names + .get(&call_id) + .cloned() + .unwrap_or_else(|| "codex_tool".to_string()); + messages.push(tool_result_message( + event_id, + call_id, + tool_name, + codex_output_blocks(payload.get("output")), + false, + timestamp, + )); + } + _ => {} + } + } + let title = titles + .get(&session_id) + .cloned() + .filter(|s| !s.trim().is_empty()); + let cwd = codex_remap_cwd(cwd, codex_temp_root, default_project); + Ok(( + finalize_import_conversation( + &config, + session_id, + model, + cwd, + created_at.unwrap_or(last_timestamp), + last_timestamp, + title, + messages, + ), + skipped_lines, + )) +} + +fn read_codex_titles(home: &std::path::Path) -> HashMap { + let mut titles = HashMap::new(); + let path = home.join(".codex/session_index.jsonl"); + let Ok(text) = std::fs::read_to_string(path) else { + return titles; + }; + for line in text.lines() { + if let Ok(row) = serde_json::from_str::(line) { + if let (Some(id), Some(title)) = ( + import_string(row.get("id")), + import_string(row.get("thread_name")), + ) { + titles.insert(id, title); + } + } + } + titles +} + +fn scan_codex_sessions() -> Result<(Vec, usize, usize), String> { + let home = dirs::home_dir().ok_or_else(|| "无法定位用户主目录".to_string())?; + let root = home.join(".codex/sessions"); + if !root.exists() { + return Ok((Vec::new(), 0, 0)); + } + let titles = read_codex_titles(&home); + let codex_temp_root = dirs::document_dir() + .unwrap_or_else(|| home.join("Documents")) + .join("Codex"); + let default_project = home + .join(format!(".{}", env!("CARGO_PKG_NAME"))) + .join("default-project"); + let mut conversations = Vec::new(); + let mut scanned = 0; + let mut skipped = 0; + for entry in walkdir::WalkDir::new(root) + .into_iter() + .filter_map(Result::ok) + { + let path = entry.path(); + if !entry.file_type().is_file() + || path.extension().and_then(|s| s.to_str()) != Some("jsonl") + || !path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or_default() + .starts_with("rollout-") + { + continue; + } + scanned += 1; + match convert_codex_file(path, &titles, &codex_temp_root, &default_project) { + Ok((Some(conversation), skipped_lines)) => { + skipped += skipped_lines; + conversations.push(conversation); + } + Ok((None, skipped_lines)) => skipped += skipped_lines, + Err(_) => skipped += 1, + } + } + conversations.sort_by_key(|conversation| conversation.created_at); + Ok((conversations, scanned, skipped)) +} + +#[tauri::command] +pub async fn chat_history_scan_codex() -> Result { + scan_preview_command(scan_codex_sessions, "Codex").await +} + +#[tauri::command] +pub async fn chat_history_import_codex( + gateway_controller: tauri::State<'_, Arc>, + ids: Vec, +) -> Result { + import_selected_command( + scan_codex_sessions, + ImportProviderConfig::codex("codex"), + ids, + &gateway_controller, + ) + .await +} diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/import.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/import.rs new file mode 100644 index 000000000..c1cfd4e13 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/import.rs @@ -0,0 +1,413 @@ +// Unified data model and persistence core shared by every provider importer +// (codex, claude_code). Each provider supplies: +// - how to locate & parse its source +// - three scalars via `ImportProviderConfig` (provider_id, id prefix, model) +// Writing the conversation to the database is identical across providers, so it +// lives here exactly once. + +use super::*; +use std::collections::HashSet; +use std::sync::Arc; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ImportResult { + pub scanned_count: usize, + pub imported_count: usize, + pub skipped_lines: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ImportPreview { + pub sessions: Vec, + pub scanned_count: usize, + pub skipped_lines: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ImportConversation { + pub id: String, + pub session_id: String, + pub title: String, + pub model: String, + pub cwd: Option, + pub created_at: i64, + pub updated_at: i64, + #[serde(skip)] + pub messages: Vec, + pub message_count: usize, + pub already_imported: bool, +} + +pub(crate) struct ImportProviderConfig { + provider_id: &'static str, + id_prefix: &'static str, + selected_model_json: Option, +} + +impl ImportProviderConfig { + pub(crate) fn codex(model: &str) -> Self { + Self { + provider_id: "codex", + id_prefix: "codex", + selected_model_json: Some(make_selected_model_json("codex", model)), + } + } + pub(crate) fn claude_code(model: &str) -> Self { + Self { + provider_id: "claude_code", + id_prefix: "claude-code", + selected_model_json: Some(make_selected_model_json("builtin-claude_code", model)), + } + } + pub(crate) fn id_prefix_with(&self, session_id: &str) -> String { + format!("{}:{session_id}", self.id_prefix) + } + pub(crate) fn api(&self) -> &'static str { + match self.provider_id { + "codex" => "openai-responses", + _ => "anthropic-messages", + } + } + pub(crate) fn message_provider(&self) -> &'static str { + match self.provider_id { + "codex" => "codex", + _ => "claude_code", + } + } +} + +fn make_selected_model_json(provider_id: &str, model: &str) -> String { + serde_json::json!({ "customProviderId": provider_id, "model": model }).to_string() +} + +pub(crate) fn import_conversation( + config: &ImportProviderConfig, + conn: &mut Connection, + conversation: ImportConversation, +) -> Result<(ChatHistorySummary, bool), String> { + if let Ok(summary) = get_summary_by_id(conn, &conversation.id) { + return Ok((summary, false)); + } + let messages_json = serde_json::to_string(&conversation.messages) + .map_err(|error| format!("序列化 {} 会话消息失败:{error}", config.provider_id))?; + let message_count = conversation.message_count as i64; + let segment_id = format!("{}:segment:0", conversation.id); + let start_message_id = conversation + .messages + .first() + .and_then(|message| message.get("id")) + .and_then(Value::as_str) + .map(str::to_string); + let end_message_id = conversation + .messages + .last() + .and_then(|message| message.get("id")) + .and_then(Value::as_str) + .map(str::to_string); + let input = ChatHistoryUpsertInput { + id: conversation.id, + title: conversation.title, + provider_id: config.provider_id.to_string(), + model: conversation.model, + session_id: Some(conversation.session_id), + cwd: conversation.cwd, + selected_model_json: config.selected_model_json.clone(), + context_meta_json: serde_json::json!({ + "schemaVersion": 3, + "activeSegmentIndex": 0, + "totalSegmentCount": 1, + "totalMessageCount": message_count + }) + .to_string(), + active_segment_index: 0, + total_segment_count: 1, + total_message_count: message_count, + segments: vec![ChatHistorySegmentInput { + segment_index: 0, + segment_id, + summary_json: None, + messages_json, + message_count, + start_message_id, + end_message_id, + created_at: conversation.created_at, + updated_at: conversation.updated_at, + }], + created_at: Some(conversation.created_at), + updated_at: conversation.updated_at, + }; + validate_upsert_input(&input)?; + let conversation_input = ChatHistoryConversationInput { + id: input.id.clone(), + title: input.title, + provider_id: input.provider_id, + model: input.model, + session_id: input.session_id, + cwd: input.cwd, + selected_model_json: input.selected_model_json, + context_meta_json: input.context_meta_json.clone(), + active_segment_index: input.active_segment_index, + total_segment_count: input.total_segment_count, + total_message_count: input.total_message_count, + created_at: input.created_at, + updated_at: input.updated_at, + }; + let tx = conn + .transaction() + .map_err(|error| format!("开启 {} 导入事务失败:{error}", config.provider_id))?; + upsert_chat_history_header(&tx, &conversation_input)?; + sync_segments( + &tx, + input.id.trim(), + &input.segments, + input.total_segment_count, + )?; + verify_chat_history_consistency(&tx, input.id.trim())?; + tx.commit() + .map_err(|error| format!("提交 {} 导入事务失败:{error}", config.provider_id))?; + Ok((get_summary_by_id(conn, input.id.trim())?, true)) +} + +pub(crate) async fn run_import( + config: ImportProviderConfig, + conversations: Vec, + skipped_lines: usize, + selected: HashSet, + gateway_controller: &Arc, +) -> Result { + let scanned_count = conversations.len(); + let provider_id = config.provider_id.to_string(); + let summaries = tauri::async_runtime::spawn_blocking(move || { + let mut conn = open_db()?; + let summaries = conversations + .into_iter() + .filter(|conversation| selected.contains(&conversation.id)) + .map(|conversation| import_conversation(&config, &mut conn, conversation)) + .collect::, _>>()?; + Ok::<_, String>(summaries) + }) + .await + .map_err(|error| format!("{provider_id} 导入失败:{error}"))??; + let mut imported_count = 0; + for (summary, did_insert) in &summaries { + if *did_insert { + gateway_controller + .publish_history_sync(build_history_sync_upsert(summary)) + .await; + imported_count += 1; + } + } + Ok(ImportResult { + scanned_count, + imported_count, + skipped_lines, + }) +} + +pub(crate) async fn scan_preview_command( + scan: F, + error_label: &str, +) -> Result +where + F: FnOnce() -> Result<(Vec, usize, usize), String> + Send + 'static, +{ + let (sessions, scanned_count, skipped_lines) = + tauri::async_runtime::spawn_blocking(move || -> Result<_, String> { + let (conversations, scanned_count, skipped_lines) = scan()?; + let conn = open_db()?; + let sessions = conversations + .into_iter() + .map(|mut conversation| { + conversation.already_imported = + get_summary_by_id(&conn, &conversation.id).is_ok(); + conversation + }) + .collect(); + Ok::<_, String>((sessions, scanned_count, skipped_lines)) + }) + .await + .map_err(|error| format!("{error_label} 扫描失败:{error}"))??; + Ok(ImportPreview { + sessions, + scanned_count, + skipped_lines, + }) +} + +pub(crate) async fn import_selected_command( + scan: F, + config: ImportProviderConfig, + ids: Vec, + gateway_controller: &Arc, +) -> Result +where + F: FnOnce() -> Result<(Vec, usize, usize), String> + Send + 'static, +{ + let selected: HashSet = ids.into_iter().filter(|id| !id.trim().is_empty()).collect(); + let (conversations, _scanned_count, skipped_lines) = tauri::async_runtime::spawn_blocking(scan) + .await + .map_err(|error| format!("{} 扫描失败:{error}", config.provider_id))??; + run_import( + config, + conversations, + skipped_lines, + selected, + gateway_controller, + ) + .await +} + +pub(crate) fn parse_jsonl_lines(text: &str) -> (Vec<(String, Value)>, usize) { + let mut rows = Vec::new(); + let mut skipped_lines = 0; + for line in text.lines() { + match serde_json::from_str::(line) { + Ok(row) if row.get("type").and_then(Value::as_str).is_some() => { + rows.push((import_string(row.get("timestamp")).unwrap_or_default(), row)); + } + _ => skipped_lines += 1, + } + } + (rows, skipped_lines) +} + +pub(crate) fn user_message(id: String, content: Vec, timestamp: i64) -> Value { + serde_json::json!({ "role": "user", "id": id, "content": content, "timestamp": timestamp }) +} + +pub(crate) fn assistant_message( + id: String, + config: &ImportProviderConfig, + model: &str, + content: Vec, + stop_reason: &str, + timestamp: i64, +) -> Value { + serde_json::json!({ + "role": "assistant", + "id": id, + "content": content, + "api": config.api(), + "provider": config.message_provider(), + "model": model, + "usage": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0 }, + "stopReason": stop_reason, + "timestamp": timestamp + }) +} + +pub(crate) fn tool_result_message( + id: String, + tool_call_id: String, + tool_name: String, + content: Vec, + is_error: bool, + timestamp: i64, +) -> Value { + serde_json::json!({ + "role": "toolResult", + "id": id, + "toolCallId": tool_call_id, + "toolName": tool_name, + "content": content, + "isError": is_error, + "timestamp": timestamp + }) +} + +pub(crate) fn finalize_import_conversation( + config: &ImportProviderConfig, + session_id: String, + model: String, + cwd: Option, + created_at: i64, + updated_at: i64, + title: Option, + messages: Vec, +) -> Option { + if messages.is_empty() { + return None; + } + let first_user_text = messages.iter().find_map(|message| { + if message.get("role")?.as_str()? != "user" { + return None; + } + message.get("content")?.as_array().map(|blocks| { + blocks + .iter() + .filter_map(|block| block.get("text")?.as_str()) + .collect::>() + .join("\n") + }) + }); + let title = title + .filter(|title| !title.trim().is_empty()) + .or_else(|| first_user_text.map(|text| text.chars().take(80).collect())) + .unwrap_or_else(|| format!("{} {}", config.message_provider(), session_id)); + let message_count = messages.len(); + Some(ImportConversation { + id: config.id_prefix_with(&session_id), + session_id, + title, + model, + cwd, + created_at, + updated_at, + messages, + message_count, + already_imported: false, + }) +} + +pub(crate) fn import_string(value: Option<&Value>) -> Option { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +pub(crate) fn import_timestamp(value: Option<&str>) -> Option { + value.and_then(|value| { + chrono::DateTime::parse_from_rfc3339(value) + .ok() + .map(|date| date.timestamp_millis()) + }) +} + +pub(crate) fn import_text_blocks(value: Option<&Value>, allowed: &[&str]) -> Vec { + let Some(value) = value else { + return Vec::new(); + }; + let values = value + .as_array() + .cloned() + .unwrap_or_else(|| vec![value.clone()]); + values + .into_iter() + .filter_map(|value| match value { + Value::String(text) if !text.is_empty() => { + Some(serde_json::json!({ "type": "text", "text": text })) + } + Value::Object(object) => { + let kind = object + .get("type") + .and_then(Value::as_str) + .unwrap_or_default(); + if allowed.contains(&kind) { + object + .get("text") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + .map(|text| serde_json::json!({ "type": "text", "text": text })) + } else { + None + } + } + _ => None, + }) + .collect() +} diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/mod.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/mod.rs index 349bd5c24..ff88c6a45 100644 --- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/mod.rs +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/mod.rs @@ -37,6 +37,9 @@ include!("share.rs"); include!("search.rs"); include!("commands.rs"); include!("replace.rs"); +pub(crate) mod claude_code_import; +pub(crate) mod codex_import; +mod import; include!("branch.rs"); include!("delete.rs"); include!("tests.rs"); diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index ee7b77a52..09f8b7ff7 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -49,6 +49,10 @@ macro_rules! app_invoke_handler { commands::chat_history::chat_history_upsert, commands::chat_history::chat_history_upsert_active_segment, commands::chat_history::chat_history_append_segment, + commands::chat_history::codex_import::chat_history_scan_codex, + commands::chat_history::codex_import::chat_history_import_codex, + commands::chat_history::claude_code_import::chat_history_scan_claude_code, + commands::chat_history::claude_code_import::chat_history_import_claude_code, commands::chat_history::chat_history_rename, commands::chat_history::chat_history_branch, commands::chat_history::chat_history_replace_from_message, diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 1d5a9014a..fee760c49 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -139,6 +139,37 @@ export const translations: Record> = { "chat.history.deleteBlockedRunning": "后台任务仍在运行,暂时不能删除该对话。", "chat.history.openFailed": "读取历史对话失败", "chat.history.persistFailed": "历史记录保存失败:{message}", + "chat.history.codexImport": "导入 Codex 对话", + "chat.history.claudeCodeImport": "导入 Claude Code 对话", + "chat.history.claudeImportFailed": "Claude 对话导入失败", + "chat.history.claudeCodeImporting": "正在导入 Claude Code 对话…", + "chat.history.claudeCodeImportFailed": "Claude Code 对话导入失败", + "chat.history.claudeCodeImportDialogTitle": "导入 Claude Code 对话", + "chat.history.claudeCodeImportDialogSubtitle": "从本机 Claude Code 会话中选择需要导入的对话。", + "chat.history.claudeCodeImportDialogWorkspaces": "工作区", + "chat.history.claudeCodeImportDialogNoWorkspace": "无工作区", + "chat.history.claudeCodeImportDialogAll": "全选", + "chat.history.claudeCodeImportDialogNone": "清空", + "chat.history.claudeCodeImportDialogSelected": "已选 {count} 项", + "chat.history.claudeCodeImportDialogImport": "导入 {count} 项", + "chat.history.claudeCodeImportDialogAlreadyImported": "已导入", + "chat.history.claudeCodeImportDialogMessages": "条消息", + "chat.history.claudeCodeImportDialogEmpty": "没有可导入的新会话。", + "chat.history.codexImporting": "正在导入 Codex 对话…", + "chat.history.codexImportDone": "已导入 {count} 个 Codex 对话", + "chat.history.codexImportFailed": "Codex 对话导入失败", + "chat.history.codexImportDialogTitle": "导入 Codex 对话", + "chat.history.codexImportDialogSubtitle": + "勾选要导入的工作区与会话,临时会话已合并到 default 工作区。", + "chat.history.codexImportDialogWorkspaces": "工作区", + "chat.history.codexImportDialogNoWorkspace": "无工作区", + "chat.history.codexImportDialogAll": "全选", + "chat.history.codexImportDialogNone": "清空", + "chat.history.codexImportDialogSelected": "已选 {count} 项", + "chat.history.codexImportDialogImport": "导入 {count} 项", + "chat.history.codexImportDialogAlreadyImported": "已导入", + "chat.history.codexImportDialogMessages": "条消息", + "chat.history.codexImportDialogEmpty": "没有可导入的新会话。", "chat.history.syncing": "正在同步…", "chat.resizeSidebarSections": "调整工作空间与最近对话占比", "chat.emptyChatHistory": "暂无历史对话", @@ -941,6 +972,7 @@ export const translations: Record> = { "settings.toolPolicy.ask": "询问", "settings.toolPolicy.deny": "拒绝", "settings.navProviders": "供应商配置", + "settings.navConversations": "会话管理", "settings.navHooks": "Hooks", "settings.navAgents": "全局提示词", "settings.navSsh": "SSH", @@ -954,6 +986,19 @@ export const translations: Record> = { "settings.groupAutomation": "自动化", "settings.groupConnectivity": "连接", "settings.groupOther": "其他", + "settings.conversationsTitle": "会话管理", + "settings.conversationsDescription": "管理从其他客户端导入到 LiveAgent 的本地会话。", + "settings.codexImportTitle": "Codex 对话", + "settings.codexImportDescription": "从 ~/.codex/sessions 导入尚未存在于 LiveAgent 的会话。", + "settings.codexImportResult": "已导入 {imported} 个会话,共扫描 {scanned} 个 Codex 会话文件。", + "settings.claudeImportTitle": "Claude 对话", + "settings.claudeImportDescription": "从本机 Claude Code 导入对话。", + "settings.claudeImportResult": "已导入 {imported} 个会话,共读取 {scanned} 个 Claude 会话。", + "settings.claudeCodeImportTitle": "Claude Code 对话", + "settings.claudeCodeImportDescription": + "从 ~/.claude/projects 导入 Claude Code 主会话;子代理和工作流记录不会导入。", + "settings.claudeCodeImportResult": + "已导入 {imported} 个会话,共扫描 {scanned} 个 Claude Code 会话文件。", "settings.backToChat": "返回对话", "settings.title": "设置", @@ -2406,6 +2451,38 @@ export const translations: Record> = { "A background task is still running; the conversation cannot be deleted yet.", "chat.history.openFailed": "Failed to open conversation", "chat.history.persistFailed": "Failed to save history: {message}", + "chat.history.codexImport": "Import Codex conversations", + "chat.history.claudeCodeImport": "Import Claude Code conversations", + "chat.history.claudeImportFailed": "Failed to import Claude conversations", + "chat.history.claudeCodeImporting": "Importing Claude Code conversations…", + "chat.history.claudeCodeImportFailed": "Failed to import Claude Code conversations", + "chat.history.claudeCodeImportDialogTitle": "Import Claude Code conversations", + "chat.history.claudeCodeImportDialogSubtitle": + "Choose which local Claude Code conversations to import.", + "chat.history.claudeCodeImportDialogWorkspaces": "Workspaces", + "chat.history.claudeCodeImportDialogNoWorkspace": "No workspace", + "chat.history.claudeCodeImportDialogAll": "Select all", + "chat.history.claudeCodeImportDialogNone": "Clear", + "chat.history.claudeCodeImportDialogSelected": "{count} selected", + "chat.history.claudeCodeImportDialogImport": "Import {count} items", + "chat.history.claudeCodeImportDialogAlreadyImported": "Imported", + "chat.history.claudeCodeImportDialogMessages": "messages", + "chat.history.claudeCodeImportDialogEmpty": "No new sessions to import.", + "chat.history.codexImporting": "Importing Codex conversations…", + "chat.history.codexImportDone": "Imported {count} Codex conversations", + "chat.history.codexImportFailed": "Failed to import Codex conversations", + "chat.history.codexImportDialogTitle": "Import Codex conversations", + "chat.history.codexImportDialogSubtitle": + "Select the workspaces and sessions to import. Temporary sessions are merged into the default workspace.", + "chat.history.codexImportDialogWorkspaces": "Workspaces", + "chat.history.codexImportDialogNoWorkspace": "No workspace", + "chat.history.codexImportDialogAll": "Select all", + "chat.history.codexImportDialogNone": "Clear", + "chat.history.codexImportDialogSelected": "{count} selected", + "chat.history.codexImportDialogImport": "Import {count} items", + "chat.history.codexImportDialogAlreadyImported": "Imported", + "chat.history.codexImportDialogMessages": "messages", + "chat.history.codexImportDialogEmpty": "No new sessions to import.", "chat.history.syncing": "Syncing…", "chat.resizeSidebarSections": "Resize workspaces and recent conversations", "chat.emptyChatHistory": "No conversation history", @@ -3234,6 +3311,7 @@ export const translations: Record> = { "settings.toolPolicy.ask": "Ask", "settings.toolPolicy.deny": "Deny", "settings.navProviders": "Providers", + "settings.navConversations": "Conversations", "settings.navHooks": "Hooks", "settings.navAgents": "Prompt", "settings.navSsh": "SSH", @@ -3247,6 +3325,23 @@ export const translations: Record> = { "settings.groupAutomation": "Automation", "settings.groupConnectivity": "Connectivity", "settings.groupOther": "Other", + "settings.conversationsTitle": "Conversation management", + "settings.conversationsDescription": + "Manage local conversations imported into LiveAgent from other clients.", + "settings.codexImportTitle": "Codex conversations", + "settings.codexImportDescription": + "Import conversations from ~/.codex/sessions that are not already in LiveAgent.", + "settings.codexImportResult": + "Imported {imported} conversations from {scanned} scanned Codex session files.", + "settings.claudeImportTitle": "Claude conversations", + "settings.claudeImportDescription": "Import conversations from local Claude Code.", + "settings.claudeImportResult": + "Imported {imported} conversations from {scanned} Claude conversations.", + "settings.claudeCodeImportTitle": "Claude Code conversations", + "settings.claudeCodeImportDescription": + "Import primary sessions from ~/.claude/projects. Subagent and workflow transcripts are excluded.", + "settings.claudeCodeImportResult": + "Imported {imported} conversations from {scanned} scanned Claude Code session files.", "settings.backToChat": "Back to Chat", "settings.title": "Settings", diff --git a/crates/agent-gui/src/lib/chat/history/chatHistory.ts b/crates/agent-gui/src/lib/chat/history/chatHistory.ts index 53b37b635..68cd07687 100644 --- a/crates/agent-gui/src/lib/chat/history/chatHistory.ts +++ b/crates/agent-gui/src/lib/chat/history/chatHistory.ts @@ -249,6 +249,54 @@ export async function listChatHistory( }); } +export type ImportPreviewSession = { + id: string; + sessionId: string; + title: string; + model: string; + cwd?: string; + createdAt: number; + updatedAt: number; + messageCount: number; + alreadyImported: boolean; +}; + +export type ImportPreview = { + sessions: ImportPreviewSession[]; + scannedCount: number; + skippedLines: number; +}; + +export type ImportResult = { + scannedCount: number; + importedCount: number; + skippedLines: number; +}; + +// Backward-compatible aliases for existing call sites. +export type ClaudeCodeImportPreviewSession = ImportPreviewSession; +export type ClaudeCodeImportPreview = ImportPreview; +export type ClaudeCodeImportResult = ImportResult; +export type CodexImportPreviewSession = ImportPreviewSession; +export type CodexImportPreview = ImportPreview; +export type CodexImportResult = ImportResult; + +export async function scanClaudeCodeChatHistory() { + return invoke("chat_history_scan_claude_code"); +} + +export async function importClaudeCodeChatHistory(ids: string[]) { + return invoke("chat_history_import_claude_code", { ids }); +} + +export async function scanCodexChatHistory() { + return invoke("chat_history_scan_codex"); +} + +export async function importCodexChatHistory(ids: string[]) { + return invoke("chat_history_import_codex", { ids }); +} + export async function listChatHistoryWorkdirs() { return invoke("chat_history_workdirs"); } diff --git a/crates/agent-gui/src/lib/chat/history/useImportSelection.ts b/crates/agent-gui/src/lib/chat/history/useImportSelection.ts new file mode 100644 index 000000000..fc365a7ec --- /dev/null +++ b/crates/agent-gui/src/lib/chat/history/useImportSelection.ts @@ -0,0 +1,76 @@ +import { useCallback, useMemo, useState } from "react"; +import type { ImportPreviewSession } from "./chatHistory"; + +export function useImportSelection(sessions: ImportPreviewSession[]) { + const [selected, setSelected] = useState>(() => { + const set = new Set(); + for (const session of sessions) { + if (!session.alreadyImported) set.add(session.id); + } + return set; + }); + + const importable = useMemo( + () => sessions.filter((session) => !session.alreadyImported), + [sessions], + ); + + const toggleSession = useCallback((session: ImportPreviewSession) => { + if (session.alreadyImported) return; + setSelected((prev) => { + const next = new Set(prev); + if (next.has(session.id)) next.delete(session.id); + else next.add(session.id); + return next; + }); + }, []); + + const toggleGroup = useCallback( + (group: ImportPreviewSession[]) => { + const importableGroup = group.filter((session) => !session.alreadyImported); + if (importableGroup.length === 0) return; + const allSelected = importableGroup.every((session) => selected.has(session.id)); + setSelected((prev) => { + const next = new Set(prev); + if (allSelected) { + for (const session of importableGroup) next.delete(session.id); + } else { + for (const session of importableGroup) next.add(session.id); + } + return next; + }); + }, + [selected], + ); + + const selectAll = useCallback(() => { + setSelected(new Set(importable.map((session) => session.id))); + }, [importable]); + + const selectNone = useCallback(() => { + setSelected(new Set()); + }, []); + + const isAllSelected = + importable.length > 0 && importable.every((session) => selected.has(session.id)); + + const isGroupAllSelected = useCallback( + (group: ImportPreviewSession[]) => { + const importableGroup = group.filter((session) => !session.alreadyImported); + return ( + importableGroup.length > 0 && importableGroup.every((session) => selected.has(session.id)) + ); + }, + [selected], + ); + + return { + selected, + toggleSession, + toggleGroup, + selectAll, + selectNone, + isAllSelected, + isGroupAllSelected, + }; +} diff --git a/crates/agent-gui/src/pages/SettingsPage.tsx b/crates/agent-gui/src/pages/SettingsPage.tsx index 05422f7cf..3076bb147 100644 --- a/crates/agent-gui/src/pages/SettingsPage.tsx +++ b/crates/agent-gui/src/pages/SettingsPage.tsx @@ -6,6 +6,7 @@ import { Clock3, Cloud, Cpu, + History, Info, Key, Keyboard, @@ -18,6 +19,7 @@ import { isMacOsTauri, MacOsTitleBarSpacer } from "../components/MacOsTitleBarSp import { useLocale } from "../i18n"; import { AboutSection } from "./settings/AboutSection"; import { AgentsSection } from "./settings/AgentsSection"; +import { ConversationsSection } from "./settings/ConversationsSection"; import { CronSection } from "./settings/CronSection"; import { GlobalShortcutsSection } from "./settings/GlobalShortcutsSection"; import { HooksSection } from "./settings/HooksSection"; @@ -43,8 +45,6 @@ function getSaveIndicator(state: SettingsPageProps["saveState"], t: (key: string text: t("settings.saveError"), title: state.message, }; - case "saved": - case "idle": default: return { dotClass: "bg-emerald-500", @@ -97,6 +97,7 @@ const NAV_GROUPS: NavGroup[] = [ items: [ { id: "system", icon: }, { id: "providers", icon: }, + { id: "conversations", icon: }, { id: "agents", icon: }, ], }, @@ -145,19 +146,23 @@ export function SettingsPage(props: SettingsPageProps) { const [section, setSection] = useState(initialSection); const [pendingProviderId, setPendingProviderId] = useState(initialProviderId); - const sectionLabels: Record = { - system: t("settings.navSystem"), - shortcuts: t("settings.navShortcuts"), - systemTools: t("settings.navSystemTools"), - providers: t("settings.navProviders"), - agents: t("settings.navAgents"), - ssh: t("settings.navSsh"), - memory: t("settings.navMemory"), - hooks: t("settings.navHooks"), - cron: t("settings.navCron"), - remote: t("settings.navRemote"), - about: t("settings.navAbout"), - }; + const sectionLabels = useMemo>( + () => ({ + system: t("settings.navSystem"), + shortcuts: t("settings.navShortcuts"), + systemTools: t("settings.navSystemTools"), + providers: t("settings.navProviders"), + conversations: t("settings.navConversations"), + agents: t("settings.navAgents"), + ssh: t("settings.navSsh"), + memory: t("settings.navMemory"), + hooks: t("settings.navHooks"), + cron: t("settings.navCron"), + remote: t("settings.navRemote"), + about: t("settings.navAbout"), + }), + [t], + ); const hiddenSectionSet = useMemo(() => new Set(hiddenSections), [hiddenSections]); const navGroups = useMemo( @@ -196,6 +201,8 @@ export function SettingsPage(props: SettingsPageProps) { onInitialProviderHandled={() => setPendingProviderId(undefined)} /> ); + case "conversations": + return ; case "system": return ; case "shortcuts": diff --git a/crates/agent-gui/src/pages/settings/ConversationsSection.tsx b/crates/agent-gui/src/pages/settings/ConversationsSection.tsx new file mode 100644 index 000000000..5f87afb71 --- /dev/null +++ b/crates/agent-gui/src/pages/settings/ConversationsSection.tsx @@ -0,0 +1,176 @@ +import { useState } from "react"; +import { History, Loader2, Upload } from "../../components/icons"; +import { Button } from "../../components/ui/button"; +import { useLocale } from "../../i18n"; +import { + type ImportPreview, + type ImportResult, + importClaudeCodeChatHistory, + importCodexChatHistory, + scanClaudeCodeChatHistory, + scanCodexChatHistory, +} from "../../lib/chat/history/chatHistory"; +import { ImportDialog, type ImportSource } from "./ImportDialog"; + +type ImportDialogState = { + source: ImportSource; + preview: ImportPreview; +}; + +export function ConversationsSection() { + const { t } = useLocale(); + const [scanning, setScanning] = useState(null); + const [importing, setImporting] = useState(null); + const [dialog, setDialog] = useState(null); + const [result, setResult] = useState<{ source: ImportSource; value: ImportResult } | null>(null); + const [error, setError] = useState(null); + + async function handleScan(source: ImportSource) { + setScanning(source); + setResult(null); + setError(null); + try { + const preview = + source === "codex" ? await scanCodexChatHistory() : await scanClaudeCodeChatHistory(); + if (preview.sessions.length === 0) { + setError( + t( + source === "codex" + ? "chat.history.codexImportDialogEmpty" + : "chat.history.claudeCodeImportDialogEmpty", + ), + ); + return; + } + setDialog({ source, preview }); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setScanning(null); + } + } + + async function handleConfirm(source: ImportSource, ids: string[]) { + setDialog(null); + setImporting(source); + setError(null); + try { + const value = + source === "codex" + ? await importCodexChatHistory(ids) + : await importClaudeCodeChatHistory(ids); + setResult({ source, value }); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setImporting(null); + } + } + + function claudeImportCard() { + const busy = scanning !== null || importing !== null; + const sourceResult = result?.source?.startsWith("claude-") ? result.value : null; + return ( +
+
+
+
{t("settings.claudeImportTitle")}
+

+ {t("settings.claudeImportDescription")} +

+
+ +
+ {sourceResult ? ( +
+ {t("settings.claudeImportResult") + .replace("{imported}", String(sourceResult.importedCount)) + .replace("{scanned}", String(sourceResult.scannedCount))} +
+ ) : null} + {error ? ( +
+ {t("chat.history.claudeImportFailed")}:{error} +
+ ) : null} +
+ ); + } + + function codexImportCard() { + const busy = scanning !== null || importing !== null; + const sourceResult = result?.source === "codex" ? result.value : null; + + return ( +
+
+
+
{t("settings.codexImportTitle")}
+

+ {t("settings.codexImportDescription")} +

+
+ +
+ {sourceResult ? ( +
+ {t("settings.codexImportResult") + .replace("{imported}", String(sourceResult.importedCount)) + .replace("{scanned}", String(sourceResult.scannedCount))} +
+ ) : null} + {error ? ( +
+ {t("chat.history.codexImportFailed")}:{error} +
+ ) : null} +
+ ); + } + + return ( +
+
+
+ +
+
+

{t("settings.conversationsTitle")}

+

+ {t("settings.conversationsDescription")} +

+
+
+ + {claudeImportCard()} + {codexImportCard()} + + {dialog ? ( + setDialog(null)} + onConfirm={(ids) => void handleConfirm(dialog.source, ids)} + /> + ) : null} +
+ ); +} diff --git a/crates/agent-gui/src/pages/settings/ImportDialog.tsx b/crates/agent-gui/src/pages/settings/ImportDialog.tsx new file mode 100644 index 000000000..9cc77b839 --- /dev/null +++ b/crates/agent-gui/src/pages/settings/ImportDialog.tsx @@ -0,0 +1,324 @@ +import { useCallback, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { Check, Folder, MessageSquareText, X } from "../../components/icons"; +import { Button } from "../../components/ui/button"; +import { useLocale } from "../../i18n"; +import type { ImportPreview, ImportPreviewSession } from "../../lib/chat/history/chatHistory"; +import { useImportSelection } from "../../lib/chat/history/useImportSelection"; +import { useModalMotion } from "../../lib/shared/modalMotion"; +import { cn } from "../../lib/shared/utils"; + +export type ImportSource = "codex" | "claude-code"; + +type ImportDialogProps = { + source: ImportSource; + preview: ImportPreview; + onClose: () => void; + onConfirm: (ids: string[]) => void; +}; + +const NO_CWD_KEY = "__liveagent_no_cwd__"; + +function workspaceLabel(cwd: string): string { + if (cwd === NO_CWD_KEY) return ""; + const segments = cwd.split(/[\\/]/).filter(Boolean); + return segments[segments.length - 1] ?? cwd; +} + +const LABELS: Record< + ImportSource, + { + title: string; + subtitle: string; + workspaces: string; + noWorkspace: string; + empty: string; + all: string; + none: string; + selected: string; + import: string; + alreadyImported: string; + messages: string; + } +> = { + codex: { + title: "chat.history.codexImportDialogTitle", + subtitle: "chat.history.codexImportDialogSubtitle", + workspaces: "chat.history.codexImportDialogWorkspaces", + noWorkspace: "chat.history.codexImportDialogNoWorkspace", + empty: "chat.history.codexImportDialogEmpty", + all: "chat.history.codexImportDialogAll", + none: "chat.history.codexImportDialogNone", + selected: "chat.history.codexImportDialogSelected", + import: "chat.history.codexImportDialogImport", + alreadyImported: "chat.history.codexImportDialogAlreadyImported", + messages: "chat.history.codexImportDialogMessages", + }, + "claude-code": { + title: "chat.history.claudeCodeImportDialogTitle", + subtitle: "chat.history.claudeCodeImportDialogSubtitle", + workspaces: "chat.history.claudeCodeImportDialogWorkspaces", + noWorkspace: "chat.history.claudeCodeImportDialogNoWorkspace", + empty: "chat.history.claudeCodeImportDialogEmpty", + all: "chat.history.claudeCodeImportDialogAll", + none: "chat.history.claudeCodeImportDialogNone", + selected: "chat.history.claudeCodeImportDialogSelected", + import: "chat.history.claudeCodeImportDialogImport", + alreadyImported: "chat.history.claudeCodeImportDialogAlreadyImported", + messages: "chat.history.claudeCodeImportDialogMessages", + }, +}; + +function buildGroups( + source: ImportSource, + sessions: ImportPreviewSession[], +): [string, ImportPreviewSession[]][] { + const map = new Map(); + for (const session of sessions) { + const key = session.cwd?.trim() ? session.cwd : NO_CWD_KEY; + const list = map.get(key); + if (list) list.push(session); + else map.set(key, [session]); + } + return Array.from(map.entries()).sort((a, b) => { + if (source === "codex") { + const aDefault = a[0].includes("default-project"); + const bDefault = b[0].includes("default-project"); + if (aDefault !== bDefault) return aDefault ? -1 : 1; + } + const aNoCwd = a[0] === NO_CWD_KEY; + const bNoCwd = b[0] === NO_CWD_KEY; + if (aNoCwd !== bNoCwd) return aNoCwd ? 1 : -1; + return b[1].length - a[1].length; + }); +} + +export function ImportDialog({ source, preview, onClose, onConfirm }: ImportDialogProps) { + const { t } = useLocale(); + const { modalState, requestClose } = useModalMotion(onClose); + const labels = LABELS[source]; + const WorkspaceIcon = Folder; + + const groups = useMemo(() => buildGroups(source, preview.sessions), [source, preview.sessions]); + const [activeCwdKey, setActiveCwdKey] = useState(groups[0]?.[0] ?? null); + + const { selected, toggleSession, toggleGroup, selectAll, selectNone, isAllSelected } = + useImportSelection(preview.sessions); + + const formatKey = useCallback( + (key: string) => { + if (key === NO_CWD_KEY) return t(labels.noWorkspace); + return workspaceLabel(key); + }, + [labels, t], + ); + + const activeSessions = useMemo( + () => groups.find(([key]) => key === activeCwdKey)?.[1] ?? [], + [groups, activeCwdKey], + ); + + const groupImportable = (list: ImportPreviewSession[]) => + list.filter((session) => !session.alreadyImported); + + const activeSessionsAllSelected = + activeSessions.length > 0 && + groupImportable(activeSessions).every((session) => selected.has(session.id)); + + return createPortal( +
+ +
+ +
+ {/* 左栏:工作区 */} +
+
+ {t(labels.workspaces)} + +
+
+ {groups.length === 0 ? ( +

{t(labels.empty)}

+ ) : ( + groups.map(([key, list]) => { + const isActive = key === activeCwdKey; + const checked = list.filter((session) => selected.has(session.id)).length; + const importable = groupImportable(list); + const allSelected = importable.length > 0 && checked === importable.length; + return ( + + + + {formatKey(key)} + {key !== NO_CWD_KEY ? ( + + {key} + + ) : null} + + + {checked}/{importable.length} + + + ); + }) + )} +
+
+ + {/* 右栏:会话 */} +
+
+ + {formatKey(activeCwdKey ?? NO_CWD_KEY)} + + {activeSessions.length > 0 ? ( + + ) : null} +
+
+ {activeSessions.length === 0 ? ( +

+ {t(labels.empty)} +

+ ) : ( + activeSessions.map((session) => { + const checked = selected.has(session.id); + return ( + + ); + }) + )} +
+
+
+ +
+ + {t(labels.selected).replace("{count}", String(selected.size))} + +
+ + +
+
+ + , + document.body, + ); +} diff --git a/crates/agent-gui/src/pages/settings/types.ts b/crates/agent-gui/src/pages/settings/types.ts index 6c1995c8a..65adc4ffb 100644 --- a/crates/agent-gui/src/pages/settings/types.ts +++ b/crates/agent-gui/src/pages/settings/types.ts @@ -9,6 +9,7 @@ export type SectionId = | "shortcuts" | "systemTools" | "providers" + | "conversations" | "agents" | "ssh" | "memory"