From 10e26b34976e33545e702823f0e8bd48425f7d4f Mon Sep 17 00:00:00 2001 From: FlowerRealm Date: Mon, 27 Jul 2026 19:35:05 -0700 Subject: [PATCH 01/14] feat(chat-history): import Codex sessions into LiveAgent Add a settings entry "Conversations" that scans ~/.codex/sessions and imports Codex conversations that are not already present in LiveAgent, exposing chat_history_import_codex to the frontend. - Backend: new codex_import module wiring an import command - Frontend: ConversationsSection + nav entry + i18n (zh/en) - Tests: cover the Codex import path --- .../history/chat_history/codex_import.rs | 384 ++++++++++++++++++ .../src/commands/history/chat_history/mod.rs | 1 + .../commands/history/chat_history/tests.rs | 112 +++++ crates/agent-gui/src-tauri/src/lib.rs | 1 + crates/agent-gui/src/i18n/config.ts | 23 ++ .../src/lib/chat/history/chatHistory.ts | 10 + crates/agent-gui/src/pages/SettingsPage.tsx | 37 +- .../pages/settings/ConversationsSection.tsx | 73 ++++ crates/agent-gui/src/pages/settings/types.ts | 1 + 9 files changed, 627 insertions(+), 15 deletions(-) create mode 100644 crates/agent-gui/src-tauri/src/commands/history/chat_history/codex_import.rs create mode 100644 crates/agent-gui/src/pages/settings/ConversationsSection.tsx 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..1d46762b0 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/codex_import.rs @@ -0,0 +1,384 @@ +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexImportResult { + pub scanned_count: usize, + pub imported_count: usize, + pub skipped_lines: usize, +} + +struct CodexConversation { + id: String, + session_id: String, + title: String, + model: String, + cwd: Option, + created_at: i64, + updated_at: i64, + messages: Vec, +} + +fn codex_string(value: Option<&Value>) -> Option { + value.and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).map(str::to_string) +} + +fn codex_timestamp(value: Option<&str>) -> Option { + value.and_then(|value| { + chrono::DateTime::parse_from_rfc3339(value) + .ok() + .map(|date| date.timestamp_millis()) + }) +} + +fn codex_text_blocks(value: Option<&Value>, input: bool) -> Vec { + let mut blocks = Vec::new(); + let Some(value) = value else { return blocks }; + let values = value.as_array().cloned().unwrap_or_else(|| vec![value.clone()]); + for item in values { + match item { + Value::String(text) if !text.is_empty() => { + blocks.push(serde_json::json!({ "type": "text", "text": text })); + } + Value::Object(object) => { + let kind = object.get("type").and_then(Value::as_str).unwrap_or_default(); + let accepted = if input { kind == "input_text" } else { kind == "output_text" || kind == "input_text" }; + if accepted { + if let Some(text) = object.get("text").and_then(Value::as_str) { + blocks.push(serde_json::json!({ "type": "text", "text": text })); + } + } + } + _ => {} + } + } + blocks +} + +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()]); + let mut blocks = Vec::new(); + for item in values { + match item { + Value::String(text) => blocks.push(serde_json::json!({ "type": "text", "text": text })), + Value::Object(object) => { + if object.get("type").and_then(Value::as_str) == Some("text") { + if let Some(text) = object.get("text").and_then(Value::as_str) { + blocks.push(serde_json::json!({ "type": "text", "text": text })); + } + } else if let Some(text) = object.get("text").and_then(Value::as_str) { + blocks.push(serde_json::json!({ "type": "text", "text": text })); + } + } + _ => {} + } + } + blocks +} + +fn codex_assistant( + id: String, + content: Vec, + model: &str, + timestamp: i64, + stop_reason: &str, +) -> Value { + serde_json::json!({ + "role": "assistant", + "id": id, + "content": content, + "api": "openai-responses", + "provider": "codex", + "model": model, + "usage": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0 }, + "stopReason": stop_reason, + "timestamp": timestamp + }) +} + +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| codex_string(payload.get("session_id")).or_else(|| codex_string(payload.get("id")))) +} + +fn codex_import_cwd(cwd: Option, home: &std::path::Path) -> Option { + let cwd = cwd?; + let temporary_root = home.join("Documents").join("Codex"); + let path = std::path::Path::new(&cwd); + if path == temporary_root || path.starts_with(&temporary_root) { + return Some( + home.join(format!(".{}", env!("CARGO_PKG_NAME"))) + .join("default-project") + .to_string_lossy() + .into_owned(), + ); + } + Some(cwd) +} + +fn convert_codex_file( + path: &std::path::Path, + titles: &HashMap, + home: &std::path::Path, +) -> Result<(Option, usize), String> { + let text = std::fs::read_to_string(path).map_err(|e| format!("读取 Codex 会话失败:{}: {e}", path.display()))?; + 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() => { + let timestamp = codex_string(row.get("timestamp")).unwrap_or_default(); + rows.push((timestamp, row)); + } + _ => skipped_lines += 1, + } + } + 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(|| codex_string(payload.get("model"))); + cwd = cwd.or_else(|| codex_string(payload.get("cwd"))); + created_at = created_at.or_else(|| codex_timestamp(Some(timestamp))); + } + } + Some("turn_context") => { + if let Some(payload) = row.get("payload") { + model = codex_string(payload.get("model")).or(model); + cwd = codex_string(payload.get("cwd")).or(cwd); + } + } + _ => {} + } + } + let model = model.unwrap_or_else(|| "codex".to_string()); + let mut messages = Vec::new(); + let mut call_names = HashMap::new(); + let mut first_user_text = None; + let mut last_timestamp = created_at.unwrap_or_else(now_ms); + + for (index, (timestamp, row)) in rows.iter().enumerate() { + let timestamp = codex_timestamp(Some(timestamp)).unwrap_or(last_timestamp); + last_timestamp = timestamp; + if let Some(payload) = row.get("payload") { + if row.get("type").and_then(Value::as_str) != Some("response_item") { continue; } + let item_type = payload.get("type").and_then(Value::as_str).unwrap_or_default(); + let item_id = codex_string(payload.get("id")).unwrap_or_else(|| format!("{session_id}:{index}")); + match item_type { + "message" => { + let role = payload.get("role").and_then(Value::as_str).unwrap_or_default(); + if role == "user" { + let content = codex_text_blocks(payload.get("content"), true); + if content.is_empty() { continue; } + let text = content.iter().filter_map(|v| v.get("text").and_then(Value::as_str)).collect::>().join("\n"); + first_user_text.get_or_insert(text); + messages.push(serde_json::json!({ "role": "user", "id": format!("{session_id}:{item_id}"), "content": content, "timestamp": timestamp })); + } else if role == "assistant" { + let content = codex_text_blocks(payload.get("content"), false); + if !content.is_empty() { messages.push(codex_assistant(format!("{session_id}:{item_id}"), content, &model, timestamp, "stop")); } + } + } + "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(codex_assistant(format!("{session_id}:{item_id}"), vec![serde_json::json!({ "type": "thinking", "thinking": summary })], &model, timestamp, "stop")); } + } + "function_call" | "custom_tool_call" => { + let call_id = codex_string(payload.get("call_id")).unwrap_or_else(|| item_id.clone()); + let name = codex_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(codex_assistant(format!("{session_id}:{item_id}"), vec![serde_json::json!({ "type": "toolCall", "id": call_id, "name": name, "arguments": arguments })], &model, timestamp, "toolUse")); + } + "function_call_output" | "custom_tool_call_output" => { + let call_id = codex_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()); + let content = codex_output_blocks(payload.get("output")); + messages.push(serde_json::json!({ "role": "toolResult", "toolCallId": call_id, "toolName": tool_name, "content": content, "isError": false, "timestamp": timestamp })); + } + _ => {} + } + } + } + if messages.is_empty() { + return Ok((None, skipped_lines)); + } + let title = titles + .get(&session_id) + .cloned() + .filter(|s| !s.trim().is_empty()) + .or_else(|| first_user_text.map(|s| s.chars().take(80).collect())) + .unwrap_or_else(|| format!("Codex session {session_id}")); + Ok(( + Some(CodexConversation { + id: format!("codex:{session_id}"), + session_id, + title, + model, + cwd: codex_import_cwd(cwd, home), + created_at: created_at.unwrap_or(last_timestamp), + updated_at: last_timestamp, + 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)) = (codex_string(row.get("id")), codex_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 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, &home) { + 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)) +} + +fn import_codex_conversation( + conn: &mut Connection, + conversation: CodexConversation, +) -> 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(|e| format!("序列化 Codex 消息失败:{e}"))?; + let message_count = conversation.messages.len() as i64; + let segment_id = format!("{}:segment:0", conversation.id); + let start_message_id = conversation + .messages + .first() + .and_then(|message| codex_string(message.get("id"))); + let end_message_id = conversation + .messages + .last() + .and_then(|message| codex_string(message.get("id"))); + let input = ChatHistoryUpsertInput { + id: conversation.id.clone(), + title: conversation.title, + provider_id: "codex".to_string(), + model: conversation.model.clone(), + session_id: Some(conversation.session_id), + cwd: conversation.cwd, + selected_model_json: Some( + serde_json::json!({ "customProviderId": "codex", "model": conversation.model }) + .to_string(), + ), + 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, + 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(|e| format!("开启 Codex 导入事务失败:{e}"))?; + 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(|e| format!("提交 Codex 导入事务失败:{e}"))?; + Ok((get_summary_by_id(conn, input.id.trim())?, true)) +} + +#[tauri::command] +pub async fn chat_history_import_codex( + gateway_controller: tauri::State<'_, Arc>, +) -> Result { + let (summaries, scanned_count, skipped_lines) = tauri::async_runtime::spawn_blocking(|| { + let (conversations, scanned_count, skipped_lines) = scan_codex_sessions()?; + let mut conn = open_db()?; + let summaries = conversations + .into_iter() + .map(|conversation| import_codex_conversation(&mut conn, conversation)) + .collect::, _>>()?; + Ok::<_, String>((summaries, scanned_count, skipped_lines)) + }) + .await + .map_err(|e| format!("Codex 导入失败:{e}"))??; + let mut imported_count = 0; + for summary in &summaries { + if summary.1 { + gateway_controller + .publish_history_sync(build_history_sync_upsert(&summary.0)) + .await; + imported_count += 1; + } + } + Ok(CodexImportResult { + scanned_count, + imported_count, + skipped_lines, + }) +} 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..85b3fd768 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,7 @@ include!("share.rs"); include!("search.rs"); include!("commands.rs"); include!("replace.rs"); +include!("codex_import.rs"); include!("branch.rs"); include!("delete.rs"); include!("tests.rs"); diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs index 3833f93f4..3af363f41 100644 --- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs @@ -2577,6 +2577,118 @@ mod tests { assert_eq!(context_meta["systemPrompt"], json!("keep me")); } + #[test] + fn codex_converter_maps_messages_tools_and_title() { + let temp = tempfile::tempdir().expect("create temp dir"); + let path = temp.path().join("rollout-test.jsonl"); + std::fs::write( + &path, + concat!( + r#"{"type":"session_meta","timestamp":"2026-01-01T00:00:00Z","payload":{"id":"session-1","session_id":"session-1","cwd":"/tmp/project"}}"#, + "\n", + r#"{"type":"turn_context","timestamp":"2026-01-01T00:00:01Z","payload":{"model":"gpt-5.4","cwd":"/tmp/project"}}"#, + "\n", + r#"{"type":"response_item","timestamp":"2026-01-01T00:00:02Z","payload":{"type":"message","id":"u1","role":"user","content":[{"type":"input_text","text":"Fix it"}]}}"#, + "\n", + "not-json\n", + r#"{"type":"response_item","timestamp":"2026-01-01T00:00:03Z","payload":{"type":"reasoning","id":"r1","summary":[{"type":"summary_text","text":"Inspect code"}]}}"#, + "\n", + r#"{"type":"response_item","timestamp":"2026-01-01T00:00:04Z","payload":{"type":"function_call","id":"c1","call_id":"call-1","name":"shell","arguments":"{\"command\":\"pwd\"}"}}"#, + "\n", + r#"{"type":"response_item","timestamp":"2026-01-01T00:00:05Z","payload":{"type":"function_call_output","id":"o1","call_id":"call-1","output":"/tmp/project"}}"#, + "\n", + r#"{"type":"response_item","timestamp":"2026-01-01T00:00:06Z","payload":{"type":"message","id":"a1","role":"assistant","content":[{"type":"output_text","text":"Done"}]}}"#, + "\n" + ), + ) + .expect("write rollout"); + let titles = HashMap::from([("session-1".to_string(), "Imported title".to_string())]); + + let (conversation, skipped) = + convert_codex_file(&path, &titles, temp.path()).expect("convert rollout"); + let conversation = conversation.expect("conversation"); + + assert_eq!(skipped, 1); + assert_eq!(conversation.id, "codex:session-1"); + assert_eq!(conversation.title, "Imported title"); + assert_eq!(conversation.model, "gpt-5.4"); + assert_eq!(conversation.cwd.as_deref(), Some("/tmp/project")); + assert_eq!(conversation.messages.len(), 5); + assert_eq!(conversation.messages[0]["role"], json!("user")); + assert_eq!(conversation.messages[1]["content"][0]["type"], json!("thinking")); + assert_eq!(conversation.messages[2]["content"][0]["name"], json!("shell")); + assert_eq!(conversation.messages[3]["toolCallId"], json!("call-1")); + assert_eq!(conversation.messages[4]["content"][0]["text"], json!("Done")); + } + + #[test] + fn codex_temporary_workdir_uses_default_project() { + let home = std::path::Path::new("/Users/tester"); + assert_eq!( + codex_import_cwd(Some("/Users/tester/Documents/Codex".to_string()), home).as_deref(), + Some("/Users/tester/.liveagent/default-project") + ); + assert_eq!( + codex_import_cwd( + Some("/Users/tester/Documents/Codex/session-1".to_string()), + home + ) + .as_deref(), + Some("/Users/tester/.liveagent/default-project") + ); + assert_eq!( + codex_import_cwd(Some("/Users/tester/Projects/app".to_string()), home).as_deref(), + Some("/Users/tester/Projects/app") + ); + } + + #[test] + fn codex_import_skips_an_existing_conversation() { + let mut conn = open_test_db().expect("open test db"); + let conversation = CodexConversation { + id: "codex:session-1".to_string(), + session_id: "session-1".to_string(), + title: "Imported".to_string(), + model: "gpt-5.4".to_string(), + cwd: Some("/tmp/project".to_string()), + created_at: 1_700_000_000_000, + updated_at: 1_700_000_000_100, + messages: vec![json!({ + "role": "user", + "id": "u1", + "content": [{ "type": "text", "text": "Hello" }], + "timestamp": 1_700_000_000_000_i64 + })], + }; + + let (_, inserted) = import_codex_conversation(&mut conn, conversation).expect("first import"); + assert!(inserted); + rename_chat_history_sync(&conn, "codex:session-1", "Keep my title").expect("rename"); + + let duplicate = CodexConversation { + id: "codex:session-1".to_string(), + session_id: "session-1".to_string(), + title: "Overwrite".to_string(), + model: "gpt-5.5".to_string(), + cwd: None, + created_at: 1_700_000_000_000, + updated_at: 1_700_000_000_200, + messages: vec![json!({ + "role": "user", + "id": "u2", + "content": [{ "type": "text", "text": "New" }], + "timestamp": 1_700_000_000_200_i64 + })], + }; + let (summary, inserted) = + import_codex_conversation(&mut conn, duplicate).expect("duplicate import"); + + assert!(!inserted); + assert_eq!(summary.title, "Keep my title"); + assert_eq!(summary.model, "gpt-5.4"); + assert_eq!(summary.message_count, 1); + } + #[test] fn branch_segments_cut_in_later_segment_midway() { // 锚点轮次的助手回复跨过分段边界:更早分段整段复制,切点段裁剪。 diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index 17ee4ded0..47b2be6fc 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -49,6 +49,7 @@ 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::chat_history_import_codex, 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 43da7e7ff..43691d109 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -139,6 +139,10 @@ export const translations: Record> = { "chat.history.deleteBlockedRunning": "后台任务仍在运行,暂时不能删除该对话。", "chat.history.openFailed": "读取历史对话失败", "chat.history.persistFailed": "历史记录保存失败:{message}", + "chat.history.codexImport": "导入 Codex 对话", + "chat.history.codexImporting": "正在导入 Codex 对话…", + "chat.history.codexImportDone": "已导入 {count} 个 Codex 对话", + "chat.history.codexImportFailed": "Codex 对话导入失败", "chat.history.syncing": "正在同步…", "chat.resizeSidebarSections": "调整工作空间与最近对话占比", "chat.emptyChatHistory": "暂无历史对话", @@ -926,6 +930,7 @@ export const translations: Record> = { "settings.navSystem": "系统设置", "settings.navSystemTools": "系统工具", "settings.navProviders": "供应商配置", + "settings.navConversations": "会话管理", "settings.navHooks": "Hooks", "settings.navAgents": "全局提示词", "settings.navSsh": "SSH", @@ -939,6 +944,11 @@ 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.backToChat": "返回对话", "settings.title": "设置", @@ -2406,6 +2416,10 @@ 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.codexImporting": "Importing Codex conversations…", + "chat.history.codexImportDone": "Imported {count} Codex conversations", + "chat.history.codexImportFailed": "Failed to import Codex conversations", "chat.history.syncing": "Syncing…", "chat.resizeSidebarSections": "Resize workspaces and recent conversations", "chat.emptyChatHistory": "No conversation history", @@ -3219,6 +3233,7 @@ export const translations: Record> = { "settings.navSystem": "System", "settings.navSystemTools": "System Tools", "settings.navProviders": "Providers", + "settings.navConversations": "Conversations", "settings.navHooks": "Hooks", "settings.navAgents": "Prompt", "settings.navSsh": "SSH", @@ -3232,6 +3247,14 @@ 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.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..1d37b81ab 100644 --- a/crates/agent-gui/src/lib/chat/history/chatHistory.ts +++ b/crates/agent-gui/src/lib/chat/history/chatHistory.ts @@ -249,6 +249,16 @@ export async function listChatHistory( }); } +export type CodexImportResult = { + scannedCount: number; + importedCount: number; + skippedLines: number; +}; + +export async function importCodexChatHistory() { + return invoke("chat_history_import_codex"); +} + export async function listChatHistoryWorkdirs() { return invoke("chat_history_workdirs"); } diff --git a/crates/agent-gui/src/pages/SettingsPage.tsx b/crates/agent-gui/src/pages/SettingsPage.tsx index 87ca02d4c..b817fa7f9 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: }, ], }, @@ -143,19 +144,23 @@ export function SettingsPage(props: SettingsPageProps) { const { t } = useLocale(); const [section, setSection] = useState(initialSection); - 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( @@ -186,6 +191,8 @@ export function SettingsPage(props: SettingsPageProps) { switch (section) { case "providers": return ; + 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..08df6508d --- /dev/null +++ b/crates/agent-gui/src/pages/settings/ConversationsSection.tsx @@ -0,0 +1,73 @@ +import { useState } from "react"; +import { History, Loader2, Upload } from "../../components/icons"; +import { Button } from "../../components/ui/button"; +import { useLocale } from "../../i18n"; +import { type CodexImportResult, importCodexChatHistory } from "../../lib/chat/history/chatHistory"; + +export function ConversationsSection() { + const { t } = useLocale(); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + async function handleImport() { + setLoading(true); + setResult(null); + setError(null); + try { + setResult(await importCodexChatHistory()); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+ +
+
+

{t("settings.conversationsTitle")}

+

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

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

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

+
+ +
+ + {result ? ( +
+ {t("settings.codexImportResult") + .replace("{imported}", String(result.importedCount)) + .replace("{scanned}", String(result.scannedCount))} +
+ ) : null} + {error ? ( +
+ {t("chat.history.codexImportFailed")}:{error} +
+ ) : null} +
+
+ ); +} diff --git a/crates/agent-gui/src/pages/settings/types.ts b/crates/agent-gui/src/pages/settings/types.ts index 45a2b522b..3a6737f09 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" From 7b1ca215457c070d91805ea5a733bd2ba53b6f1a Mon Sep 17 00:00:00 2001 From: FlowerRealm Date: Mon, 27 Jul 2026 19:58:49 -0700 Subject: [PATCH 02/14] feat(chat-history): selective Codex import dialog Split the one-shot chat_history_import_codex command into a read-only scan (chat_history_scan_codex) returning per-session alreadyImported flags, and an import command that accepts the selected session ids. Add a two-pane CodexImportDialog (workspaces left, sessions right). New sessions are selected by default; already-imported sessions are greyed out and unchecked. Sessions under ~/Documents/Codex already collapse into the default-project workspace via codex_import_cwd. --- .../history/chat_history/codex_import.rs | 61 +++- .../src/commands/history/chat_history/mod.rs | 2 +- crates/agent-gui/src-tauri/src/lib.rs | 1 + crates/agent-gui/src/i18n/config.ts | 23 ++ .../src/lib/chat/history/chatHistory.ts | 26 +- .../src/pages/settings/CodexImportDialog.tsx | 312 ++++++++++++++++++ .../pages/settings/ConversationsSection.tsx | 54 ++- 7 files changed, 466 insertions(+), 13 deletions(-) create mode 100644 crates/agent-gui/src/pages/settings/CodexImportDialog.tsx 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 index 1d46762b0..984fd0e53 100644 --- 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 @@ -6,6 +6,28 @@ pub struct CodexImportResult { pub skipped_lines: usize, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexImportSession { + pub id: String, + pub session_id: String, + pub title: String, + pub model: String, + pub cwd: Option, + pub created_at: i64, + pub updated_at: i64, + pub message_count: usize, + pub already_imported: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexImportPreview { + pub sessions: Vec, + pub scanned_count: usize, + pub skipped_lines: usize, +} + struct CodexConversation { id: String, session_id: String, @@ -352,15 +374,52 @@ fn import_codex_conversation( Ok((get_summary_by_id(conn, input.id.trim())?, true)) } +#[tauri::command] +pub async fn chat_history_scan_codex() -> Result { + let (sessions, scanned_count, skipped_lines) = + tauri::async_runtime::spawn_blocking(|| -> Result<_, String> { + let (conversations, scanned_count, skipped_lines) = scan_codex_sessions()?; + let conn = open_db()?; + let sessions = conversations + .into_iter() + .map(|conversation| { + let already_imported = get_summary_by_id(&conn, &conversation.id).is_ok(); + CodexImportSession { + id: conversation.id, + session_id: conversation.session_id, + title: conversation.title, + model: conversation.model, + cwd: conversation.cwd, + created_at: conversation.created_at, + updated_at: conversation.updated_at, + message_count: conversation.messages.len(), + already_imported, + } + }) + .collect(); + Ok::<_, String>((sessions, scanned_count, skipped_lines)) + }) + .await + .map_err(|e| format!("Codex 扫描失败:{e}"))??; + Ok(CodexImportPreview { + sessions, + scanned_count, + skipped_lines, + }) +} + #[tauri::command] pub async fn chat_history_import_codex( gateway_controller: tauri::State<'_, Arc>, + ids: Vec, ) -> Result { - let (summaries, scanned_count, skipped_lines) = tauri::async_runtime::spawn_blocking(|| { + let selected: HashSet = ids.into_iter().filter(|id| !id.trim().is_empty()).collect(); + let (summaries, scanned_count, skipped_lines) = tauri::async_runtime::spawn_blocking(move || { let (conversations, scanned_count, skipped_lines) = scan_codex_sessions()?; let mut conn = open_db()?; let summaries = conversations .into_iter() + .filter(|conversation| selected.contains(&conversation.id)) .map(|conversation| import_codex_conversation(&mut conn, conversation)) .collect::, _>>()?; Ok::<_, String>((summaries, scanned_count, skipped_lines)) 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 85b3fd768..71a0d26e2 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 @@ -4,7 +4,7 @@ use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, }; diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index 47b2be6fc..14c4a241a 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -49,6 +49,7 @@ 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::chat_history_scan_codex, commands::chat_history::chat_history_import_codex, commands::chat_history::chat_history_rename, commands::chat_history::chat_history_branch, diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 43691d109..b6d8a2389 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -143,6 +143,17 @@ export const translations: Record> = { "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": "暂无历史对话", @@ -2420,6 +2431,18 @@ export const translations: Record> = { "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", diff --git a/crates/agent-gui/src/lib/chat/history/chatHistory.ts b/crates/agent-gui/src/lib/chat/history/chatHistory.ts index 1d37b81ab..7fa61de89 100644 --- a/crates/agent-gui/src/lib/chat/history/chatHistory.ts +++ b/crates/agent-gui/src/lib/chat/history/chatHistory.ts @@ -255,8 +255,30 @@ export type CodexImportResult = { skippedLines: number; }; -export async function importCodexChatHistory() { - return invoke("chat_history_import_codex"); +export type CodexImportPreviewSession = { + id: string; + sessionId: string; + title: string; + model: string; + cwd?: string; + createdAt: number; + updatedAt: number; + messageCount: number; + alreadyImported: boolean; +}; + +export type CodexImportPreview = { + sessions: CodexImportPreviewSession[]; + scannedCount: number; + skippedLines: number; +}; + +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() { diff --git a/crates/agent-gui/src/pages/settings/CodexImportDialog.tsx b/crates/agent-gui/src/pages/settings/CodexImportDialog.tsx new file mode 100644 index 000000000..6418c877a --- /dev/null +++ b/crates/agent-gui/src/pages/settings/CodexImportDialog.tsx @@ -0,0 +1,312 @@ +import { 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 { cn } from "../../lib/shared/utils"; +import { useModalMotion } from "../../lib/shared/modalMotion"; +import type { CodexImportPreview, CodexImportPreviewSession } from "../../lib/chat/history/chatHistory"; + +type CodexImportDialogProps = { + preview: CodexImportPreview; + onClose: () => void; + onConfirm: (ids: string[]) => void; +}; + +const NO_CWD_KEY = "__liveagent_no_cwd__"; + +function workspaceLabel(cwd: string): string { + if (cwd === NO_CWD_KEY) return ""; + // 显示路径末段,title 上带完整路径 + const segments = cwd.split(/[\\/]/).filter(Boolean); + return segments[segments.length - 1] ?? cwd; +} + +export function CodexImportDialog({ preview, onClose, onConfirm }: CodexImportDialogProps) { + const { t } = useLocale(); + const { modalState, requestClose } = useModalMotion(onClose); + + const groups = useMemo(() => { + const map = new Map(); + for (const session of preview.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]); + } + // 按会话数倒序,default-project 置顶 + return Array.from(map.entries()).sort((a, b) => { + const aIsDefault = a[0].includes("default-project"); + const bIsDefault = b[0].includes("default-project"); + if (aIsDefault !== bIsDefault) return aIsDefault ? -1 : 1; + return b[1].length - a[1].length; + }); + }, [preview.sessions]); + + const [activeCwdKey, setActiveCwdKey] = useState(groups[0]?.[0] ?? null); + + const [selected, setSelected] = useState>(() => { + const set = new Set(); + for (const session of preview.sessions) { + if (!session.alreadyImported) set.add(session.id); + } + return set; + }); + + const activeSessions = useMemo( + () => groups.find(([key]) => key === activeCwdKey)?.[1] ?? [], + [groups, activeCwdKey], + ); + + const groupSelectedCount = (key: string) => { + const list = groups.find(([k]) => k === key)?.[1] ?? []; + return list.filter((s) => selected.has(s.id)).length; + }; + + function toggleSession(session: CodexImportPreviewSession) { + 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; + }); + } + + function toggleGroup(key: string) { + const list = groups.find(([k]) => k === key)?.[1] ?? []; + const importable = list.filter((s) => !s.alreadyImported); + if (importable.length === 0) return; + const allSelected = importable.every((s) => selected.has(s.id)); + setSelected((prev) => { + const next = new Set(prev); + if (allSelected) importable.forEach((s) => next.delete(s.id)); + else importable.forEach((s) => next.add(s.id)); + return next; + }); + } + + const activeSessionsAllSelected = + activeSessions.length > 0 && + activeSessions.filter((s) => !s.alreadyImported).every((s) => selected.has(s.id)); + + return createPortal( +
+
+
+
+
+ +
+
+

{t("chat.history.codexImportDialogTitle")}

+

+ {t("chat.history.codexImportDialogSubtitle")} +

+
+ +
+ +
+ {/* 左栏:工作区 */} +
+
+ {t("chat.history.codexImportDialogWorkspaces")} + +
+
+ {groups.length === 0 ? ( +

+ {t("chat.history.codexImportDialogEmpty")} +

+ ) : ( + groups.map(([key, list]) => { + const isActive = key === activeCwdKey; + const checked = groupSelectedCount(key); + const importable = list.filter((s) => !s.alreadyImported).length; + const allSelected = importable > 0 && checked === importable; + return ( + + ); + }) + )} +
+
+ + {/* 右栏:会话 */} +
+
+ + {activeCwdKey === NO_CWD_KEY + ? t("chat.history.codexImportDialogNoWorkspace") + : activeCwdKey ?? ""} + + {activeSessions.length > 0 ? ( + + ) : null} +
+
+ {activeSessions.length === 0 ? ( +

+ {t("chat.history.codexImportDialogEmpty")} +

+ ) : ( + activeSessions.map((session) => { + const checked = selected.has(session.id); + return ( + + ); + }) + )} +
+
+
+ +
+ + {t("chat.history.codexImportDialogSelected").replace("{count}", String(selected.size))} + +
+ + +
+
+
+
, + document.body, + ); +} \ No newline at end of file diff --git a/crates/agent-gui/src/pages/settings/ConversationsSection.tsx b/crates/agent-gui/src/pages/settings/ConversationsSection.tsx index 08df6508d..aeefdbe13 100644 --- a/crates/agent-gui/src/pages/settings/ConversationsSection.tsx +++ b/crates/agent-gui/src/pages/settings/ConversationsSection.tsx @@ -2,24 +2,50 @@ import { useState } from "react"; import { History, Loader2, Upload } from "../../components/icons"; import { Button } from "../../components/ui/button"; import { useLocale } from "../../i18n"; -import { type CodexImportResult, importCodexChatHistory } from "../../lib/chat/history/chatHistory"; +import { + type CodexImportPreview, + type CodexImportResult, + importCodexChatHistory, + scanCodexChatHistory, +} from "../../lib/chat/history/chatHistory"; +import { CodexImportDialog } from "./CodexImportDialog"; export function ConversationsSection() { const { t } = useLocale(); - const [loading, setLoading] = useState(false); + const [scanning, setScanning] = useState(false); + const [importing, setImporting] = useState(false); + const [dialog, setDialog] = useState(null); const [result, setResult] = useState(null); const [error, setError] = useState(null); - async function handleImport() { - setLoading(true); + async function handleScan() { + setScanning(true); setResult(null); setError(null); try { - setResult(await importCodexChatHistory()); + const preview = await scanCodexChatHistory(); + if (preview.sessions.length === 0) { + setError(t("chat.history.codexImportDialogEmpty")); + return; + } + setDialog(preview); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } finally { - setLoading(false); + setScanning(false); + } + } + + async function handleConfirm(ids: string[]) { + setDialog(null); + setImporting(true); + setError(null); + try { + setResult(await importCodexChatHistory(ids)); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setImporting(false); } } @@ -45,13 +71,15 @@ export function ConversationsSection() { {t("settings.codexImportDescription")}

- @@ -68,6 +96,14 @@ export function ConversationsSection() { ) : null} + + {dialog ? ( + setDialog(null)} + onConfirm={(ids) => void handleConfirm(ids)} + /> + ) : null} ); } From 87c13b800c396e87c21670bfbf330bf459554951 Mon Sep 17 00:00:00 2001 From: FlowerRealm Date: Mon, 27 Jul 2026 21:37:11 -0700 Subject: [PATCH 03/14] =?UTF-8?q?refactor(codex):=20eliminate=20CodexImpor?= =?UTF-8?q?tSession=20=E2=80=94=20use=20single=20CodexConversation=20struc?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../history/chat_history/codex_import.rs | 45 +++++++------------ 1 file changed, 15 insertions(+), 30 deletions(-) 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 index 984fd0e53..2b7c22506 100644 --- 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 @@ -6,29 +6,17 @@ pub struct CodexImportResult { pub skipped_lines: usize, } -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CodexImportSession { - pub id: String, - pub session_id: String, - pub title: String, - pub model: String, - pub cwd: Option, - pub created_at: i64, - pub updated_at: i64, - pub message_count: usize, - pub already_imported: bool, -} - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct CodexImportPreview { - pub sessions: Vec, + pub sessions: Vec, pub scanned_count: usize, pub skipped_lines: usize, } -struct CodexConversation { +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CodexConversation { id: String, session_id: String, title: String, @@ -36,7 +24,10 @@ struct CodexConversation { cwd: Option, created_at: i64, updated_at: i64, + #[serde(skip)] messages: Vec, + message_count: usize, + already_imported: bool, } fn codex_string(value: Option<&Value>) -> Option { @@ -248,6 +239,7 @@ fn convert_codex_file( .filter(|s| !s.trim().is_empty()) .or_else(|| first_user_text.map(|s| s.chars().take(80).collect())) .unwrap_or_else(|| format!("Codex session {session_id}")); + let message_count = messages.len(); Ok(( Some(CodexConversation { id: format!("codex:{session_id}"), @@ -258,6 +250,8 @@ fn convert_codex_file( created_at: created_at.unwrap_or(last_timestamp), updated_at: last_timestamp, messages, + message_count, + already_imported: false, }), skipped_lines, )) @@ -309,7 +303,7 @@ fn import_codex_conversation( } let messages_json = serde_json::to_string(&conversation.messages) .map_err(|e| format!("序列化 Codex 消息失败:{e}"))?; - let message_count = conversation.messages.len() as i64; + let message_count = conversation.message_count as i64; let segment_id = format!("{}:segment:0", conversation.id); let start_message_id = conversation .messages @@ -382,19 +376,10 @@ pub async fn chat_history_scan_codex() -> Result { let conn = open_db()?; let sessions = conversations .into_iter() - .map(|conversation| { - let already_imported = get_summary_by_id(&conn, &conversation.id).is_ok(); - CodexImportSession { - id: conversation.id, - session_id: conversation.session_id, - title: conversation.title, - model: conversation.model, - cwd: conversation.cwd, - created_at: conversation.created_at, - updated_at: conversation.updated_at, - message_count: conversation.messages.len(), - already_imported, - } + .map(|mut conversation| { + conversation.already_imported = + get_summary_by_id(&conn, &conversation.id).is_ok(); + conversation }) .collect(); Ok::<_, String>((sessions, scanned_count, skipped_lines)) From 79c9b2472925bbc64e08bc9d8fd9c93f17ceb422 Mon Sep 17 00:00:00 2001 From: FlowerRealm Date: Mon, 27 Jul 2026 23:12:49 -0700 Subject: [PATCH 04/14] =?UTF-8?q?refactor(codex):=20remove=20codex=5Fimpor?= =?UTF-8?q?t=5Fcwd=20=E2=80=94=20cwd=20passthrough=20as-is?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ~/Documents/Codex remapping was platform-dependent and fragile. Sessions are already read from the stable ~/.codex/sessions path; the cwd field inside each session now passes through unmodified. --- .../history/chat_history/codex_import.rs | 20 ++----------------- 1 file changed, 2 insertions(+), 18 deletions(-) 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 index 2b7c22506..0bf84f47c 100644 --- 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 @@ -122,25 +122,9 @@ fn codex_session_id(rows: &[(String, Value)]) -> Option { .and_then(|payload| codex_string(payload.get("session_id")).or_else(|| codex_string(payload.get("id")))) } -fn codex_import_cwd(cwd: Option, home: &std::path::Path) -> Option { - let cwd = cwd?; - let temporary_root = home.join("Documents").join("Codex"); - let path = std::path::Path::new(&cwd); - if path == temporary_root || path.starts_with(&temporary_root) { - return Some( - home.join(format!(".{}", env!("CARGO_PKG_NAME"))) - .join("default-project") - .to_string_lossy() - .into_owned(), - ); - } - Some(cwd) -} - fn convert_codex_file( path: &std::path::Path, titles: &HashMap, - home: &std::path::Path, ) -> Result<(Option, usize), String> { let text = std::fs::read_to_string(path).map_err(|e| format!("读取 Codex 会话失败:{}: {e}", path.display()))?; let mut rows = Vec::new(); @@ -246,7 +230,7 @@ fn convert_codex_file( session_id, title, model, - cwd: codex_import_cwd(cwd, home), + cwd, created_at: created_at.unwrap_or(last_timestamp), updated_at: last_timestamp, messages, @@ -281,7 +265,7 @@ fn scan_codex_sessions() -> Result<(Vec, usize, usize), Strin 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, &home) { + match convert_codex_file(path, &titles) { Ok((Some(conversation), skipped_lines)) => { skipped += skipped_lines; conversations.push(conversation); From bf0e15e9624ca3a298247f674f33de56d4a1d78a Mon Sep 17 00:00:00 2001 From: FlowerRealm Date: Tue, 28 Jul 2026 00:55:21 -0700 Subject: [PATCH 05/14] fix(gui): resolve biome lint errors in CodexImportDialog Replace forEach callbacks returning values with for...of loops to fix useIterableCallbackReturn errors. Biome auto-formatting. --- crates/agent-gui/src/i18n/config.ts | 3 +- .../src/pages/settings/CodexImportDialog.tsx | 31 ++++++++++++------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index b6d8a2389..fe3d77847 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -144,7 +144,8 @@ export const translations: Record> = { "chat.history.codexImportDone": "已导入 {count} 个 Codex 对话", "chat.history.codexImportFailed": "Codex 对话导入失败", "chat.history.codexImportDialogTitle": "导入 Codex 对话", - "chat.history.codexImportDialogSubtitle": "勾选要导入的工作区与会话,临时会话已合并到 default 工作区。", + "chat.history.codexImportDialogSubtitle": + "勾选要导入的工作区与会话,临时会话已合并到 default 工作区。", "chat.history.codexImportDialogWorkspaces": "工作区", "chat.history.codexImportDialogNoWorkspace": "无工作区", "chat.history.codexImportDialogAll": "全选", diff --git a/crates/agent-gui/src/pages/settings/CodexImportDialog.tsx b/crates/agent-gui/src/pages/settings/CodexImportDialog.tsx index 6418c877a..50e306c9c 100644 --- a/crates/agent-gui/src/pages/settings/CodexImportDialog.tsx +++ b/crates/agent-gui/src/pages/settings/CodexImportDialog.tsx @@ -3,9 +3,12 @@ import { createPortal } from "react-dom"; import { Check, Folder, MessageSquareText, X } from "../../components/icons"; import { Button } from "../../components/ui/button"; import { useLocale } from "../../i18n"; -import { cn } from "../../lib/shared/utils"; +import type { + CodexImportPreview, + CodexImportPreviewSession, +} from "../../lib/chat/history/chatHistory"; import { useModalMotion } from "../../lib/shared/modalMotion"; -import type { CodexImportPreview, CodexImportPreviewSession } from "../../lib/chat/history/chatHistory"; +import { cn } from "../../lib/shared/utils"; type CodexImportDialogProps = { preview: CodexImportPreview; @@ -80,8 +83,11 @@ export function CodexImportDialog({ preview, onClose, onConfirm }: CodexImportDi const allSelected = importable.every((s) => selected.has(s.id)); setSelected((prev) => { const next = new Set(prev); - if (allSelected) importable.forEach((s) => next.delete(s.id)); - else importable.forEach((s) => next.add(s.id)); + if (allSelected) { + for (const s of importable) next.delete(s.id); + } else { + for (const s of importable) next.add(s.id); + } return next; }); } @@ -199,7 +205,10 @@ export function CodexImportDialog({ preview, onClose, onConfirm }: CodexImportDi : workspaceLabel(key)} {key !== NO_CWD_KEY ? ( - + {key} ) : null} @@ -220,7 +229,7 @@ export function CodexImportDialog({ preview, onClose, onConfirm }: CodexImportDi {activeCwdKey === NO_CWD_KEY ? t("chat.history.codexImportDialogNoWorkspace") - : activeCwdKey ?? ""} + : (activeCwdKey ?? "")} {activeSessions.length > 0 ? ( @@ -309,4 +316,4 @@ export function CodexImportDialog({ preview, onClose, onConfirm }: CodexImportDi , document.body, ); -} \ No newline at end of file +} From d5c2b0c9f345f9d13405cd70c4ee7634715ba80a Mon Sep 17 00:00:00 2001 From: FlowerRealm Date: Tue, 28 Jul 2026 18:50:29 -0700 Subject: [PATCH 06/14] fix(codex): remap temporary session directories Map Codex temporary Documents directories to LiveAgent's default project so imported conversations do not retain invalid working directories. --- .../history/chat_history/codex_import.rs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) 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 index 0bf84f47c..e428567ed 100644 --- 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 @@ -122,9 +122,26 @@ fn codex_session_id(rows: &[(String, Value)]) -> Option { .and_then(|payload| codex_string(payload.get("session_id")).or_else(|| codex_string(payload.get("id")))) } +fn codex_import_cwd(cwd: Option, home: &std::path::Path) -> Option { + let cwd = cwd?; + let codex_temp = dirs::document_dir() + .unwrap_or_else(|| home.join("Documents")) + .join("Codex"); + if std::path::Path::new(&cwd).starts_with(&codex_temp) { + return Some( + home.join(format!(".{}", env!("CARGO_PKG_NAME"))) + .join("default-project") + .to_string_lossy() + .into_owned(), + ); + } + Some(cwd) +} + fn convert_codex_file( path: &std::path::Path, titles: &HashMap, + home: &std::path::Path, ) -> Result<(Option, usize), String> { let text = std::fs::read_to_string(path).map_err(|e| format!("读取 Codex 会话失败:{}: {e}", path.display()))?; let mut rows = Vec::new(); @@ -230,7 +247,7 @@ fn convert_codex_file( session_id, title, model, - cwd, + cwd: codex_import_cwd(cwd, home), created_at: created_at.unwrap_or(last_timestamp), updated_at: last_timestamp, messages, @@ -265,7 +282,7 @@ fn scan_codex_sessions() -> Result<(Vec, usize, usize), Strin 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) { + match convert_codex_file(path, &titles, &home) { Ok((Some(conversation), skipped_lines)) => { skipped += skipped_lines; conversations.push(conversation); From 6522d3884affa78fc54f48c9952c895fa6c9667f Mon Sep 17 00:00:00 2001 From: FlowerRealm Date: Wed, 29 Jul 2026 16:17:48 -0700 Subject: [PATCH 07/14] feat(claude): import Code and official conversations --- Cargo.lock | 203 +++++++- .../src/commands/config/settings/db.rs | 2 +- .../chat_history/claude_code_import.rs | 451 ++++++++++++++++++ .../chat_history/claude_official_import.rs | 430 +++++++++++++++++ .../src/commands/history/chat_history/mod.rs | 2 + crates/agent-gui/src-tauri/src/lib.rs | 4 + crates/agent-gui/src/i18n/config.ts | 75 +++ .../src/lib/chat/history/chatHistory.ts | 55 +++ .../pages/settings/ClaudeCodeImportDialog.tsx | 355 ++++++++++++++ .../pages/settings/ConversationsSection.tsx | 197 ++++++-- 10 files changed, 1716 insertions(+), 58 deletions(-) create mode 100644 crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_code_import.rs create mode 100644 crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_official_import.rs create mode 100644 crates/agent-gui/src/pages/settings/ClaudeCodeImportDialog.tsx diff --git a/Cargo.lock b/Cargo.lock index b9583405e..3bb8b3ead 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -174,7 +174,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -205,7 +205,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -231,7 +231,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -400,6 +400,29 @@ dependencies = [ "sha2 0.11.0", ] +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags 2.13.0", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.118", + "which", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -666,7 +689,7 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -675,6 +698,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfb" version = "0.7.3" @@ -774,6 +806,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -823,6 +866,15 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -2100,7 +2152,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix", + "rustix 1.1.4", "windows-link 0.2.1", ] @@ -2469,6 +2521,15 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -2877,6 +2938,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -3131,6 +3201,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "leveldb-core" version = "0.1.0" @@ -3221,6 +3297,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3324,7 +3406,7 @@ dependencies = [ "jiff", "log", "md-5", - "nom", + "nom 8.0.0", "rand 0.10.2", "rangemap", "rayon", @@ -3396,6 +3478,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "minisign-verify" version = "0.2.5" @@ -3540,6 +3628,16 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -4476,7 +4574,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -4690,7 +4788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck 0.5.0", - "itertools", + "itertools 0.14.0", "log", "multimap", "petgraph", @@ -4709,7 +4807,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.118", @@ -4759,7 +4857,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "socket2", "thiserror 2.0.18", @@ -4781,7 +4879,7 @@ dependencies = [ "rand 0.10.2", "rand_pcg", "ring", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "slab", @@ -4982,6 +5080,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "reqwest" version = "0.13.4" @@ -5084,6 +5188,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d16661bff09e9ed8e01094a188b463de45ec0693ade55b92ed54027d7ba7c40c" dependencies = [ "rquickjs-core", + "rquickjs-macro", ] [[package]] @@ -5092,15 +5197,34 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c8db6379e204ef84c0811e90e7cc3e3e4d7688701db68a00d14a6db6849087b" dependencies = [ + "relative-path", "rquickjs-sys", ] +[[package]] +name = "rquickjs-macro" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6041104330c019fcd936026ae05e2446f5e8a2abef329d924f25424b7052a2f3" +dependencies = [ + "convert_case", + "fnv", + "ident_case", + "indexmap 2.14.0", + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "rquickjs-core", + "syn 2.0.118", +] + [[package]] name = "rquickjs-sys" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4bc352c6b663604c3c186c000cfcc6c271f4b50bc135a285dd6d4f2a42f9790a" dependencies = [ + "bindgen", "cc", ] @@ -5264,6 +5388,12 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.3" @@ -5279,6 +5409,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -5288,7 +5431,7 @@ dependencies = [ "bitflags 2.13.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -5534,7 +5677,7 @@ dependencies = [ "phf 0.13.1", "phf_codegen", "precomputed-hash", - "rustc-hash", + "rustc-hash 2.1.3", "servo_arc", "smallvec", ] @@ -5831,6 +5974,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -6623,7 +6772,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -7065,7 +7214,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" dependencies = [ "memchr", - "nom", + "nom 8.0.0", "petgraph", ] @@ -7447,7 +7596,7 @@ checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", "downcast-rs", - "rustix", + "rustix 1.1.4", "scoped-tls", "smallvec", "wayland-sys", @@ -7460,7 +7609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ "bitflags 2.13.0", - "rustix", + "rustix 1.1.4", "wayland-backend", "wayland-scanner", ] @@ -7657,6 +7806,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2" +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "winapi" version = "0.3.9" @@ -8211,7 +8372,7 @@ dependencies = [ "libc", "log", "os_pipe", - "rustix", + "rustix 1.1.4", "thiserror 2.0.18", "tree_magic_mini", "wayland-backend", @@ -8309,7 +8470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix", + "rustix 1.1.4", "x11rb-protocol", ] @@ -8326,7 +8487,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -8380,7 +8541,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix", + "rustix 1.1.4", "serde", "serde_repr", "tracing", 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..1ba05be25 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_code_import.rs @@ -0,0 +1,451 @@ +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeCodeImportResult { + pub scanned_count: usize, + pub imported_count: usize, + pub skipped_lines: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeCodeImportPreview { + pub sessions: Vec, + pub scanned_count: usize, + pub skipped_lines: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ClaudeCodeConversation { + id: String, + session_id: String, + title: String, + model: String, + cwd: Option, + created_at: i64, + updated_at: i64, + #[serde(skip)] + messages: Vec, + message_count: usize, + already_imported: bool, +} + +fn claude_code_string(value: Option<&Value>) -> Option { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn claude_code_timestamp(value: Option<&str>) -> Option { + value.and_then(|value| { + chrono::DateTime::parse_from_rfc3339(value) + .ok() + .map(|date| date.timestamp_millis()) + }) +} + +fn claude_code_text_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) if object.get("type").and_then(Value::as_str) == Some("text") => { + 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 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_assistant( + id: String, + content: Vec, + model: &str, + timestamp: i64, + stop_reason: &str, +) -> Value { + serde_json::json!({ + "role": "assistant", + "id": id, + "content": content, + "api": "anthropic-messages", + "provider": "claude_code", + "model": model, + "usage": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0 }, + "stopReason": stop_reason, + "timestamp": timestamp + }) +} + +fn claude_code_tool_result_blocks(value: Option<&Value>) -> Vec { + claude_code_text_blocks(value) +} + +fn claude_code_session_id(rows: &[(String, Value)], path: &std::path::Path) -> Option { + rows.iter() + .find_map(|(_, row)| { + claude_code_string(row.get("sessionId")) + .or_else(|| claude_code_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 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((claude_code_string(row.get("timestamp")).unwrap_or_default(), row)); + } + _ => skipped_lines += 1, + } + } + 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(|| claude_code_string(row.get("cwd"))); + created_at = created_at.or_else(|| claude_code_timestamp(Some(timestamp))); + match row.get("type").and_then(Value::as_str) { + Some("assistant") => { + model = model.or_else(|| { + row.get("message") + .and_then(|message| claude_code_string(message.get("model"))) + }); + } + Some("ai-title") => title = claude_code_string(row.get("aiTitle")).or(title), + _ => {} + } + } + let model = model.unwrap_or_else(|| "claude_code".to_string()); + let mut messages = Vec::new(); + let mut tool_names = HashMap::new(); + let mut first_user_text = None; + 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 = claude_code_timestamp(Some(timestamp)).unwrap_or(last_timestamp); + last_timestamp = timestamp; + let entry_id = claude_code_string(row.get("uuid")) + .unwrap_or_else(|| format!("{session_id}:{index}")); + match row.get("type").and_then(Value::as_str) { + Some("user") => { + 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) { + continue; + } + if text.trim().is_empty() { + continue; + } + first_user_text.get_or_insert_with(|| text.to_string()); + messages.push(serde_json::json!({ + "role": "user", + "id": format!("{session_id}:{entry_id}"), + "content": [{ "type": "text", "text": text }], + "timestamp": timestamp + })); + continue; + } + + let Some(blocks) = content.and_then(Value::as_array) else { continue }; + let text_blocks = claude_code_text_blocks(content); + if !text_blocks.is_empty() { + let text = text_blocks + .iter() + .filter_map(|block| block.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"); + first_user_text.get_or_insert(text); + messages.push(serde_json::json!({ + "role": "user", + "id": format!("{session_id}:{entry_id}"), + "content": text_blocks, + "timestamp": timestamp + })); + } + for block in blocks { + if block.get("type").and_then(Value::as_str) != Some("tool_result") { + continue; + } + let tool_call_id = claude_code_string(block.get("tool_use_id")) + .unwrap_or_else(|| format!("{session_id}:{entry_id}")); + let tool_name = tool_names + .get(&tool_call_id) + .cloned() + .unwrap_or_else(|| "claude_code_tool".to_string()); + messages.push(serde_json::json!({ + "role": "toolResult", + "toolCallId": tool_call_id, + "toolName": tool_name, + "content": claude_code_tool_result_blocks(block.get("content")), + "isError": block.get("is_error").and_then(Value::as_bool).unwrap_or(false), + "timestamp": timestamp + })); + } + } + Some("assistant") => { + let Some(message) = row.get("message") else { continue }; + let message_model = claude_code_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!("{session_id}:{entry_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(claude_code_assistant( + id, + vec![serde_json::json!({ "type": "text", "text": text })], + &message_model, + timestamp, + stop_reason, + )); + } + } + Some("thinking") => { + if let Some(thinking) = block.get("thinking").and_then(Value::as_str).filter(|text| !text.is_empty()) { + messages.push(claude_code_assistant( + id, + vec![serde_json::json!({ "type": "thinking", "thinking": thinking })], + &message_model, + timestamp, + "stop", + )); + } + } + Some("tool_use") => { + let tool_call_id = claude_code_string(block.get("id")).unwrap_or_else(|| id.clone()); + let tool_name = claude_code_string(block.get("name")).unwrap_or_else(|| "claude_code_tool".to_string()); + tool_names.insert(tool_call_id.clone(), tool_name.clone()); + messages.push(claude_code_assistant( + id, + vec![serde_json::json!({ + "type": "toolCall", + "id": tool_call_id, + "name": tool_name, + "arguments": block.get("input").cloned().unwrap_or_else(|| serde_json::json!({})) + })], + &message_model, + timestamp, + "toolUse", + )); + } + _ => {} + } + } + } + _ => {} + } + } + if messages.is_empty() { + return Ok((None, skipped_lines)); + } + 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!("Claude Code session {session_id}")); + let message_count = messages.len(); + Ok(( + Some(ClaudeCodeConversation { + id: format!("claude-code:{session_id}"), + session_id, + title, + model, + // Desktop Chat/Cowork has no public Claude Code transcript format. For a + // transcript that genuinely lacks cwd, preserve None so the UI groups it as + // no workspace instead of inventing a project directory. + cwd, + created_at: created_at.unwrap_or(last_timestamp), + updated_at: last_timestamp, + messages, + message_count, + already_imported: false, + }), + 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)) +} + +fn import_claude_code_conversation( + conn: &mut Connection, + conversation: ClaudeCodeConversation, +) -> 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!("序列化 Claude Code 消息失败:{error}"))?; + 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| claude_code_string(message.get("id"))); + let end_message_id = conversation + .messages + .last() + .and_then(|message| claude_code_string(message.get("id"))); + let input = ChatHistoryUpsertInput { + id: conversation.id.clone(), + title: conversation.title, + provider_id: "claude_code".to_string(), + model: conversation.model.clone(), + session_id: Some(conversation.session_id), + cwd: conversation.cwd, + selected_model_json: Some( + serde_json::json!({ "customProviderId": "builtin-claude_code", "model": conversation.model }).to_string(), + ), + 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, + 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!("开启 Claude Code 导入事务失败:{error}"))?; + 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!("提交 Claude Code 导入事务失败:{error}"))?; + Ok((get_summary_by_id(conn, input.id.trim())?, true)) +} + +#[tauri::command] +pub async fn chat_history_scan_claude_code() -> Result { + let (sessions, scanned_count, skipped_lines) = tauri::async_runtime::spawn_blocking(|| -> Result<_, String> { + let (conversations, scanned_count, skipped_lines) = scan_claude_code_sessions()?; + 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!("Claude Code 扫描失败:{error}"))??; + Ok(ClaudeCodeImportPreview { sessions, scanned_count, skipped_lines }) +} + +#[tauri::command] +pub async fn chat_history_import_claude_code( + gateway_controller: tauri::State<'_, Arc>, + ids: Vec, +) -> Result { + let selected: HashSet = ids.into_iter().filter(|id| !id.trim().is_empty()).collect(); + let (summaries, scanned_count, skipped_lines) = tauri::async_runtime::spawn_blocking(move || { + let (conversations, scanned_count, skipped_lines) = scan_claude_code_sessions()?; + let mut conn = open_db()?; + let summaries = conversations + .into_iter() + .filter(|conversation| selected.contains(&conversation.id)) + .map(|conversation| import_claude_code_conversation(&mut conn, conversation)) + .collect::, _>>()?; + Ok::<_, String>((summaries, scanned_count, skipped_lines)) + }) + .await + .map_err(|error| format!("Claude Code 导入失败:{error}"))??; + let mut imported_count = 0; + for summary in &summaries { + if summary.1 { + gateway_controller.publish_history_sync(build_history_sync_upsert(&summary.0)).await; + imported_count += 1; + } + } + Ok(ClaudeCodeImportResult { scanned_count, imported_count, skipped_lines }) +} diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_official_import.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_official_import.rs new file mode 100644 index 000000000..ad0fd14f4 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_official_import.rs @@ -0,0 +1,430 @@ +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeOfficialImportResult { + pub scanned_count: usize, + pub imported_count: usize, + pub skipped_lines: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeOfficialImportPreview { + pub sessions: Vec, + pub scanned_count: usize, + pub skipped_lines: usize, +} + +const CLAUDE_OFFICIAL_CONVERSATIONS_ENTRY: &str = "conversations.json"; + +fn claude_official_default_cwd() -> Result { + crate::commands::settings::default_project_workdir() +} + +fn claude_official_workspace_cwd(conversation: &Value, default_cwd: &str) -> String { + for key in ["cwd", "source_cwd", "workspace_path", "workspacePath"] { + let Some(cwd) = claude_code_string(conversation.get(key)) else { continue }; + let path = std::path::Path::new(&cwd); + if path.is_absolute() + && !path.starts_with("/home/claude") + && !path.starts_with("/mnt/user-data") + { + return cwd; + } + } + default_cwd.to_string() +} + +fn read_claude_official_conversations(path: &std::path::Path) -> Result, String> { + if path.extension().and_then(|extension| extension.to_str()) != Some("zip") { + return Err("请选择 Claude 官方数据 ZIP 文件".to_string()); + } + + let file = std::fs::File::open(path) + .map_err(|error| format!("读取 Claude 官方数据文件失败:{}: {error}", path.display()))?; + let mut archive = zip::ZipArchive::new(file) + .map_err(|error| format!("无法打开 Claude 官方数据 ZIP:{error}"))?; + let entry_index = (0..archive.len()) + .find(|index| { + archive + .by_index(*index) + .ok() + .is_some_and(|entry| entry.name() == CLAUDE_OFFICIAL_CONVERSATIONS_ENTRY) + }) + .ok_or_else(|| "此 ZIP 不包含 Claude 官方数据的 conversations.json".to_string())?; + let mut entry = archive + .by_index(entry_index) + .map_err(|error| format!("读取 conversations.json 失败:{error}"))?; + + use std::io::Read; + let mut text = String::new(); + entry + .read_to_string(&mut text) + .map_err(|error| format!("读取 conversations.json 内容失败:{error}"))?; + serde_json::from_str::>(&text) + .map_err(|error| format!("Claude 官方数据的 conversations.json 格式无效:{error}")) +} + +fn claude_official_attachment_text(message: &Value) -> Vec { + let mut blocks = Vec::new(); + for attachment in message + .get("attachments") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let name = claude_code_string(attachment.get("file_name")) + .unwrap_or_else(|| "unnamed attachment".to_string()); + let file_type = claude_code_string(attachment.get("file_type")); + let size = attachment.get("file_size").and_then(Value::as_u64); + let mut text = format!("[Imported attachment: {name}"); + if let Some(file_type) = file_type { + text.push_str(&format!(", {file_type}")); + } + if let Some(size) = size { + text.push_str(&format!(", {size} bytes")); + } + text.push(']'); + if let Some(extracted) = claude_code_string(attachment.get("extracted_content")) { + text.push('\n'); + text.push_str(&extracted); + } + blocks.push(serde_json::json!({ "type": "text", "text": text })); + } + for file in message + .get("files") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let Some(name) = claude_code_string(file.get("file_name")) else { continue }; + blocks.push(serde_json::json!({ + "type": "text", + "text": format!("[Imported file reference: {name}; the original file was not included in the Claude official data]") + })); + } + blocks +} + +fn claude_official_content_blocks( + value: Option<&Value>, + tool_names: &mut HashMap, +) -> (Vec, Vec) { + let mut assistant_blocks = Vec::new(); + let mut tool_results = Vec::new(); + for block in value.and_then(Value::as_array).into_iter().flatten() { + match block.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = claude_code_string(block.get("text")) { + assistant_blocks.push(serde_json::json!({ "type": "text", "text": text })); + } + } + Some("thinking") => { + if let Some(thinking) = claude_code_string(block.get("thinking")) { + assistant_blocks.push(serde_json::json!({ "type": "thinking", "thinking": thinking })); + } + } + Some("tool_use") => { + let id = claude_code_string(block.get("id")) + .unwrap_or_else(|| format!("claude-official-tool-{}", tool_names.len())); + let name = claude_code_string(block.get("name")) + .unwrap_or_else(|| "claude_official_tool".to_string()); + tool_names.insert(id.clone(), name.clone()); + assistant_blocks.push(serde_json::json!({ + "type": "toolCall", + "id": id, + "name": name, + "arguments": block.get("input").cloned().unwrap_or_else(|| serde_json::json!({})) + })); + } + Some("tool_result") => { + let tool_call_id = claude_code_string(block.get("tool_use_id")) + .or_else(|| claude_code_string(block.get("tool_call_id"))) + .unwrap_or_else(|| format!("claude-official-tool-result-{}", tool_results.len())); + let tool_name = tool_names + .get(&tool_call_id) + .cloned() + .unwrap_or_else(|| "claude_official_tool".to_string()); + tool_results.push(serde_json::json!({ + "role": "toolResult", + "toolCallId": tool_call_id, + "toolName": tool_name, + "content": claude_code_text_blocks(block.get("content")), + "isError": block.get("is_error").and_then(Value::as_bool).unwrap_or(false) + })); + } + _ => {} + } + } + (assistant_blocks, tool_results) +} + +fn convert_claude_official_conversation( + conversation: &Value, + default_cwd: &str, +) -> Option { + let cwd = claude_official_workspace_cwd(conversation, default_cwd); + let session_id = claude_code_string(conversation.get("uuid"))?; + let created_at = claude_code_timestamp(conversation.get("created_at").and_then(Value::as_str)) + .unwrap_or_else(now_ms); + let mut updated_at = claude_code_timestamp(conversation.get("updated_at").and_then(Value::as_str)) + .unwrap_or(created_at); + let mut messages = Vec::new(); + let mut tool_names = HashMap::new(); + let mut first_user_text = None; + + for (index, message) in conversation + .get("chat_messages") + .and_then(Value::as_array) + .into_iter() + .flatten() + .enumerate() + { + let timestamp = claude_code_timestamp(message.get("created_at").and_then(Value::as_str)) + .unwrap_or(updated_at); + updated_at = updated_at.max(timestamp); + let message_id = claude_code_string(message.get("uuid")) + .unwrap_or_else(|| format!("{session_id}:{index}")); + match message.get("sender").and_then(Value::as_str) { + Some("human") => { + let mut content = claude_code_text_blocks(message.get("text")); + if content.is_empty() { + content = message + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|block| block.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|block| claude_code_string(block.get("text"))) + .map(|text| serde_json::json!({ "type": "text", "text": text })) + .collect(); + } + content.extend(claude_official_attachment_text(message)); + if content.is_empty() { + continue; + } + let text = content + .iter() + .filter_map(|block| block.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"); + first_user_text.get_or_insert(text); + messages.push(serde_json::json!({ + "role": "user", + "id": format!("{session_id}:{message_id}"), + "content": content, + "timestamp": timestamp + })); + } + Some("assistant") => { + let (mut content, mut tool_results) = + claude_official_content_blocks(message.get("content"), &mut tool_names); + if content.is_empty() { + content = claude_code_text_blocks(message.get("text")); + } + if !content.is_empty() { + messages.push(claude_code_assistant( + format!("{session_id}:{message_id}"), + content, + "claude-official", + timestamp, + "stop", + )); + } + for result in &mut tool_results { + if let Some(object) = result.as_object_mut() { + object.insert("timestamp".to_string(), serde_json::json!(timestamp)); + } + messages.push(result.clone()); + } + } + _ => {} + } + } + + if messages.is_empty() { + return None; + } + let title = claude_code_string(conversation.get("name")) + .or_else(|| first_user_text.map(|text| text.chars().take(80).collect())) + .unwrap_or_else(|| format!("Claude conversation {session_id}")); + let message_count = messages.len(); + Some(ClaudeCodeConversation { + id: format!("claude-official:{session_id}"), + session_id, + title, + model: "claude-official".to_string(), + cwd: Some(cwd), + created_at, + updated_at, + messages, + message_count, + already_imported: false, + }) +} + +fn scan_claude_official( + zip_path: &std::path::Path, +) -> Result<(Vec, usize, usize), String> { + let records = read_claude_official_conversations(zip_path)?; + let default_cwd = claude_official_default_cwd()?; + let scanned_count = records.len(); + let mut skipped_lines = 0; + let mut conversations = Vec::new(); + for record in &records { + match convert_claude_official_conversation(record, &default_cwd) { + Some(conversation) => conversations.push(conversation), + None => skipped_lines += 1, + } + } + conversations.sort_by_key(|conversation| conversation.created_at); + Ok((conversations, scanned_count, skipped_lines)) +} + +fn import_claude_official_conversation( + conn: &mut Connection, + conversation: ClaudeCodeConversation, +) -> 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!("序列化 Claude 官方会话消息失败:{error}"))?; + 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| claude_code_string(message.get("id"))); + let end_message_id = conversation.messages.last().and_then(|message| claude_code_string(message.get("id"))); + let input = ChatHistoryUpsertInput { + id: conversation.id.clone(), + title: conversation.title, + provider_id: "claude_official".to_string(), + model: conversation.model, + session_id: Some(conversation.session_id), + cwd: conversation.cwd, + selected_model_json: None, + 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, 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!("开启 Claude 官方会话导入事务失败:{error}"))?; + 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!("提交 Claude 官方会话导入事务失败:{error}"))?; + Ok((get_summary_by_id(conn, input.id.trim())?, true)) +} + +#[tauri::command] +pub async fn chat_history_scan_claude_official(zip_path: String) -> Result { + let (sessions, scanned_count, skipped_lines) = tauri::async_runtime::spawn_blocking(move || { + let (conversations, scanned_count, skipped_lines) = scan_claude_official(std::path::Path::new(&zip_path))?; + 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!("Claude 官方数据扫描失败:{error}"))??; + Ok(ClaudeOfficialImportPreview { sessions, scanned_count, skipped_lines }) +} + +#[tauri::command] +pub async fn chat_history_import_claude_official( + gateway_controller: tauri::State<'_, Arc>, + zip_path: String, + ids: Vec, +) -> Result { + let selected: HashSet = ids.into_iter().filter(|id| !id.trim().is_empty()).collect(); + let (summaries, scanned_count, skipped_lines) = tauri::async_runtime::spawn_blocking(move || { + let (conversations, scanned_count, skipped_lines) = scan_claude_official(std::path::Path::new(&zip_path))?; + let mut conn = open_db()?; + let summaries = conversations.into_iter().filter(|conversation| selected.contains(&conversation.id)) + .map(|conversation| import_claude_official_conversation(&mut conn, conversation)) + .collect::, _>>()?; + Ok::<_, String>((summaries, scanned_count, skipped_lines)) + }).await.map_err(|error| format!("Claude 官方数据导入失败:{error}"))??; + let mut imported_count = 0; + for summary in &summaries { + if summary.1 { + gateway_controller.publish_history_sync(build_history_sync_upsert(&summary.0)).await; + imported_count += 1; + } + } + Ok(ClaudeOfficialImportResult { scanned_count, imported_count, skipped_lines }) +} + +#[cfg(test)] +mod claude_official_import_tests { + use super::*; + + #[test] + fn keeps_explicit_workspace_paths_and_defaults_workspace_less_conversations() { + let conversation = serde_json::json!({ + "uuid": "conversation-1", + "cwd": "/tmp/linked-workspace", "name": "Official conversation", + "created_at": "2026-07-29T07:30:25.134893Z", "updated_at": "2026-07-29T07:30:28.537983Z", + "chat_messages": [ + {"uuid":"user-1", "sender":"human", "text":"Hello", "content":[], "attachments":[], "files":[], "created_at":"2026-07-29T07:30:25.134893Z"}, + {"uuid":"assistant-1", "sender":"assistant", "text":"", "content":[ + {"type":"thinking", "thinking":"I should answer."}, {"type":"text", "text":"Hi"}, + {"type":"tool_use", "id":"tool-1", "name":"view", "input":{"path":"README.md"}}, + {"type":"tool_result", "tool_use_id":"tool-1", "content":"contents", "is_error":false} + ], "created_at":"2026-07-29T07:30:28.537983Z"} + ] + }); + let imported = convert_claude_official_conversation(&conversation, "/tmp/default-project").expect("official conversation should be importable"); + assert_eq!(imported.id, "claude-official:conversation-1"); + assert_eq!(imported.cwd.as_deref(), Some("/tmp/linked-workspace")); + assert_eq!(imported.message_count, 3); + assert_eq!(imported.messages[0]["role"], "user"); + assert_eq!(imported.messages[1]["content"][0]["type"], "thinking"); + assert_eq!(imported.messages[1]["content"][1]["type"], "text"); + assert_eq!(imported.messages[1]["content"][2]["type"], "toolCall"); + assert_eq!(imported.messages[2]["role"], "toolResult"); + assert_eq!(imported.messages[2]["toolCallId"], "tool-1"); + } + + #[test] + fn sends_workspace_less_conversations_to_default_workspace() { + let conversation = serde_json::json!({ + "uuid": "workspace-less-conversation", + "chat_messages": [{ + "uuid": "user-1", + "sender": "human", + "text": "Hello", + "created_at": "2026-07-29T07:30:25.134893Z" + }] + }); + + let imported = convert_claude_official_conversation(&conversation, "/tmp/default-project") + .expect("workspace-less conversation should be importable"); + assert_eq!(imported.cwd.as_deref(), Some("/tmp/default-project")); + } + + #[test] + fn skips_official_conversation_without_displayable_messages() { + let conversation = serde_json::json!({"uuid":"empty-conversation", "name":"", "chat_messages":[]}); + assert!(convert_claude_official_conversation(&conversation, "/tmp/default-project").is_none()); + } +} 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 71a0d26e2..7c1e42f29 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 @@ -38,6 +38,8 @@ include!("search.rs"); include!("commands.rs"); include!("replace.rs"); include!("codex_import.rs"); +include!("claude_code_import.rs"); +include!("claude_official_import.rs"); 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 14c4a241a..fe88a40a7 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -51,6 +51,10 @@ macro_rules! app_invoke_handler { commands::chat_history::chat_history_append_segment, commands::chat_history::chat_history_scan_codex, commands::chat_history::chat_history_import_codex, + commands::chat_history::chat_history_scan_claude_code, + commands::chat_history::chat_history_import_claude_code, + commands::chat_history::chat_history_scan_claude_official, + commands::chat_history::chat_history_import_claude_official, 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 fe3d77847..648a6fdac 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -140,6 +140,29 @@ export const translations: Record> = { "chat.history.openFailed": "读取历史对话失败", "chat.history.persistFailed": "历史记录保存失败:{message}", "chat.history.codexImport": "导入 Codex 对话", + "chat.history.claudeCodeImport": "导入本机 Claude Code", + "chat.history.claudeOfficialImport": "导入 Official ZIP", + "chat.history.claudeImportFailed": "Claude 对话导入失败", + "chat.history.claudeOfficialImporting": "正在导入 Claude 官方对话…", + "chat.history.claudeOfficialImportFailed": "Claude 官方对话导入失败", + "chat.history.claudeOfficialImportDialogTitle": "导入 Claude 官方对话", + "chat.history.claudeOfficialImportDialogSubtitle": + "从官方 Claude 数据 ZIP 中选择需要导入的对话;会话将全部归入 Default 工作区。", + "chat.history.claudeOfficialImportDialogEmpty": "没有可导入的 Claude 官方对话。", + "chat.history.claudeOfficialImportDialogDefaultWorkspace": "Default 工作区", + "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 对话导入失败", @@ -961,6 +984,19 @@ export const translations: Record> = { "settings.codexImportTitle": "Codex 对话", "settings.codexImportDescription": "从 ~/.codex/sessions 导入尚未存在于 LiveAgent 的会话。", "settings.codexImportResult": "已导入 {imported} 个会话,共扫描 {scanned} 个 Codex 会话文件。", + "settings.claudeImportTitle": "Claude 对话", + "settings.claudeImportDescription": "从本机 Claude Code 或 Official Claude 数据 ZIP 导入对话。", + "settings.claudeImportResult": "已导入 {imported} 个会话,共读取 {scanned} 个 Claude 会话。", + "settings.claudeCodeImportTitle": "Claude Code 对话", + "settings.claudeOfficialImportTitle": "Claude 官方对话", + "settings.claudeOfficialImportDescription": + "选择 Claude 官方数据 ZIP;会话会全部导入 LiveAgent 的 Default 工作区。", + "settings.claudeOfficialImportResult": + "已导入 {imported} 个会话,共读取 {scanned} 个 Claude 官方对话。", + "settings.claudeCodeImportDescription": + "从 ~/.claude/projects 导入 Claude Code 主会话;子代理和工作流记录不会导入。", + "settings.claudeCodeImportResult": + "已导入 {imported} 个会话,共扫描 {scanned} 个 Claude Code 会话文件。", "settings.backToChat": "返回对话", "settings.title": "设置", @@ -2429,6 +2465,30 @@ export const translations: Record> = { "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 local Claude Code", + "chat.history.claudeOfficialImport": "Import Official ZIP", + "chat.history.claudeImportFailed": "Failed to import Claude conversations", + "chat.history.claudeOfficialImporting": "Importing official Claude conversations…", + "chat.history.claudeOfficialImportFailed": "Failed to import official Claude conversations", + "chat.history.claudeOfficialImportDialogTitle": "Import official Claude conversations", + "chat.history.claudeOfficialImportDialogSubtitle": + "Choose conversations from an official Claude data ZIP. All conversations are imported into the Default workspace.", + "chat.history.claudeOfficialImportDialogEmpty": "No official Claude conversations to import.", + "chat.history.claudeOfficialImportDialogDefaultWorkspace": "Default workspace", + "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", @@ -3279,6 +3339,21 @@ export const translations: Record> = { "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 or an Official Claude data ZIP.", + "settings.claudeImportResult": + "Imported {imported} conversations from {scanned} Claude conversations.", + "settings.claudeCodeImportTitle": "Claude Code conversations", + "settings.claudeOfficialImportTitle": "Official Claude conversations", + "settings.claudeOfficialImportDescription": + "Choose an official Claude data ZIP. All imported conversations go to LiveAgent's Default workspace.", + "settings.claudeOfficialImportResult": + "Imported {imported} conversations from {scanned} official Claude 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 7fa61de89..39f4999ae 100644 --- a/crates/agent-gui/src/lib/chat/history/chatHistory.ts +++ b/crates/agent-gui/src/lib/chat/history/chatHistory.ts @@ -249,6 +249,61 @@ export async function listChatHistory( }); } +export type ClaudeCodeImportResult = { + scannedCount: number; + importedCount: number; + skippedLines: number; +}; + +export type ClaudeCodeImportPreviewSession = { + id: string; + sessionId: string; + title: string; + model: string; + cwd?: string; + createdAt: number; + updatedAt: number; + messageCount: number; + alreadyImported: boolean; +}; + +export type ClaudeCodeImportPreview = { + sessions: ClaudeCodeImportPreviewSession[]; + scannedCount: number; + skippedLines: number; +}; + +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 type ClaudeOfficialImportResult = { + scannedCount: number; + importedCount: number; + skippedLines: number; +}; + +export type ClaudeOfficialImportPreview = { + sessions: ClaudeCodeImportPreviewSession[]; + scannedCount: number; + skippedLines: number; +}; + +export async function scanClaudeOfficialChatHistory(zipPath: string) { + return invoke("chat_history_scan_claude_official", { zipPath }); +} + +export async function importClaudeOfficialChatHistory(zipPath: string, ids: string[]) { + return invoke("chat_history_import_claude_official", { + zipPath, + ids, + }); +} + export type CodexImportResult = { scannedCount: number; importedCount: number; diff --git a/crates/agent-gui/src/pages/settings/ClaudeCodeImportDialog.tsx b/crates/agent-gui/src/pages/settings/ClaudeCodeImportDialog.tsx new file mode 100644 index 000000000..cbcbeff2e --- /dev/null +++ b/crates/agent-gui/src/pages/settings/ClaudeCodeImportDialog.tsx @@ -0,0 +1,355 @@ +import { 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 { + ClaudeCodeImportPreview, + ClaudeCodeImportPreviewSession, + ClaudeOfficialImportPreview, +} from "../../lib/chat/history/chatHistory"; +import { useModalMotion } from "../../lib/shared/modalMotion"; +import { cn } from "../../lib/shared/utils"; + +type ClaudeCodeImportDialogProps = { + preview: ClaudeCodeImportPreview | ClaudeOfficialImportPreview; + onClose: () => void; + onConfirm: (ids: string[]) => void; + defaultWorkspaceOnly?: boolean; +}; + +const NO_CWD_KEY = "__liveagent_no_cwd__"; +const DEFAULT_WORKSPACE_KEY = "__liveagent_default_workspace__"; + +function workspaceLabel(cwd: string): string { + if (cwd === NO_CWD_KEY) return ""; + // 显示路径末段,title 上带完整路径 + const segments = cwd.split(/[\\/]/).filter(Boolean); + return segments[segments.length - 1] ?? cwd; +} + +export function ClaudeCodeImportDialog({ + preview, + onClose, + onConfirm, + defaultWorkspaceOnly = false, +}: ClaudeCodeImportDialogProps) { + const { t } = useLocale(); + const { modalState, requestClose } = useModalMotion(onClose); + + const groups = useMemo(() => { + if (defaultWorkspaceOnly) { + return [ + ["__liveagent_default_workspace__", preview.sessions] as [ + string, + ClaudeCodeImportPreviewSession[], + ], + ]; + } + const map = new Map(); + for (const session of preview.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]); + } + // 按会话数倒序,default-project 置顶 + return Array.from(map.entries()).sort((a, b) => { + const aIsDefault = a[0].includes("default-project"); + const bIsDefault = b[0].includes("default-project"); + if (aIsDefault !== bIsDefault) return aIsDefault ? -1 : 1; + return b[1].length - a[1].length; + }); + }, [preview.sessions, defaultWorkspaceOnly]); + + const [activeCwdKey, setActiveCwdKey] = useState(groups[0]?.[0] ?? null); + + const [selected, setSelected] = useState>(() => { + const set = new Set(); + for (const session of preview.sessions) { + if (!session.alreadyImported) set.add(session.id); + } + return set; + }); + + const activeSessions = useMemo( + () => groups.find(([key]) => key === activeCwdKey)?.[1] ?? [], + [groups, activeCwdKey], + ); + + const groupSelectedCount = (key: string) => { + const list = groups.find(([k]) => k === key)?.[1] ?? []; + return list.filter((s) => selected.has(s.id)).length; + }; + + function toggleSession(session: ClaudeCodeImportPreviewSession) { + 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; + }); + } + + function toggleGroup(key: string) { + const list = groups.find(([k]) => k === key)?.[1] ?? []; + const importable = list.filter((s) => !s.alreadyImported); + if (importable.length === 0) return; + const allSelected = importable.every((s) => selected.has(s.id)); + setSelected((prev) => { + const next = new Set(prev); + if (allSelected) { + for (const s of importable) next.delete(s.id); + } else { + for (const s of importable) next.add(s.id); + } + return next; + }); + } + + const activeSessionsAllSelected = + activeSessions.length > 0 && + activeSessions.filter((s) => !s.alreadyImported).every((s) => selected.has(s.id)); + + return createPortal( +
+ +
+ +
+ {/* 左栏:工作区 */} +
+
+ {t("chat.history.claudeCodeImportDialogWorkspaces")} + +
+
+ {groups.length === 0 ? ( +

+ {t("chat.history.claudeCodeImportDialogEmpty")} +

+ ) : ( + groups.map(([key, list]) => { + const isActive = key === activeCwdKey; + const checked = groupSelectedCount(key); + const importable = list.filter((s) => !s.alreadyImported).length; + const allSelected = importable > 0 && checked === importable; + return ( + + + + + {key === NO_CWD_KEY + ? t("chat.history.claudeCodeImportDialogNoWorkspace") + : key === DEFAULT_WORKSPACE_KEY + ? t("chat.history.claudeOfficialImportDialogDefaultWorkspace") + : workspaceLabel(key)} + + {key !== NO_CWD_KEY && key !== DEFAULT_WORKSPACE_KEY ? ( + + {key} + + ) : null} + + + {checked}/{importable} + + + ); + }) + )} +
+
+ + {/* 右栏:会话 */} +
+
+ + {activeCwdKey === NO_CWD_KEY + ? t("chat.history.claudeCodeImportDialogNoWorkspace") + : activeCwdKey === DEFAULT_WORKSPACE_KEY + ? t("chat.history.claudeOfficialImportDialogDefaultWorkspace") + : (activeCwdKey ?? "")} + + {activeSessions.length > 0 ? ( + + ) : null} +
+
+ {activeSessions.length === 0 ? ( +

+ {t("chat.history.claudeCodeImportDialogEmpty")} +

+ ) : ( + activeSessions.map((session) => { + const checked = selected.has(session.id); + return ( + + ); + }) + )} +
+
+
+ +
+ + {t("chat.history.claudeCodeImportDialogSelected").replace( + "{count}", + String(selected.size), + )} + +
+ + +
+
+ + , + document.body, + ); +} diff --git a/crates/agent-gui/src/pages/settings/ConversationsSection.tsx b/crates/agent-gui/src/pages/settings/ConversationsSection.tsx index aeefdbe13..5516c8f7a 100644 --- a/crates/agent-gui/src/pages/settings/ConversationsSection.tsx +++ b/crates/agent-gui/src/pages/settings/ConversationsSection.tsx @@ -1,68 +1,161 @@ +import { invoke } from "@tauri-apps/api/core"; import { useState } from "react"; import { History, Loader2, Upload } from "../../components/icons"; import { Button } from "../../components/ui/button"; import { useLocale } from "../../i18n"; import { + type ClaudeCodeImportPreview, + type ClaudeCodeImportResult, + type ClaudeOfficialImportPreview, + type ClaudeOfficialImportResult, type CodexImportPreview, type CodexImportResult, + importClaudeCodeChatHistory, + importClaudeOfficialChatHistory, importCodexChatHistory, + scanClaudeCodeChatHistory, + scanClaudeOfficialChatHistory, scanCodexChatHistory, } from "../../lib/chat/history/chatHistory"; +import { ClaudeCodeImportDialog } from "./ClaudeCodeImportDialog"; import { CodexImportDialog } from "./CodexImportDialog"; +type ImportSource = "codex" | "claude-code" | "claude-official"; +type ImportPreview = CodexImportPreview | ClaudeCodeImportPreview | ClaudeOfficialImportPreview; +type ImportResult = CodexImportResult | ClaudeCodeImportResult | ClaudeOfficialImportResult; +type ImportDialog = { + source: ImportSource; + preview: ImportPreview; + zipPath?: string; +}; + export function ConversationsSection() { const { t } = useLocale(); - const [scanning, setScanning] = useState(false); - const [importing, setImporting] = useState(false); - const [dialog, setDialog] = useState(null); - const [result, setResult] = useState(null); + 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() { - setScanning(true); + async function handleScan(source: ImportSource) { + setScanning(source); setResult(null); setError(null); try { - const preview = await scanCodexChatHistory(); + const zipPath = + source === "claude-official" + ? await invoke("system_pick_file", { + initialWorkdir: undefined, + filterName: "Claude data export", + extensions: ["zip"], + }) + : undefined; + if (source === "claude-official" && !zipPath) return; + const officialZipPath = zipPath ?? ""; + const preview = + source === "codex" + ? await scanCodexChatHistory() + : source === "claude-code" + ? await scanClaudeCodeChatHistory() + : await scanClaudeOfficialChatHistory(officialZipPath); if (preview.sessions.length === 0) { - setError(t("chat.history.codexImportDialogEmpty")); + setError( + t( + source === "codex" + ? "chat.history.codexImportDialogEmpty" + : source === "claude-code" + ? "chat.history.claudeCodeImportDialogEmpty" + : "chat.history.claudeOfficialImportDialogEmpty", + ), + ); return; } - setDialog(preview); + setDialog({ source, preview, zipPath: zipPath ?? undefined }); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } finally { - setScanning(false); + setScanning(null); } } - async function handleConfirm(ids: string[]) { + async function handleConfirm(source: ImportSource, ids: string[], zipPath?: string) { setDialog(null); - setImporting(true); + setImporting(source); setError(null); try { - setResult(await importCodexChatHistory(ids)); + const value = + source === "codex" + ? await importCodexChatHistory(ids) + : source === "claude-code" + ? await importClaudeCodeChatHistory(ids) + : await importClaudeOfficialChatHistory(zipPath ?? "", ids); + setResult({ source, value }); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } finally { - setImporting(false); + setImporting(null); } } - return ( -
-
-
- -
-
-

{t("settings.conversationsTitle")}

-

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

+ 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 (
@@ -71,23 +164,22 @@ export function ConversationsSection() { {t("settings.codexImportDescription")}

-
- - {result ? ( + {sourceResult ? (
{t("settings.codexImportResult") - .replace("{imported}", String(result.importedCount)) - .replace("{scanned}", String(result.scannedCount))} + .replace("{imported}", String(sourceResult.importedCount)) + .replace("{scanned}", String(sourceResult.scannedCount))}
) : null} {error ? ( @@ -96,12 +188,45 @@ export function ConversationsSection() {
) : null} + ); + } + + return ( +
+
+
+ +
+
+

{t("settings.conversationsTitle")}

+

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

+
+
+ + {claudeImportCard()} + {codexImportCard()} - {dialog ? ( + {dialog?.source === "codex" ? ( setDialog(null)} + onConfirm={(ids) => void handleConfirm("codex", ids)} + /> + ) : null} + {dialog?.source === "claude-official" ? ( + setDialog(null)} + onConfirm={(ids) => void handleConfirm("claude-official", ids, dialog.zipPath)} + /> + ) : null} + {dialog?.source === "claude-code" ? ( + setDialog(null)} - onConfirm={(ids) => void handleConfirm(ids)} + onConfirm={(ids) => void handleConfirm("claude-code", ids)} /> ) : null}
From b5dc4b3d0ac39a80c3491f4a74c45255fc860e5a Mon Sep 17 00:00:00 2001 From: FlowerRealm Date: Wed, 29 Jul 2026 18:36:49 -0700 Subject: [PATCH 08/14] refactor(chat-history): dedupe + mod-ize conversation importers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codex/claude_code/claude_official importers mirrored each other: three copies of the Conversation/ImportResult/ImportPreview structs, three copies of the DB-write boilerplate (sequence messages, build the single segment, open Tx, upsert header, sync segments, verify, commit), and three near-identical tauri command shells. The only real per-provider variation is how to locate + parse the source, plus three scalars (provider_id, id prefix, selectedModelJson). Extract the shared struct + the single write path + the JSON helpers into a new `import` module, and turn the three importers into real submodules instead of `include!` fragments — so they get explicit module boundaries rather than flat-namespace naked names with codex_/claude_code_ prefixes. - Remove the triplicated structs and `import_*_conversation` boilderplate; one `import_conversation` + one `async run_import` serve all three. - Drop the codex_/claude_code_ string/timestamp aliases in favor of shared `import_string`/`import_timestamp`/`import_text_blocks`. - Hoist `claude_code_assistant` into `import` so the official parser no longer borrows from the code parser across module lines. - Pull `codex_remap_cwd` into a pure function taking codex_temp_root + default_project; the temp-dir test was platform-coupled to `dirs` and only passed on a /Users/tester home. Now deterministic. - Fix the two codex tests that wouldn't compile on the prior branch (they initialized `CodexConversation` without the message_count/already_imported fields the struct gained). - lib.rs: point generate_handler! at the new submodule paths; the frontend `invoke("chat_history_scan_codex")` contract is unchanged. cargo build --lib + --bin liveagent: clean, 0 warnings. cargo test --lib: 673 passed. clippy on the touched files: 0 new findings. --- .../chat_history/claude_code_import.rs | 235 +++----------- .../chat_history/claude_official_import.rs | 156 +++------- .../history/chat_history/codex_import.rs | 226 +++----------- .../commands/history/chat_history/import.rs | 287 ++++++++++++++++++ .../src/commands/history/chat_history/mod.rs | 7 +- .../commands/history/chat_history/tests.rs | 40 ++- crates/agent-gui/src-tauri/src/lib.rs | 12 +- 7 files changed, 453 insertions(+), 510 deletions(-) create mode 100644 crates/agent-gui/src-tauri/src/commands/history/chat_history/import.rs 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 index 1ba05be25..bef9ced2a 100644 --- 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 @@ -1,69 +1,13 @@ -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ClaudeCodeImportResult { - pub scanned_count: usize, - pub imported_count: usize, - pub skipped_lines: usize, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ClaudeCodeImportPreview { - pub sessions: Vec, - pub scanned_count: usize, - pub skipped_lines: usize, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct ClaudeCodeConversation { - id: String, - session_id: String, - title: String, - model: String, - cwd: Option, - created_at: i64, - updated_at: i64, - #[serde(skip)] - messages: Vec, - message_count: usize, - already_imported: bool, -} +// Claude Code importer. Locates & parses `~/.claude/projects/**` depth-2 JSONL +// into [`ImportConversation`]. The shared struct, DB write, command shell and +// the `claude_code_assistant` formatter all live in `super::import`; this +// module just supplies the Claude Code-specific scan + parse plus two tauri +// commands wired to that core. -fn claude_code_string(value: Option<&Value>) -> Option { - value - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - -fn claude_code_timestamp(value: Option<&str>) -> Option { - value.and_then(|value| { - chrono::DateTime::parse_from_rfc3339(value) - .ok() - .map(|date| date.timestamp_millis()) - }) -} - -fn claude_code_text_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) if object.get("type").and_then(Value::as_str) == Some("text") => { - object.get("text").and_then(Value::as_str).filter(|text| !text.is_empty()).map(|text| { - serde_json::json!({ "type": "text", "text": text }) - }) - } - _ => None, - }) - .collect() -} +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) @@ -74,42 +18,21 @@ fn claude_code_is_internal_user_message(row: &Value, content: &str) -> bool { || content.starts_with("") } -fn claude_code_assistant( - id: String, - content: Vec, - model: &str, - timestamp: i64, - stop_reason: &str, -) -> Value { - serde_json::json!({ - "role": "assistant", - "id": id, - "content": content, - "api": "anthropic-messages", - "provider": "claude_code", - "model": model, - "usage": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0 }, - "stopReason": stop_reason, - "timestamp": timestamp - }) -} - fn claude_code_tool_result_blocks(value: Option<&Value>) -> Vec { - claude_code_text_blocks(value) + import_text_blocks(value) } fn claude_code_session_id(rows: &[(String, Value)], path: &std::path::Path) -> Option { rows.iter() .find_map(|(_, row)| { - claude_code_string(row.get("sessionId")) - .or_else(|| claude_code_string(row.get("session_id"))) + 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> { +) -> Result<(Option, usize), String> { let text = std::fs::read_to_string(path) .map_err(|error| format!("读取 Claude Code 会话失败:{}: {error}", path.display()))?; let mut rows = Vec::new(); @@ -117,7 +40,7 @@ fn convert_claude_code_file( 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((claude_code_string(row.get("timestamp")).unwrap_or_default(), row)); + rows.push((import_string(row.get("timestamp")).unwrap_or_default(), row)); } _ => skipped_lines += 1, } @@ -134,16 +57,16 @@ fn convert_claude_code_file( let mut created_at = None; let mut title = None; for (timestamp, row) in &rows { - cwd = cwd.or_else(|| claude_code_string(row.get("cwd"))); - created_at = created_at.or_else(|| claude_code_timestamp(Some(timestamp))); + 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| claude_code_string(message.get("model"))) + .and_then(|message| import_string(message.get("model"))) }); } - Some("ai-title") => title = claude_code_string(row.get("aiTitle")).or(title), + Some("ai-title") => title = import_string(row.get("aiTitle")).or(title), _ => {} } } @@ -157,9 +80,9 @@ fn convert_claude_code_file( if row.get("isSidechain").and_then(Value::as_bool) == Some(true) { continue; } - let timestamp = claude_code_timestamp(Some(timestamp)).unwrap_or(last_timestamp); + let timestamp = import_timestamp(Some(timestamp)).unwrap_or(last_timestamp); last_timestamp = timestamp; - let entry_id = claude_code_string(row.get("uuid")) + let entry_id = import_string(row.get("uuid")) .unwrap_or_else(|| format!("{session_id}:{index}")); match row.get("type").and_then(Value::as_str) { Some("user") => { @@ -183,7 +106,7 @@ fn convert_claude_code_file( } let Some(blocks) = content.and_then(Value::as_array) else { continue }; - let text_blocks = claude_code_text_blocks(content); + let text_blocks = import_text_blocks(content); if !text_blocks.is_empty() { let text = text_blocks .iter() @@ -202,7 +125,7 @@ fn convert_claude_code_file( if block.get("type").and_then(Value::as_str) != Some("tool_result") { continue; } - let tool_call_id = claude_code_string(block.get("tool_use_id")) + let tool_call_id = import_string(block.get("tool_use_id")) .unwrap_or_else(|| format!("{session_id}:{entry_id}")); let tool_name = tool_names .get(&tool_call_id) @@ -220,7 +143,7 @@ fn convert_claude_code_file( } Some("assistant") => { let Some(message) = row.get("message") else { continue }; - let message_model = claude_code_string(message.get("model")).unwrap_or_else(|| model.clone()); + 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 { @@ -253,8 +176,8 @@ fn convert_claude_code_file( } } Some("tool_use") => { - let tool_call_id = claude_code_string(block.get("id")).unwrap_or_else(|| id.clone()); - let tool_name = claude_code_string(block.get("name")).unwrap_or_else(|| "claude_code_tool".to_string()); + 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(claude_code_assistant( id, @@ -285,8 +208,8 @@ fn convert_claude_code_file( .unwrap_or_else(|| format!("Claude Code session {session_id}")); let message_count = messages.len(); Ok(( - Some(ClaudeCodeConversation { - id: format!("claude-code:{session_id}"), + Some(ImportConversation { + id: ImportProviderConfig::claude_code(&model).id_prefix_with(&session_id), session_id, title, model, @@ -304,7 +227,7 @@ fn convert_claude_code_file( )) } -fn scan_claude_code_sessions() -> Result<(Vec, usize, usize), String> { +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() { @@ -332,79 +255,8 @@ fn scan_claude_code_sessions() -> Result<(Vec, usize, us Ok((conversations, scanned, skipped)) } -fn import_claude_code_conversation( - conn: &mut Connection, - conversation: ClaudeCodeConversation, -) -> 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!("序列化 Claude Code 消息失败:{error}"))?; - 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| claude_code_string(message.get("id"))); - let end_message_id = conversation - .messages - .last() - .and_then(|message| claude_code_string(message.get("id"))); - let input = ChatHistoryUpsertInput { - id: conversation.id.clone(), - title: conversation.title, - provider_id: "claude_code".to_string(), - model: conversation.model.clone(), - session_id: Some(conversation.session_id), - cwd: conversation.cwd, - selected_model_json: Some( - serde_json::json!({ "customProviderId": "builtin-claude_code", "model": conversation.model }).to_string(), - ), - 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, - 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!("开启 Claude Code 导入事务失败:{error}"))?; - 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!("提交 Claude Code 导入事务失败:{error}"))?; - Ok((get_summary_by_id(conn, input.id.trim())?, true)) -} - #[tauri::command] -pub async fn chat_history_scan_claude_code() -> Result { +pub async fn chat_history_scan_claude_code() -> Result { let (sessions, scanned_count, skipped_lines) = tauri::async_runtime::spawn_blocking(|| -> Result<_, String> { let (conversations, scanned_count, skipped_lines) = scan_claude_code_sessions()?; let conn = open_db()?; @@ -419,33 +271,18 @@ pub async fn chat_history_scan_claude_code() -> Result>, ids: Vec, -) -> Result { +) -> Result { let selected: HashSet = ids.into_iter().filter(|id| !id.trim().is_empty()).collect(); - let (summaries, scanned_count, skipped_lines) = tauri::async_runtime::spawn_blocking(move || { - let (conversations, scanned_count, skipped_lines) = scan_claude_code_sessions()?; - let mut conn = open_db()?; - let summaries = conversations - .into_iter() - .filter(|conversation| selected.contains(&conversation.id)) - .map(|conversation| import_claude_code_conversation(&mut conn, conversation)) - .collect::, _>>()?; - Ok::<_, String>((summaries, scanned_count, skipped_lines)) - }) - .await - .map_err(|error| format!("Claude Code 导入失败:{error}"))??; - let mut imported_count = 0; - for summary in &summaries { - if summary.1 { - gateway_controller.publish_history_sync(build_history_sync_upsert(&summary.0)).await; - imported_count += 1; - } - } - Ok(ClaudeCodeImportResult { scanned_count, imported_count, skipped_lines }) -} + let (conversations, _scanned_count, skipped_lines) = + tauri::async_runtime::spawn_blocking(scan_claude_code_sessions) + .await + .map_err(|error| format!("Claude Code 扫描失败:{error}"))??; + run_import(ImportProviderConfig::claude_code("claude_code"), conversations, skipped_lines, selected, &gateway_controller).await +} \ No newline at end of file diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_official_import.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_official_import.rs index ad0fd14f4..3061cc525 100644 --- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_official_import.rs +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_official_import.rs @@ -1,18 +1,12 @@ -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ClaudeOfficialImportResult { - pub scanned_count: usize, - pub imported_count: usize, - pub skipped_lines: usize, -} +// Claude official importer. Locates & parses `conversations.json` inside a +// user-selected Claude data-export ZIP into [`ImportConversation`]. The shared +// struct, DB write, command shell and the `claude_code_assistant` formatter all +// live in `super::import`; this module just supplies the ZIP-specific scan + +// parse plus two tauri commands wired to that core. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ClaudeOfficialImportPreview { - pub sessions: Vec, - pub scanned_count: usize, - pub skipped_lines: usize, -} +use super::import::*; +use super::*; +use std::collections::HashMap; const CLAUDE_OFFICIAL_CONVERSATIONS_ENTRY: &str = "conversations.json"; @@ -22,7 +16,7 @@ fn claude_official_default_cwd() -> Result { fn claude_official_workspace_cwd(conversation: &Value, default_cwd: &str) -> String { for key in ["cwd", "source_cwd", "workspace_path", "workspacePath"] { - let Some(cwd) = claude_code_string(conversation.get(key)) else { continue }; + let Some(cwd) = import_string(conversation.get(key)) else { continue }; let path = std::path::Path::new(&cwd); if path.is_absolute() && !path.starts_with("/home/claude") @@ -72,9 +66,9 @@ fn claude_official_attachment_text(message: &Value) -> Vec { .into_iter() .flatten() { - let name = claude_code_string(attachment.get("file_name")) + let name = import_string(attachment.get("file_name")) .unwrap_or_else(|| "unnamed attachment".to_string()); - let file_type = claude_code_string(attachment.get("file_type")); + let file_type = import_string(attachment.get("file_type")); let size = attachment.get("file_size").and_then(Value::as_u64); let mut text = format!("[Imported attachment: {name}"); if let Some(file_type) = file_type { @@ -84,7 +78,7 @@ fn claude_official_attachment_text(message: &Value) -> Vec { text.push_str(&format!(", {size} bytes")); } text.push(']'); - if let Some(extracted) = claude_code_string(attachment.get("extracted_content")) { + if let Some(extracted) = import_string(attachment.get("extracted_content")) { text.push('\n'); text.push_str(&extracted); } @@ -96,7 +90,7 @@ fn claude_official_attachment_text(message: &Value) -> Vec { .into_iter() .flatten() { - let Some(name) = claude_code_string(file.get("file_name")) else { continue }; + let Some(name) = import_string(file.get("file_name")) else { continue }; blocks.push(serde_json::json!({ "type": "text", "text": format!("[Imported file reference: {name}; the original file was not included in the Claude official data]") @@ -114,19 +108,19 @@ fn claude_official_content_blocks( for block in value.and_then(Value::as_array).into_iter().flatten() { match block.get("type").and_then(Value::as_str) { Some("text") => { - if let Some(text) = claude_code_string(block.get("text")) { + if let Some(text) = import_string(block.get("text")) { assistant_blocks.push(serde_json::json!({ "type": "text", "text": text })); } } Some("thinking") => { - if let Some(thinking) = claude_code_string(block.get("thinking")) { + if let Some(thinking) = import_string(block.get("thinking")) { assistant_blocks.push(serde_json::json!({ "type": "thinking", "thinking": thinking })); } } Some("tool_use") => { - let id = claude_code_string(block.get("id")) + let id = import_string(block.get("id")) .unwrap_or_else(|| format!("claude-official-tool-{}", tool_names.len())); - let name = claude_code_string(block.get("name")) + let name = import_string(block.get("name")) .unwrap_or_else(|| "claude_official_tool".to_string()); tool_names.insert(id.clone(), name.clone()); assistant_blocks.push(serde_json::json!({ @@ -137,8 +131,8 @@ fn claude_official_content_blocks( })); } Some("tool_result") => { - let tool_call_id = claude_code_string(block.get("tool_use_id")) - .or_else(|| claude_code_string(block.get("tool_call_id"))) + let tool_call_id = import_string(block.get("tool_use_id")) + .or_else(|| import_string(block.get("tool_call_id"))) .unwrap_or_else(|| format!("claude-official-tool-result-{}", tool_results.len())); let tool_name = tool_names .get(&tool_call_id) @@ -148,7 +142,7 @@ fn claude_official_content_blocks( "role": "toolResult", "toolCallId": tool_call_id, "toolName": tool_name, - "content": claude_code_text_blocks(block.get("content")), + "content": import_text_blocks(block.get("content")), "isError": block.get("is_error").and_then(Value::as_bool).unwrap_or(false) })); } @@ -161,12 +155,12 @@ fn claude_official_content_blocks( fn convert_claude_official_conversation( conversation: &Value, default_cwd: &str, -) -> Option { +) -> Option { let cwd = claude_official_workspace_cwd(conversation, default_cwd); - let session_id = claude_code_string(conversation.get("uuid"))?; - let created_at = claude_code_timestamp(conversation.get("created_at").and_then(Value::as_str)) + let session_id = import_string(conversation.get("uuid"))?; + let created_at = import_timestamp(conversation.get("created_at").and_then(Value::as_str)) .unwrap_or_else(now_ms); - let mut updated_at = claude_code_timestamp(conversation.get("updated_at").and_then(Value::as_str)) + let mut updated_at = import_timestamp(conversation.get("updated_at").and_then(Value::as_str)) .unwrap_or(created_at); let mut messages = Vec::new(); let mut tool_names = HashMap::new(); @@ -179,14 +173,14 @@ fn convert_claude_official_conversation( .flatten() .enumerate() { - let timestamp = claude_code_timestamp(message.get("created_at").and_then(Value::as_str)) + let timestamp = import_timestamp(message.get("created_at").and_then(Value::as_str)) .unwrap_or(updated_at); updated_at = updated_at.max(timestamp); - let message_id = claude_code_string(message.get("uuid")) + let message_id = import_string(message.get("uuid")) .unwrap_or_else(|| format!("{session_id}:{index}")); match message.get("sender").and_then(Value::as_str) { Some("human") => { - let mut content = claude_code_text_blocks(message.get("text")); + let mut content = import_text_blocks(message.get("text")); if content.is_empty() { content = message .get("content") @@ -194,7 +188,7 @@ fn convert_claude_official_conversation( .into_iter() .flatten() .filter(|block| block.get("type").and_then(Value::as_str) == Some("text")) - .filter_map(|block| claude_code_string(block.get("text"))) + .filter_map(|block| import_string(block.get("text"))) .map(|text| serde_json::json!({ "type": "text", "text": text })) .collect(); } @@ -219,7 +213,7 @@ fn convert_claude_official_conversation( let (mut content, mut tool_results) = claude_official_content_blocks(message.get("content"), &mut tool_names); if content.is_empty() { - content = claude_code_text_blocks(message.get("text")); + content = import_text_blocks(message.get("text")); } if !content.is_empty() { messages.push(claude_code_assistant( @@ -244,12 +238,12 @@ fn convert_claude_official_conversation( if messages.is_empty() { return None; } - let title = claude_code_string(conversation.get("name")) + let title = import_string(conversation.get("name")) .or_else(|| first_user_text.map(|text| text.chars().take(80).collect())) .unwrap_or_else(|| format!("Claude conversation {session_id}")); let message_count = messages.len(); - Some(ClaudeCodeConversation { - id: format!("claude-official:{session_id}"), + Some(ImportConversation { + id: ImportProviderConfig::claude_official().id_prefix_with(&session_id), session_id, title, model: "claude-official".to_string(), @@ -264,7 +258,7 @@ fn convert_claude_official_conversation( fn scan_claude_official( zip_path: &std::path::Path, -) -> Result<(Vec, usize, usize), String> { +) -> Result<(Vec, usize, usize), String> { let records = read_claude_official_conversations(zip_path)?; let default_cwd = claude_official_default_cwd()?; let scanned_count = records.len(); @@ -280,63 +274,8 @@ fn scan_claude_official( Ok((conversations, scanned_count, skipped_lines)) } -fn import_claude_official_conversation( - conn: &mut Connection, - conversation: ClaudeCodeConversation, -) -> 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!("序列化 Claude 官方会话消息失败:{error}"))?; - 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| claude_code_string(message.get("id"))); - let end_message_id = conversation.messages.last().and_then(|message| claude_code_string(message.get("id"))); - let input = ChatHistoryUpsertInput { - id: conversation.id.clone(), - title: conversation.title, - provider_id: "claude_official".to_string(), - model: conversation.model, - session_id: Some(conversation.session_id), - cwd: conversation.cwd, - selected_model_json: None, - 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, 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!("开启 Claude 官方会话导入事务失败:{error}"))?; - 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!("提交 Claude 官方会话导入事务失败:{error}"))?; - Ok((get_summary_by_id(conn, input.id.trim())?, true)) -} - #[tauri::command] -pub async fn chat_history_scan_claude_official(zip_path: String) -> Result { +pub async fn chat_history_scan_claude_official(zip_path: String) -> Result { let (sessions, scanned_count, skipped_lines) = tauri::async_runtime::spawn_blocking(move || { let (conversations, scanned_count, skipped_lines) = scan_claude_official(std::path::Path::new(&zip_path))?; let conn = open_db()?; @@ -346,7 +285,7 @@ pub async fn chat_history_scan_claude_official(zip_path: String) -> Result((sessions, scanned_count, skipped_lines)) }).await.map_err(|error| format!("Claude 官方数据扫描失败:{error}"))??; - Ok(ClaudeOfficialImportPreview { sessions, scanned_count, skipped_lines }) + Ok(ImportPreview { sessions, scanned_count, skipped_lines }) } #[tauri::command] @@ -354,24 +293,13 @@ pub async fn chat_history_import_claude_official( gateway_controller: tauri::State<'_, Arc>, zip_path: String, ids: Vec, -) -> Result { +) -> Result { let selected: HashSet = ids.into_iter().filter(|id| !id.trim().is_empty()).collect(); - let (summaries, scanned_count, skipped_lines) = tauri::async_runtime::spawn_blocking(move || { - let (conversations, scanned_count, skipped_lines) = scan_claude_official(std::path::Path::new(&zip_path))?; - let mut conn = open_db()?; - let summaries = conversations.into_iter().filter(|conversation| selected.contains(&conversation.id)) - .map(|conversation| import_claude_official_conversation(&mut conn, conversation)) - .collect::, _>>()?; - Ok::<_, String>((summaries, scanned_count, skipped_lines)) - }).await.map_err(|error| format!("Claude 官方数据导入失败:{error}"))??; - let mut imported_count = 0; - for summary in &summaries { - if summary.1 { - gateway_controller.publish_history_sync(build_history_sync_upsert(&summary.0)).await; - imported_count += 1; - } - } - Ok(ClaudeOfficialImportResult { scanned_count, imported_count, skipped_lines }) + let (conversations, _scanned_count, skipped_lines) = + tauri::async_runtime::spawn_blocking(move || scan_claude_official(std::path::Path::new(&zip_path))) + .await + .map_err(|error| format!("Claude 官方数据导入失败:{error}"))??; + run_import(ImportProviderConfig::claude_official(), conversations, skipped_lines, selected, &gateway_controller).await } #[cfg(test)] @@ -427,4 +355,4 @@ mod claude_official_import_tests { let conversation = serde_json::json!({"uuid":"empty-conversation", "name":"", "chat_messages":[]}); assert!(convert_claude_official_conversation(&conversation, "/tmp/default-project").is_none()); } -} +} \ No newline at end of file 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 index e428567ed..9bca60219 100644 --- 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 @@ -1,46 +1,12 @@ -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CodexImportResult { - pub scanned_count: usize, - pub imported_count: usize, - pub skipped_lines: usize, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CodexImportPreview { - pub sessions: Vec, - pub scanned_count: usize, - pub skipped_lines: usize, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct CodexConversation { - id: String, - session_id: String, - title: String, - model: String, - cwd: Option, - created_at: i64, - updated_at: i64, - #[serde(skip)] - messages: Vec, - message_count: usize, - already_imported: bool, -} +// Codex importer. Knows only how to locate & parse `~/.codex/sessions/**/rollout-*.jsonl` +// into [`ImportConversation`]. The shared struct, DB write and command shell +// live in `super::import`; this module just supplies the Codex-specific scan + +// parse plus two tauri commands wired to that core. -fn codex_string(value: Option<&Value>) -> Option { - value.and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).map(str::to_string) -} - -fn codex_timestamp(value: Option<&str>) -> Option { - value.and_then(|value| { - chrono::DateTime::parse_from_rfc3339(value) - .ok() - .map(|date| date.timestamp_millis()) - }) -} +use super::import::*; +use super::*; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; fn codex_text_blocks(value: Option<&Value>, input: bool) -> Vec { let mut blocks = Vec::new(); @@ -119,37 +85,34 @@ fn codex_session_id(rows: &[(String, Value)]) -> Option { .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| codex_string(payload.get("session_id")).or_else(|| codex_string(payload.get("id")))) + .and_then(|payload| import_string(payload.get("session_id")).or_else(|| import_string(payload.get("id")))) } -fn codex_import_cwd(cwd: Option, home: &std::path::Path) -> Option { +/// Remap a Codex `cwd` to a live-agent workdir. Sessions Codex ran inside its +/// own sandbox temp dir (`/Codex/…`) never existed on disk for the +/// user, so they are parked under the live-agent default project; any other +/// `cwd` is kept verbatim. +pub(crate) fn codex_remap_cwd(cwd: Option, codex_temp_root: &std::path::Path, default_project: &std::path::Path) -> Option { let cwd = cwd?; - let codex_temp = dirs::document_dir() - .unwrap_or_else(|| home.join("Documents")) - .join("Codex"); - if std::path::Path::new(&cwd).starts_with(&codex_temp) { - return Some( - home.join(format!(".{}", env!("CARGO_PKG_NAME"))) - .join("default-project") - .to_string_lossy() - .into_owned(), - ); + if std::path::Path::new(&cwd).starts_with(codex_temp_root) { + return Some(default_project.to_string_lossy().into_owned()); } Some(cwd) } -fn convert_codex_file( +pub(crate) fn convert_codex_file( path: &std::path::Path, titles: &HashMap, - home: &std::path::Path, -) -> Result<(Option, usize), String> { + 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 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() => { - let timestamp = codex_string(row.get("timestamp")).unwrap_or_default(); + let timestamp = import_string(row.get("timestamp")).unwrap_or_default(); rows.push((timestamp, row)); } _ => skipped_lines += 1, @@ -169,15 +132,15 @@ fn convert_codex_file( match row.get("type").and_then(Value::as_str) { Some("session_meta") => { if let Some(payload) = row.get("payload") { - model = model.or_else(|| codex_string(payload.get("model"))); - cwd = cwd.or_else(|| codex_string(payload.get("cwd"))); - created_at = created_at.or_else(|| codex_timestamp(Some(timestamp))); + 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 = codex_string(payload.get("model")).or(model); - cwd = codex_string(payload.get("cwd")).or(cwd); + model = import_string(payload.get("model")).or(model); + cwd = import_string(payload.get("cwd")).or(cwd); } } _ => {} @@ -190,12 +153,12 @@ fn convert_codex_file( let mut last_timestamp = created_at.unwrap_or_else(now_ms); for (index, (timestamp, row)) in rows.iter().enumerate() { - let timestamp = codex_timestamp(Some(timestamp)).unwrap_or(last_timestamp); + let timestamp = import_timestamp(Some(timestamp)).unwrap_or(last_timestamp); last_timestamp = timestamp; if let Some(payload) = row.get("payload") { if row.get("type").and_then(Value::as_str) != Some("response_item") { continue; } let item_type = payload.get("type").and_then(Value::as_str).unwrap_or_default(); - let item_id = codex_string(payload.get("id")).unwrap_or_else(|| format!("{session_id}:{index}")); + let item_id = import_string(payload.get("id")).unwrap_or_else(|| format!("{session_id}:{index}")); match item_type { "message" => { let role = payload.get("role").and_then(Value::as_str).unwrap_or_default(); @@ -215,14 +178,14 @@ fn convert_codex_file( if !summary.is_empty() { messages.push(codex_assistant(format!("{session_id}:{item_id}"), vec![serde_json::json!({ "type": "thinking", "thinking": summary })], &model, timestamp, "stop")); } } "function_call" | "custom_tool_call" => { - let call_id = codex_string(payload.get("call_id")).unwrap_or_else(|| item_id.clone()); - let name = codex_string(payload.get("name")).unwrap_or_else(|| "codex_tool".to_string()); + 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(codex_assistant(format!("{session_id}:{item_id}"), vec![serde_json::json!({ "type": "toolCall", "id": call_id, "name": name, "arguments": arguments })], &model, timestamp, "toolUse")); } "function_call_output" | "custom_tool_call_output" => { - let call_id = codex_string(payload.get("call_id")).unwrap_or_else(|| item_id.clone()); + 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()); let content = codex_output_blocks(payload.get("output")); messages.push(serde_json::json!({ "role": "toolResult", "toolCallId": call_id, "toolName": tool_name, "content": content, "isError": false, "timestamp": timestamp })); @@ -242,12 +205,12 @@ fn convert_codex_file( .unwrap_or_else(|| format!("Codex session {session_id}")); let message_count = messages.len(); Ok(( - Some(CodexConversation { + Some(ImportConversation { id: format!("codex:{session_id}"), session_id, title, model, - cwd: codex_import_cwd(cwd, home), + cwd: codex_remap_cwd(cwd, codex_temp_root, default_project), created_at: created_at.unwrap_or(last_timestamp), updated_at: last_timestamp, messages, @@ -264,17 +227,21 @@ fn read_codex_titles(home: &std::path::Path) -> HashMap { 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)) = (codex_string(row.get("id")), codex_string(row.get("thread_name"))) { titles.insert(id, title); } + 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> { +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); + // Codex parks throwaway sessions under /Codex; map those onto the + // live-agent default project so we don't surface a workdir the user never had. + 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; @@ -282,7 +249,7 @@ fn scan_codex_sessions() -> Result<(Vec, usize, usize), Strin 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, &home) { + match convert_codex_file(path, &titles, &codex_temp_root, &default_project) { Ok((Some(conversation), skipped_lines)) => { skipped += skipped_lines; conversations.push(conversation); @@ -295,82 +262,8 @@ fn scan_codex_sessions() -> Result<(Vec, usize, usize), Strin Ok((conversations, scanned, skipped)) } -fn import_codex_conversation( - conn: &mut Connection, - conversation: CodexConversation, -) -> 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(|e| format!("序列化 Codex 消息失败:{e}"))?; - 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| codex_string(message.get("id"))); - let end_message_id = conversation - .messages - .last() - .and_then(|message| codex_string(message.get("id"))); - let input = ChatHistoryUpsertInput { - id: conversation.id.clone(), - title: conversation.title, - provider_id: "codex".to_string(), - model: conversation.model.clone(), - session_id: Some(conversation.session_id), - cwd: conversation.cwd, - selected_model_json: Some( - serde_json::json!({ "customProviderId": "codex", "model": conversation.model }) - .to_string(), - ), - 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, - 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(|e| format!("开启 Codex 导入事务失败:{e}"))?; - 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(|e| format!("提交 Codex 导入事务失败:{e}"))?; - Ok((get_summary_by_id(conn, input.id.trim())?, true)) -} - #[tauri::command] -pub async fn chat_history_scan_codex() -> Result { +pub async fn chat_history_scan_codex() -> Result { let (sessions, scanned_count, skipped_lines) = tauri::async_runtime::spawn_blocking(|| -> Result<_, String> { let (conversations, scanned_count, skipped_lines) = scan_codex_sessions()?; @@ -387,7 +280,7 @@ pub async fn chat_history_scan_codex() -> Result { }) .await .map_err(|e| format!("Codex 扫描失败:{e}"))??; - Ok(CodexImportPreview { + Ok(ImportPreview { sessions, scanned_count, skipped_lines, @@ -398,32 +291,11 @@ pub async fn chat_history_scan_codex() -> Result { pub async fn chat_history_import_codex( gateway_controller: tauri::State<'_, Arc>, ids: Vec, -) -> Result { +) -> Result { let selected: HashSet = ids.into_iter().filter(|id| !id.trim().is_empty()).collect(); - let (summaries, scanned_count, skipped_lines) = tauri::async_runtime::spawn_blocking(move || { - let (conversations, scanned_count, skipped_lines) = scan_codex_sessions()?; - let mut conn = open_db()?; - let summaries = conversations - .into_iter() - .filter(|conversation| selected.contains(&conversation.id)) - .map(|conversation| import_codex_conversation(&mut conn, conversation)) - .collect::, _>>()?; - Ok::<_, String>((summaries, scanned_count, skipped_lines)) - }) - .await - .map_err(|e| format!("Codex 导入失败:{e}"))??; - let mut imported_count = 0; - for summary in &summaries { - if summary.1 { - gateway_controller - .publish_history_sync(build_history_sync_upsert(&summary.0)) - .await; - imported_count += 1; - } - } - Ok(CodexImportResult { - scanned_count, - imported_count, - skipped_lines, - }) -} + let (conversations, _scanned_count, skipped_lines) = + tauri::async_runtime::spawn_blocking(scan_codex_sessions) + .await + .map_err(|e| format!("Codex 扫描失败:{e}"))??; + run_import(ImportProviderConfig::codex("codex"), conversations, skipped_lines, selected, &gateway_controller).await +} \ No newline at end of file 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..354e6114e --- /dev/null +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/import.rs @@ -0,0 +1,287 @@ +// Unified data model and persistence core shared by every provider importer +// (codex, claude_code, claude_official). Each provider only supplies: +// - how to locate & parse its source into `ImportConversation` (`scan_*`) +// - 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. +// +// This is a real submodule of `chat_history`; `use super::*` gives us the +// parent's DB helpers (`get_summary_by_id`, `open_db`, `now_ms`, …) and types +// (`ChatHistorySummary`, `ChatHistoryUpsertInput`, …) without `include!`'s flat +// namespace. + +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, +} + +/// The resolved payload of one source conversation. Fields map 1:1 to the +/// `ChatHistoryUpsertInput` we persist, except for the helpers (`messages`, +/// `already_imported`) that the DB write does not round-trip. +#[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, +} + +/// The only per-provider variation in the persistence path: which `provider_id` +/// and which `id` prefix the live-agent conversation gets, plus the +/// `selectedModelJson` written to the header. The model string itself comes +/// from the parser and is carried on each [`ImportConversation`]. +pub(crate) struct ImportProviderConfig { + provider_id: &'static str, + id_prefix: &'static str, + /// `selectedModelJson` written to the header. `None` leaves it null (claude + /// official has no model selector of its own). + selected_model_json: Option, +} + +impl ImportProviderConfig { + /// Codex: live-agent conversations imported with the `codex` provider. + pub(crate) fn codex(model: &str) -> Self { + Self { + provider_id: "codex", + id_prefix: "codex", + selected_model_json: Some(make_selected_model_json("codex", model)), + } + } + /// Claude Code (`~/.claude/projects`) backed by a builtin provider. + 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)), + } + } + /// Claude official data export: no model selector, lives under its own provider. + pub(crate) fn claude_official() -> Self { + Self { + provider_id: "claude_official", + id_prefix: "claude-official", + selected_model_json: None, + } + } + /// Build the live-agent conversation id `:`. + pub(crate) fn id_prefix_with(&self, session_id: &str) -> String { + format!("{}:{session_id}", self.id_prefix) + } +} + +fn make_selected_model_json(provider_id: &str, model: &str) -> String { + serde_json::json!({ "customProviderId": provider_id, "model": model }).to_string() +} + +/// `import_*_conversation`, unified. Returns the persisted summary plus whether +/// the row was actually inserted (`false` when it was already present, so the +/// caller can skip the history-sync broadcast). +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)) +} + +/// Shared tail of every `import_*` tauri command: filter to selected ids, write +/// each via the unified path, then broadcast history-sync for rows that were +/// actually inserted. `scanned_count` is every conversation the parser produced +/// (selected or not); `skipped_lines` is parser-rejected source rows. Both pass +/// straight through to the result; the DB write only touches selected ids. +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_label = 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!("{} 导入失败:{error}", provider_label))??; + 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, + }) +} + +// ---- small JSON helpers shared by the raw formatters ------------------------ + +/// Trimmed non-empty string, or `None`. +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) +} + +/// RFC-3339 timestamp → epoch millis. +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()) + }) +} + +/// Extract `{type:"text"}` blocks from either a string or an array of content +/// blocks, dropping empty text. Shared by every provider's text extraction. +pub(crate) fn import_text_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) if object.get("type").and_then(Value::as_str) == Some("text") => { + object + .get("text") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + .map(|text| serde_json::json!({ "type": "text", "text": text })) + } + _ => None, + }) + .collect() +} + +/// Build an assistant `message` JSON object with the Anthropic Messages shape. +/// Shared by the Claude Code and Claude-official parsers (both feed Anthropic +/// transcripts); Codex builds its own `openai-responses` variant locally. +pub(crate) fn claude_code_assistant( + id: String, + content: Vec, + model: &str, + timestamp: i64, + stop_reason: &str, +) -> Value { + serde_json::json!({ + "role": "assistant", + "id": id, + "content": content, + "api": "anthropic-messages", + "provider": "claude_code", + "model": model, + "usage": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0 }, + "stopReason": stop_reason, + "timestamp": timestamp + }) +} + +#[cfg(test)] +mod import_tests { + use super::*; + + #[test] + fn selected_model_json_pairs_custom_provider_with_model() { + let json = make_selected_model_json("codex", "gpt-5"); + let value: Value = serde_json::from_str(&json).unwrap(); + assert_eq!(value["customProviderId"], "codex"); + assert_eq!(value["model"], "gpt-5"); + } +} \ No newline at end of file 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 7c1e42f29..d6ec1a227 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,9 +37,10 @@ include!("share.rs"); include!("search.rs"); include!("commands.rs"); include!("replace.rs"); -include!("codex_import.rs"); -include!("claude_code_import.rs"); -include!("claude_official_import.rs"); +mod import; +pub(crate) mod codex_import; +pub(crate) mod claude_code_import; +pub(crate) mod claude_official_import; include!("branch.rs"); include!("delete.rs"); include!("tests.rs"); diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs index 3af363f41..4ebf12395 100644 --- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs @@ -1,5 +1,7 @@ #[cfg(test)] mod tests { + use super::codex_import::{convert_codex_file, codex_remap_cwd}; + use super::import::*; use super::*; use serde_json::json; @@ -2604,8 +2606,13 @@ mod tests { .expect("write rollout"); let titles = HashMap::from([("session-1".to_string(), "Imported title".to_string())]); - let (conversation, skipped) = - convert_codex_file(&path, &titles, temp.path()).expect("convert rollout"); + let (conversation, skipped) = convert_codex_file( + &path, + &titles, + std::path::Path::new("/nonexistent/Codex"), + std::path::Path::new("/nonexistent/default-project"), + ) + .expect("convert rollout"); let conversation = conversation.expect("conversation"); assert_eq!(skipped, 1); @@ -2623,29 +2630,36 @@ mod tests { #[test] fn codex_temporary_workdir_uses_default_project() { - let home = std::path::Path::new("/Users/tester"); + let codex_temp = std::path::Path::new("/Users/tester/Documents/Codex"); + let default_project = std::path::Path::new("/Users/tester/.liveagent/default-project"); assert_eq!( - codex_import_cwd(Some("/Users/tester/Documents/Codex".to_string()), home).as_deref(), + codex_remap_cwd(Some("/Users/tester/Documents/Codex".to_string()), codex_temp, default_project).as_deref(), Some("/Users/tester/.liveagent/default-project") ); assert_eq!( - codex_import_cwd( + codex_remap_cwd( Some("/Users/tester/Documents/Codex/session-1".to_string()), - home + codex_temp, + default_project ) .as_deref(), Some("/Users/tester/.liveagent/default-project") ); assert_eq!( - codex_import_cwd(Some("/Users/tester/Projects/app".to_string()), home).as_deref(), + codex_remap_cwd(Some("/Users/tester/Projects/app".to_string()), codex_temp, default_project).as_deref(), Some("/Users/tester/Projects/app") ); + assert_eq!( + codex_remap_cwd(None, codex_temp, default_project).as_deref(), + None + ); } #[test] fn codex_import_skips_an_existing_conversation() { let mut conn = open_test_db().expect("open test db"); - let conversation = CodexConversation { + let config = ImportProviderConfig::codex("codex"); + let conversation = ImportConversation { id: "codex:session-1".to_string(), session_id: "session-1".to_string(), title: "Imported".to_string(), @@ -2659,13 +2673,15 @@ mod tests { "content": [{ "type": "text", "text": "Hello" }], "timestamp": 1_700_000_000_000_i64 })], + message_count: 1, + already_imported: false, }; - let (_, inserted) = import_codex_conversation(&mut conn, conversation).expect("first import"); + let (_, inserted) = import_conversation(&config, &mut conn, conversation).expect("first import"); assert!(inserted); rename_chat_history_sync(&conn, "codex:session-1", "Keep my title").expect("rename"); - let duplicate = CodexConversation { + let duplicate = ImportConversation { id: "codex:session-1".to_string(), session_id: "session-1".to_string(), title: "Overwrite".to_string(), @@ -2679,9 +2695,11 @@ mod tests { "content": [{ "type": "text", "text": "New" }], "timestamp": 1_700_000_000_200_i64 })], + message_count: 1, + already_imported: false, }; let (summary, inserted) = - import_codex_conversation(&mut conn, duplicate).expect("duplicate import"); + import_conversation(&config, &mut conn, duplicate).expect("duplicate import"); assert!(!inserted); assert_eq!(summary.title, "Keep my title"); diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index fe88a40a7..0663c64c7 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -49,12 +49,12 @@ 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::chat_history_scan_codex, - commands::chat_history::chat_history_import_codex, - commands::chat_history::chat_history_scan_claude_code, - commands::chat_history::chat_history_import_claude_code, - commands::chat_history::chat_history_scan_claude_official, - commands::chat_history::chat_history_import_claude_official, + 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::claude_official_import::chat_history_scan_claude_official, + commands::chat_history::claude_official_import::chat_history_import_claude_official, commands::chat_history::chat_history_rename, commands::chat_history::chat_history_branch, commands::chat_history::chat_history_replace_from_message, From f6831a180689ba5ca4cfa8406e25b56f30d2052f Mon Sep 17 00:00:00 2001 From: FlowerRealm Date: Wed, 29 Jul 2026 21:44:53 -0700 Subject: [PATCH 09/14] fix(claude): import official chats in chat mode --- .../chat_history/claude_official_import.rs | 61 +++++++------- crates/agent-gui/src/i18n/config.ts | 11 +-- .../pages/settings/ClaudeCodeImportDialog.tsx | 84 +++++++++++-------- .../pages/settings/ConversationsSection.tsx | 2 + 4 files changed, 86 insertions(+), 72 deletions(-) diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_official_import.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_official_import.rs index 3061cc525..65ba1c0cf 100644 --- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_official_import.rs +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_official_import.rs @@ -10,24 +10,6 @@ use std::collections::HashMap; const CLAUDE_OFFICIAL_CONVERSATIONS_ENTRY: &str = "conversations.json"; -fn claude_official_default_cwd() -> Result { - crate::commands::settings::default_project_workdir() -} - -fn claude_official_workspace_cwd(conversation: &Value, default_cwd: &str) -> String { - for key in ["cwd", "source_cwd", "workspace_path", "workspacePath"] { - let Some(cwd) = import_string(conversation.get(key)) else { continue }; - let path = std::path::Path::new(&cwd); - if path.is_absolute() - && !path.starts_with("/home/claude") - && !path.starts_with("/mnt/user-data") - { - return cwd; - } - } - default_cwd.to_string() -} - fn read_claude_official_conversations(path: &std::path::Path) -> Result, String> { if path.extension().and_then(|extension| extension.to_str()) != Some("zip") { return Err("请选择 Claude 官方数据 ZIP 文件".to_string()); @@ -154,9 +136,7 @@ fn claude_official_content_blocks( fn convert_claude_official_conversation( conversation: &Value, - default_cwd: &str, ) -> Option { - let cwd = claude_official_workspace_cwd(conversation, default_cwd); let session_id = import_string(conversation.get("uuid"))?; let created_at = import_timestamp(conversation.get("created_at").and_then(Value::as_str)) .unwrap_or_else(now_ms); @@ -247,7 +227,9 @@ fn convert_claude_official_conversation( session_id, title, model: "claude-official".to_string(), - cwd: Some(cwd), + // Official exports cannot identify a LiveAgent project. All imported + // official conversations belong to Chat mode, never a workspace. + cwd: None, created_at, updated_at, messages, @@ -260,12 +242,11 @@ fn scan_claude_official( zip_path: &std::path::Path, ) -> Result<(Vec, usize, usize), String> { let records = read_claude_official_conversations(zip_path)?; - let default_cwd = claude_official_default_cwd()?; let scanned_count = records.len(); let mut skipped_lines = 0; let mut conversations = Vec::new(); for record in &records { - match convert_claude_official_conversation(record, &default_cwd) { + match convert_claude_official_conversation(record) { Some(conversation) => conversations.push(conversation), None => skipped_lines += 1, } @@ -307,7 +288,7 @@ mod claude_official_import_tests { use super::*; #[test] - fn keeps_explicit_workspace_paths_and_defaults_workspace_less_conversations() { + fn imports_official_conversations_into_chat_mode() { let conversation = serde_json::json!({ "uuid": "conversation-1", "cwd": "/tmp/linked-workspace", "name": "Official conversation", @@ -321,9 +302,9 @@ mod claude_official_import_tests { ], "created_at":"2026-07-29T07:30:28.537983Z"} ] }); - let imported = convert_claude_official_conversation(&conversation, "/tmp/default-project").expect("official conversation should be importable"); + let imported = convert_claude_official_conversation(&conversation).expect("official conversation should be importable"); assert_eq!(imported.id, "claude-official:conversation-1"); - assert_eq!(imported.cwd.as_deref(), Some("/tmp/linked-workspace")); + assert_eq!(imported.cwd, None); assert_eq!(imported.message_count, 3); assert_eq!(imported.messages[0]["role"], "user"); assert_eq!(imported.messages[1]["content"][0]["type"], "thinking"); @@ -334,7 +315,27 @@ mod claude_official_import_tests { } #[test] - fn sends_workspace_less_conversations_to_default_workspace() { + fn ignores_official_workspace_metadata() { + for (key, value) in [ + ("cwd", "/Users/tester/project"), + ("source_cwd", "/Users/tester/project"), + ("workspace_path", "/Users/tester/project"), + ("workspacePath", "/Users/tester/project"), + ("cwd", "/home/claude/project"), + ] { + let conversation = serde_json::json!({ + "uuid": format!("conversation-{key}"), + key: value, + "chat_messages": [{"uuid":"user-1", "sender":"human", "text":"Hello"}] + }); + let imported = convert_claude_official_conversation(&conversation) + .expect("official conversation should be importable"); + assert_eq!(imported.cwd, None, "{key} must not create a workspace"); + } + } + + #[test] + fn sends_workspace_less_conversations_to_chat_mode() { let conversation = serde_json::json!({ "uuid": "workspace-less-conversation", "chat_messages": [{ @@ -345,14 +346,14 @@ mod claude_official_import_tests { }] }); - let imported = convert_claude_official_conversation(&conversation, "/tmp/default-project") + let imported = convert_claude_official_conversation(&conversation) .expect("workspace-less conversation should be importable"); - assert_eq!(imported.cwd.as_deref(), Some("/tmp/default-project")); + assert_eq!(imported.cwd, None); } #[test] fn skips_official_conversation_without_displayable_messages() { let conversation = serde_json::json!({"uuid":"empty-conversation", "name":"", "chat_messages":[]}); - assert!(convert_claude_official_conversation(&conversation, "/tmp/default-project").is_none()); + assert!(convert_claude_official_conversation(&conversation).is_none()); } } \ No newline at end of file diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 648a6fdac..819b9d722 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -147,9 +147,8 @@ export const translations: Record> = { "chat.history.claudeOfficialImportFailed": "Claude 官方对话导入失败", "chat.history.claudeOfficialImportDialogTitle": "导入 Claude 官方对话", "chat.history.claudeOfficialImportDialogSubtitle": - "从官方 Claude 数据 ZIP 中选择需要导入的对话;会话将全部归入 Default 工作区。", + "从官方 Claude 数据 ZIP 中选择需要导入的对话;会话将归入 Chat 模式。", "chat.history.claudeOfficialImportDialogEmpty": "没有可导入的 Claude 官方对话。", - "chat.history.claudeOfficialImportDialogDefaultWorkspace": "Default 工作区", "chat.history.claudeCodeImporting": "正在导入 Claude Code 对话…", "chat.history.claudeCodeImportFailed": "Claude Code 对话导入失败", "chat.history.claudeCodeImportDialogTitle": "导入 Claude Code 对话", @@ -989,8 +988,7 @@ export const translations: Record> = { "settings.claudeImportResult": "已导入 {imported} 个会话,共读取 {scanned} 个 Claude 会话。", "settings.claudeCodeImportTitle": "Claude Code 对话", "settings.claudeOfficialImportTitle": "Claude 官方对话", - "settings.claudeOfficialImportDescription": - "选择 Claude 官方数据 ZIP;会话会全部导入 LiveAgent 的 Default 工作区。", + "settings.claudeOfficialImportDescription": "选择 Claude 官方数据 ZIP;会话将归入 Chat 模式。", "settings.claudeOfficialImportResult": "已导入 {imported} 个会话,共读取 {scanned} 个 Claude 官方对话。", "settings.claudeCodeImportDescription": @@ -2472,9 +2470,8 @@ export const translations: Record> = { "chat.history.claudeOfficialImportFailed": "Failed to import official Claude conversations", "chat.history.claudeOfficialImportDialogTitle": "Import official Claude conversations", "chat.history.claudeOfficialImportDialogSubtitle": - "Choose conversations from an official Claude data ZIP. All conversations are imported into the Default workspace.", + "Choose conversations from an official Claude data ZIP. They are imported into Chat mode.", "chat.history.claudeOfficialImportDialogEmpty": "No official Claude conversations to import.", - "chat.history.claudeOfficialImportDialogDefaultWorkspace": "Default workspace", "chat.history.claudeCodeImporting": "Importing Claude Code conversations…", "chat.history.claudeCodeImportFailed": "Failed to import Claude Code conversations", "chat.history.claudeCodeImportDialogTitle": "Import Claude Code conversations", @@ -3347,7 +3344,7 @@ export const translations: Record> = { "settings.claudeCodeImportTitle": "Claude Code conversations", "settings.claudeOfficialImportTitle": "Official Claude conversations", "settings.claudeOfficialImportDescription": - "Choose an official Claude data ZIP. All imported conversations go to LiveAgent's Default workspace.", + "Choose an official Claude data ZIP. Conversations are imported into Chat mode.", "settings.claudeOfficialImportResult": "Imported {imported} conversations from {scanned} official Claude conversations.", "settings.claudeCodeImportDescription": diff --git a/crates/agent-gui/src/pages/settings/ClaudeCodeImportDialog.tsx b/crates/agent-gui/src/pages/settings/ClaudeCodeImportDialog.tsx index cbcbeff2e..91b1b4449 100644 --- a/crates/agent-gui/src/pages/settings/ClaudeCodeImportDialog.tsx +++ b/crates/agent-gui/src/pages/settings/ClaudeCodeImportDialog.tsx @@ -15,14 +15,16 @@ type ClaudeCodeImportDialogProps = { preview: ClaudeCodeImportPreview | ClaudeOfficialImportPreview; onClose: () => void; onConfirm: (ids: string[]) => void; - defaultWorkspaceOnly?: boolean; + /// 官方导入没有 LiveAgent 工作区语义,始终汇入 Chat 模式;本机 Claude + /// Code 导入则保留其实际 cwd 分组。`variant` 也控制标题与副标题。 + variant: "claude-code" | "claude-official"; }; const NO_CWD_KEY = "__liveagent_no_cwd__"; -const DEFAULT_WORKSPACE_KEY = "__liveagent_default_workspace__"; +const CHAT_MODE_KEY = "__liveagent_chat_mode__"; function workspaceLabel(cwd: string): string { - if (cwd === NO_CWD_KEY) return ""; + if (cwd === NO_CWD_KEY || cwd === CHAT_MODE_KEY) return ""; // 显示路径末段,title 上带完整路径 const segments = cwd.split(/[\\/]/).filter(Boolean); return segments[segments.length - 1] ?? cwd; @@ -32,19 +34,14 @@ export function ClaudeCodeImportDialog({ preview, onClose, onConfirm, - defaultWorkspaceOnly = false, + variant, }: ClaudeCodeImportDialogProps) { const { t } = useLocale(); const { modalState, requestClose } = useModalMotion(onClose); const groups = useMemo(() => { - if (defaultWorkspaceOnly) { - return [ - ["__liveagent_default_workspace__", preview.sessions] as [ - string, - ClaudeCodeImportPreviewSession[], - ], - ]; + if (variant === "claude-official") { + return [[CHAT_MODE_KEY, preview.sessions] as [string, ClaudeCodeImportPreviewSession[]]]; } const map = new Map(); for (const session of preview.sessions) { @@ -53,14 +50,14 @@ export function ClaudeCodeImportDialog({ if (list) list.push(session); else map.set(key, [session]); } - // 按会话数倒序,default-project 置顶 + // 按会话数倒序;无 cwd 的小组垫底 return Array.from(map.entries()).sort((a, b) => { - const aIsDefault = a[0].includes("default-project"); - const bIsDefault = b[0].includes("default-project"); - if (aIsDefault !== bIsDefault) return aIsDefault ? -1 : 1; + const aIsNoCwd = a[0] === NO_CWD_KEY; + const bIsNoCwd = b[0] === NO_CWD_KEY; + if (aIsNoCwd !== bIsNoCwd) return aIsNoCwd ? 1 : -1; return b[1].length - a[1].length; }); - }, [preview.sessions, defaultWorkspaceOnly]); + }, [preview.sessions, variant]); const [activeCwdKey, setActiveCwdKey] = useState(groups[0]?.[0] ?? null); @@ -128,16 +125,20 @@ export function ClaudeCodeImportDialog({
- + {variant === "claude-official" ? ( + + ) : ( + + )}

- {defaultWorkspaceOnly + {variant === "claude-official" ? t("chat.history.claudeOfficialImportDialogTitle") : t("chat.history.claudeCodeImportDialogTitle")}

- {defaultWorkspaceOnly + {variant === "claude-official" ? t("chat.history.claudeOfficialImportDialogSubtitle") : t("chat.history.claudeCodeImportDialogSubtitle")}

@@ -157,7 +158,11 @@ export function ClaudeCodeImportDialog({ {/* 左栏:工作区 */}
- {t("chat.history.claudeCodeImportDialogWorkspaces")} + + {variant === "claude-official" + ? t("settings.executionMode") + : t("chat.history.claudeCodeImportDialogWorkspaces")} + - + {variant === "claude-official" ? ( + + ) : ( + + )} - {key === NO_CWD_KEY - ? t("chat.history.claudeCodeImportDialogNoWorkspace") - : key === DEFAULT_WORKSPACE_KEY - ? t("chat.history.claudeOfficialImportDialogDefaultWorkspace") + {key === CHAT_MODE_KEY + ? t("settings.chatMode") + : key === NO_CWD_KEY + ? t("chat.history.claudeCodeImportDialogNoWorkspace") : workspaceLabel(key)} - {key !== NO_CWD_KEY && key !== DEFAULT_WORKSPACE_KEY ? ( + {key !== NO_CWD_KEY && key !== CHAT_MODE_KEY ? (
- {activeCwdKey === NO_CWD_KEY - ? t("chat.history.claudeCodeImportDialogNoWorkspace") - : activeCwdKey === DEFAULT_WORKSPACE_KEY - ? t("chat.history.claudeOfficialImportDialogDefaultWorkspace") + {activeCwdKey === CHAT_MODE_KEY + ? t("settings.chatMode") + : activeCwdKey === NO_CWD_KEY + ? t("chat.history.claudeCodeImportDialogNoWorkspace") : (activeCwdKey ?? "")} {activeSessions.length > 0 ? ( diff --git a/crates/agent-gui/src/pages/settings/ConversationsSection.tsx b/crates/agent-gui/src/pages/settings/ConversationsSection.tsx index 5516c8f7a..530583e92 100644 --- a/crates/agent-gui/src/pages/settings/ConversationsSection.tsx +++ b/crates/agent-gui/src/pages/settings/ConversationsSection.tsx @@ -220,6 +220,7 @@ export function ConversationsSection() { preview={dialog.preview as ClaudeOfficialImportPreview} onClose={() => setDialog(null)} onConfirm={(ids) => void handleConfirm("claude-official", ids, dialog.zipPath)} + variant="claude-official" /> ) : null} {dialog?.source === "claude-code" ? ( @@ -227,6 +228,7 @@ export function ConversationsSection() { preview={dialog.preview as ClaudeCodeImportPreview} onClose={() => setDialog(null)} onConfirm={(ids) => void handleConfirm("claude-code", ids)} + variant="claude-code" /> ) : null}
From 89450050386b23089054809a1ecba973d463747c Mon Sep 17 00:00:00 2001 From: FlowerRealm Date: Thu, 30 Jul 2026 00:52:17 -0700 Subject: [PATCH 10/14] refactor(chat-history): unify import dialog and message builders --- .../chat_history/claude_code_import.rs | 236 +++++------ .../chat_history/claude_official_import.rs | 332 ++++++---------- .../history/chat_history/codex_import.rs | 368 ++++++++--------- .../commands/history/chat_history/import.rs | 305 +++++++++++---- .../src/commands/history/chat_history/mod.rs | 6 +- .../commands/history/chat_history/tests.rs | 130 ------ .../src/lib/chat/history/chatHistory.ts | 76 ++-- .../lib/chat/history/useImportSelection.ts | 76 ++++ .../pages/settings/ClaudeCodeImportDialog.tsx | 369 ------------------ .../pages/settings/ConversationsSection.tsx | 43 +- ...CodexImportDialog.tsx => ImportDialog.tsx} | 270 +++++++------ 11 files changed, 902 insertions(+), 1309 deletions(-) create mode 100644 crates/agent-gui/src/lib/chat/history/useImportSelection.ts delete mode 100644 crates/agent-gui/src/pages/settings/ClaudeCodeImportDialog.tsx rename crates/agent-gui/src/pages/settings/{CodexImportDialog.tsx => ImportDialog.tsx} (57%) 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 index bef9ced2a..8f31ec089 100644 --- 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 @@ -1,8 +1,7 @@ // Claude Code importer. Locates & parses `~/.claude/projects/**` depth-2 JSONL -// into [`ImportConversation`]. The shared struct, DB write, command shell and -// the `claude_code_assistant` formatter all live in `super::import`; this -// module just supplies the Claude Code-specific scan + parse plus two tauri -// commands wired to that core. +// 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::*; @@ -18,16 +17,16 @@ fn claude_code_is_internal_user_message(row: &Value, content: &str) -> bool { || content.starts_with("") } -fn claude_code_tool_result_blocks(value: Option<&Value>) -> Vec { - import_text_blocks(value) -} - 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)) + .or_else(|| { + path.file_stem() + .and_then(|name| name.to_str()) + .map(str::to_string) + }) } fn convert_claude_code_file( @@ -35,16 +34,7 @@ fn convert_claude_code_file( ) -> Result<(Option, usize), String> { let text = std::fs::read_to_string(path) .map_err(|error| format!("读取 Claude Code 会话失败:{}: {error}", path.display()))?; - 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, - } - } + let (rows, skipped_lines) = parse_jsonl_lines(&text); if rows.is_empty() { return Ok((None, skipped_lines)); } @@ -64,16 +54,16 @@ fn convert_claude_code_file( 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::new(); - let mut first_user_text = None; + 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() { @@ -82,115 +72,113 @@ fn convert_claude_code_file( } 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 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") => { - let Some(message) = row.get("message") else { 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) { + if claude_code_is_internal_user_message(row, text) || text.trim().is_empty() { continue; } - if text.trim().is_empty() { - continue; - } - first_user_text.get_or_insert_with(|| text.to_string()); - messages.push(serde_json::json!({ - "role": "user", - "id": format!("{session_id}:{entry_id}"), - "content": [{ "type": "text", "text": text }], - "timestamp": timestamp - })); + 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); + let Some(blocks) = content.and_then(Value::as_array) else { + continue; + }; + let text_blocks = import_text_blocks(content, &["text"]); if !text_blocks.is_empty() { - let text = text_blocks - .iter() - .filter_map(|block| block.get("text").and_then(Value::as_str)) - .collect::>() - .join("\n"); - first_user_text.get_or_insert(text); - messages.push(serde_json::json!({ - "role": "user", - "id": format!("{session_id}:{entry_id}"), - "content": text_blocks, - "timestamp": timestamp - })); + 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(|| format!("{session_id}:{entry_id}")); + 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(serde_json::json!({ - "role": "toolResult", - "toolCallId": tool_call_id, - "toolName": tool_name, - "content": claude_code_tool_result_blocks(block.get("content")), - "isError": block.get("is_error").and_then(Value::as_bool).unwrap_or(false), - "timestamp": timestamp - })); + 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(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; }; - let Some(blocks) = message.get("content").and_then(Value::as_array) else { continue }; for (block_index, block) in blocks.iter().enumerate() { - let id = format!("{session_id}:{entry_id}:{block_index}"); + 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(claude_code_assistant( + if let Some(text) = block + .get("text") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + { + messages.push(assistant_message( id, - vec![serde_json::json!({ "type": "text", "text": text })], + &config, &message_model, - timestamp, + 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(claude_code_assistant( + if let Some(thinking) = block + .get("thinking") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + { + messages.push(assistant_message( id, - vec![serde_json::json!({ "type": "thinking", "thinking": thinking })], + &config, &message_model, - timestamp, + 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()); + 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(claude_code_assistant( - id, - vec![serde_json::json!({ - "type": "toolCall", - "id": tool_call_id, - "name": tool_name, - "arguments": block.get("input").cloned().unwrap_or_else(|| serde_json::json!({})) - })], - &message_model, - timestamp, - "toolUse", - )); + 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)); } _ => {} } @@ -199,30 +187,17 @@ fn convert_claude_code_file( _ => {} } } - if messages.is_empty() { - return Ok((None, skipped_lines)); - } - 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!("Claude Code session {session_id}")); - let message_count = messages.len(); Ok(( - Some(ImportConversation { - id: ImportProviderConfig::claude_code(&model).id_prefix_with(&session_id), + finalize_import_conversation( + &config, session_id, - title, model, - // Desktop Chat/Cowork has no public Claude Code transcript format. For a - // transcript that genuinely lacks cwd, preserve None so the UI groups it as - // no workspace instead of inventing a project directory. cwd, - created_at: created_at.unwrap_or(last_timestamp), - updated_at: last_timestamp, + created_at.unwrap_or(last_timestamp), + last_timestamp, + title, messages, - message_count, - already_imported: false, - }), + ), skipped_lines, )) } @@ -236,9 +211,15 @@ fn scan_claude_code_sessions() -> Result<(Vec, usize, usize) 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) { + 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") { + if !entry.file_type().is_file() + || path.extension().and_then(|value| value.to_str()) != Some("jsonl") + { continue; } scanned += 1; @@ -257,21 +238,7 @@ fn scan_claude_code_sessions() -> Result<(Vec, usize, usize) #[tauri::command] pub async fn chat_history_scan_claude_code() -> Result { - let (sessions, scanned_count, skipped_lines) = tauri::async_runtime::spawn_blocking(|| -> Result<_, String> { - let (conversations, scanned_count, skipped_lines) = scan_claude_code_sessions()?; - 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!("Claude Code 扫描失败:{error}"))??; - Ok(ImportPreview { sessions, scanned_count, skipped_lines }) + scan_preview_command(scan_claude_code_sessions, "Claude Code").await } #[tauri::command] @@ -279,10 +246,11 @@ pub async fn chat_history_import_claude_code( gateway_controller: tauri::State<'_, Arc>, ids: Vec, ) -> Result { - 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_claude_code_sessions) - .await - .map_err(|error| format!("Claude Code 扫描失败:{error}"))??; - run_import(ImportProviderConfig::claude_code("claude_code"), conversations, skipped_lines, selected, &gateway_controller).await -} \ No newline at end of file + 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/claude_official_import.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_official_import.rs index 65ba1c0cf..131009ed4 100644 --- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_official_import.rs +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_official_import.rs @@ -1,12 +1,13 @@ // Claude official importer. Locates & parses `conversations.json` inside a // user-selected Claude data-export ZIP into [`ImportConversation`]. The shared -// struct, DB write, command shell and the `claude_code_assistant` formatter all -// live in `super::import`; this module just supplies the ZIP-specific scan + -// parse plus two tauri commands wired to that core. +// struct, DB write and message builders live in `super::import`; this module +// just supplies the ZIP-specific scan + parse plus two tauri commands wired to +// that core. use super::import::*; use super::*; use std::collections::HashMap; +use std::sync::Arc; const CLAUDE_OFFICIAL_CONVERSATIONS_ENTRY: &str = "conversations.json"; @@ -14,7 +15,6 @@ fn read_claude_official_conversations(path: &std::path::Path) -> Result Result Vec { .into_iter() .flatten() { - let Some(name) = import_string(file.get("file_name")) else { continue }; + let Some(name) = import_string(file.get("file_name")) else { + continue; + }; blocks.push(serde_json::json!({ "type": "text", "text": format!("[Imported file reference: {name}; the original file was not included in the Claude official data]") @@ -81,70 +82,15 @@ fn claude_official_attachment_text(message: &Value) -> Vec { blocks } -fn claude_official_content_blocks( - value: Option<&Value>, - tool_names: &mut HashMap, -) -> (Vec, Vec) { - let mut assistant_blocks = Vec::new(); - let mut tool_results = Vec::new(); - for block in value.and_then(Value::as_array).into_iter().flatten() { - match block.get("type").and_then(Value::as_str) { - Some("text") => { - if let Some(text) = import_string(block.get("text")) { - assistant_blocks.push(serde_json::json!({ "type": "text", "text": text })); - } - } - Some("thinking") => { - if let Some(thinking) = import_string(block.get("thinking")) { - assistant_blocks.push(serde_json::json!({ "type": "thinking", "thinking": thinking })); - } - } - Some("tool_use") => { - let id = import_string(block.get("id")) - .unwrap_or_else(|| format!("claude-official-tool-{}", tool_names.len())); - let name = import_string(block.get("name")) - .unwrap_or_else(|| "claude_official_tool".to_string()); - tool_names.insert(id.clone(), name.clone()); - assistant_blocks.push(serde_json::json!({ - "type": "toolCall", - "id": id, - "name": name, - "arguments": block.get("input").cloned().unwrap_or_else(|| serde_json::json!({})) - })); - } - Some("tool_result") => { - let tool_call_id = import_string(block.get("tool_use_id")) - .or_else(|| import_string(block.get("tool_call_id"))) - .unwrap_or_else(|| format!("claude-official-tool-result-{}", tool_results.len())); - let tool_name = tool_names - .get(&tool_call_id) - .cloned() - .unwrap_or_else(|| "claude_official_tool".to_string()); - tool_results.push(serde_json::json!({ - "role": "toolResult", - "toolCallId": tool_call_id, - "toolName": tool_name, - "content": import_text_blocks(block.get("content")), - "isError": block.get("is_error").and_then(Value::as_bool).unwrap_or(false) - })); - } - _ => {} - } - } - (assistant_blocks, tool_results) -} - -fn convert_claude_official_conversation( - conversation: &Value, -) -> Option { +fn convert_claude_official_conversation(conversation: &Value) -> Option { let session_id = import_string(conversation.get("uuid"))?; let created_at = import_timestamp(conversation.get("created_at").and_then(Value::as_str)) .unwrap_or_else(now_ms); let mut updated_at = import_timestamp(conversation.get("updated_at").and_then(Value::as_str)) .unwrap_or(created_at); + let config = ImportProviderConfig::claude_official(); let mut messages = Vec::new(); - let mut tool_names = HashMap::new(); - let mut first_user_text = None; + let mut tool_names: HashMap = HashMap::new(); for (index, message) in conversation .get("chat_messages") @@ -156,11 +102,12 @@ fn convert_claude_official_conversation( let timestamp = import_timestamp(message.get("created_at").and_then(Value::as_str)) .unwrap_or(updated_at); updated_at = updated_at.max(timestamp); - let message_id = import_string(message.get("uuid")) - .unwrap_or_else(|| format!("{session_id}:{index}")); + let message_id = + import_string(message.get("uuid")).unwrap_or_else(|| format!("{session_id}:{index}")); + let event_id = format!("{session_id}:{message_id}"); match message.get("sender").and_then(Value::as_str) { Some("human") => { - let mut content = import_text_blocks(message.get("text")); + let mut content = import_text_blocks(message.get("text"), &["text"]); if content.is_empty() { content = message .get("content") @@ -168,74 +115,122 @@ fn convert_claude_official_conversation( .into_iter() .flatten() .filter(|block| block.get("type").and_then(Value::as_str) == Some("text")) - .filter_map(|block| import_string(block.get("text"))) - .map(|text| serde_json::json!({ "type": "text", "text": text })) + .filter_map(|block| { + import_string(block.get("text")) + .map(|text| serde_json::json!({ "type": "text", "text": text })) + }) .collect(); } content.extend(claude_official_attachment_text(message)); - if content.is_empty() { - continue; + if !content.is_empty() { + messages.push(user_message(event_id, content, timestamp)); } - let text = content - .iter() - .filter_map(|block| block.get("text").and_then(Value::as_str)) - .collect::>() - .join("\n"); - first_user_text.get_or_insert(text); - messages.push(serde_json::json!({ - "role": "user", - "id": format!("{session_id}:{message_id}"), - "content": content, - "timestamp": timestamp - })); } Some("assistant") => { - let (mut content, mut tool_results) = - claude_official_content_blocks(message.get("content"), &mut tool_names); - if content.is_empty() { - content = import_text_blocks(message.get("text")); + let mut assistant_blocks = Vec::new(); + let mut tool_results = Vec::new(); + for block in message + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + match block.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = import_string(block.get("text")) { + assistant_blocks + .push(serde_json::json!({ "type": "text", "text": text })); + } + } + Some("thinking") => { + if let Some(thinking) = import_string(block.get("thinking")) { + assistant_blocks.push( + serde_json::json!({ "type": "thinking", "thinking": thinking }), + ); + } + } + Some("tool_use") => { + let id = import_string(block.get("id")).unwrap_or_else(|| { + format!("claude-official-tool-{}", tool_names.len()) + }); + let name = import_string(block.get("name")) + .unwrap_or_else(|| "claude_official_tool".to_string()); + tool_names.insert(id.clone(), name.clone()); + assistant_blocks.push(serde_json::json!({ "type": "toolCall", "id": id, "name": name, "arguments": block.get("input").cloned().unwrap_or_else(|| serde_json::json!({})) })); + } + Some("tool_result") => { + let tool_call_id = import_string(block.get("tool_use_id")) + .or_else(|| import_string(block.get("tool_call_id"))) + .unwrap_or_else(|| { + format!("claude-official-tool-result-{}", tool_results.len()) + }); + let tool_name = tool_names + .get(&tool_call_id) + .cloned() + .unwrap_or_else(|| "claude_official_tool".to_string()); + tool_results.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, + )); + } + _ => {} + } } - if !content.is_empty() { - messages.push(claude_code_assistant( - format!("{session_id}:{message_id}"), - content, + if assistant_blocks.is_empty() { + if let Some(text) = message + .get("text") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + { + assistant_blocks.push(serde_json::json!({ "type": "text", "text": text })); + } else { + assistant_blocks = message + .get("text") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|block| { + block.get("type").and_then(Value::as_str) == Some("text") + }) + .filter_map(|block| { + import_string(block.get("text")) + .map(|text| serde_json::json!({ "type": "text", "text": text })) + }) + .collect(); + } + } + if !assistant_blocks.is_empty() { + messages.push(assistant_message( + event_id, + &config, "claude-official", - timestamp, + assistant_blocks, "stop", + timestamp, )); } - for result in &mut tool_results { - if let Some(object) = result.as_object_mut() { - object.insert("timestamp".to_string(), serde_json::json!(timestamp)); - } - messages.push(result.clone()); - } + messages.extend(tool_results); } _ => {} } } - - if messages.is_empty() { - return None; - } - let title = import_string(conversation.get("name")) - .or_else(|| first_user_text.map(|text| text.chars().take(80).collect())) - .unwrap_or_else(|| format!("Claude conversation {session_id}")); - let message_count = messages.len(); - Some(ImportConversation { - id: ImportProviderConfig::claude_official().id_prefix_with(&session_id), + finalize_import_conversation( + &config, session_id, - title, - model: "claude-official".to_string(), - // Official exports cannot identify a LiveAgent project. All imported - // official conversations belong to Chat mode, never a workspace. - cwd: None, + "claude-official".to_string(), + None, created_at, updated_at, + import_string(conversation.get("name")), messages, - message_count, - already_imported: false, - }) + ) } fn scan_claude_official( @@ -257,16 +252,12 @@ fn scan_claude_official( #[tauri::command] pub async fn chat_history_scan_claude_official(zip_path: String) -> Result { - let (sessions, scanned_count, skipped_lines) = tauri::async_runtime::spawn_blocking(move || { - let (conversations, scanned_count, skipped_lines) = scan_claude_official(std::path::Path::new(&zip_path))?; - 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!("Claude 官方数据扫描失败:{error}"))??; - Ok(ImportPreview { sessions, scanned_count, skipped_lines }) + let path = zip_path.clone(); + scan_preview_command( + move || scan_claude_official(std::path::Path::new(&path)), + "Claude 官方数据", + ) + .await } #[tauri::command] @@ -275,85 +266,12 @@ pub async fn chat_history_import_claude_official( zip_path: String, ids: Vec, ) -> Result { - let selected: HashSet = ids.into_iter().filter(|id| !id.trim().is_empty()).collect(); - let (conversations, _scanned_count, skipped_lines) = - tauri::async_runtime::spawn_blocking(move || scan_claude_official(std::path::Path::new(&zip_path))) - .await - .map_err(|error| format!("Claude 官方数据导入失败:{error}"))??; - run_import(ImportProviderConfig::claude_official(), conversations, skipped_lines, selected, &gateway_controller).await + let path = zip_path.clone(); + import_selected_command( + move || scan_claude_official(std::path::Path::new(&path)), + ImportProviderConfig::claude_official(), + ids, + &gateway_controller, + ) + .await } - -#[cfg(test)] -mod claude_official_import_tests { - use super::*; - - #[test] - fn imports_official_conversations_into_chat_mode() { - let conversation = serde_json::json!({ - "uuid": "conversation-1", - "cwd": "/tmp/linked-workspace", "name": "Official conversation", - "created_at": "2026-07-29T07:30:25.134893Z", "updated_at": "2026-07-29T07:30:28.537983Z", - "chat_messages": [ - {"uuid":"user-1", "sender":"human", "text":"Hello", "content":[], "attachments":[], "files":[], "created_at":"2026-07-29T07:30:25.134893Z"}, - {"uuid":"assistant-1", "sender":"assistant", "text":"", "content":[ - {"type":"thinking", "thinking":"I should answer."}, {"type":"text", "text":"Hi"}, - {"type":"tool_use", "id":"tool-1", "name":"view", "input":{"path":"README.md"}}, - {"type":"tool_result", "tool_use_id":"tool-1", "content":"contents", "is_error":false} - ], "created_at":"2026-07-29T07:30:28.537983Z"} - ] - }); - let imported = convert_claude_official_conversation(&conversation).expect("official conversation should be importable"); - assert_eq!(imported.id, "claude-official:conversation-1"); - assert_eq!(imported.cwd, None); - assert_eq!(imported.message_count, 3); - assert_eq!(imported.messages[0]["role"], "user"); - assert_eq!(imported.messages[1]["content"][0]["type"], "thinking"); - assert_eq!(imported.messages[1]["content"][1]["type"], "text"); - assert_eq!(imported.messages[1]["content"][2]["type"], "toolCall"); - assert_eq!(imported.messages[2]["role"], "toolResult"); - assert_eq!(imported.messages[2]["toolCallId"], "tool-1"); - } - - #[test] - fn ignores_official_workspace_metadata() { - for (key, value) in [ - ("cwd", "/Users/tester/project"), - ("source_cwd", "/Users/tester/project"), - ("workspace_path", "/Users/tester/project"), - ("workspacePath", "/Users/tester/project"), - ("cwd", "/home/claude/project"), - ] { - let conversation = serde_json::json!({ - "uuid": format!("conversation-{key}"), - key: value, - "chat_messages": [{"uuid":"user-1", "sender":"human", "text":"Hello"}] - }); - let imported = convert_claude_official_conversation(&conversation) - .expect("official conversation should be importable"); - assert_eq!(imported.cwd, None, "{key} must not create a workspace"); - } - } - - #[test] - fn sends_workspace_less_conversations_to_chat_mode() { - let conversation = serde_json::json!({ - "uuid": "workspace-less-conversation", - "chat_messages": [{ - "uuid": "user-1", - "sender": "human", - "text": "Hello", - "created_at": "2026-07-29T07:30:25.134893Z" - }] - }); - - let imported = convert_claude_official_conversation(&conversation) - .expect("workspace-less conversation should be importable"); - assert_eq!(imported.cwd, None); - } - - #[test] - fn skips_official_conversation_without_displayable_messages() { - let conversation = serde_json::json!({"uuid":"empty-conversation", "name":"", "chat_messages":[]}); - assert!(convert_claude_official_conversation(&conversation).is_none()); - } -} \ No newline at end of file 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 index 9bca60219..6ea8cc77d 100644 --- 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 @@ -1,98 +1,71 @@ // Codex importer. Knows only how to locate & parse `~/.codex/sessions/**/rollout-*.jsonl` -// into [`ImportConversation`]. The shared struct, DB write and command shell +// 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, HashSet}; +use std::collections::HashMap; use std::sync::Arc; -fn codex_text_blocks(value: Option<&Value>, input: bool) -> Vec { - let mut blocks = Vec::new(); - let Some(value) = value else { return blocks }; - let values = value.as_array().cloned().unwrap_or_else(|| vec![value.clone()]); - for item in values { - match item { - Value::String(text) if !text.is_empty() => { - blocks.push(serde_json::json!({ "type": "text", "text": text })); - } - Value::Object(object) => { - let kind = object.get("type").and_then(Value::as_str).unwrap_or_default(); - let accepted = if input { kind == "input_text" } else { kind == "output_text" || kind == "input_text" }; - if accepted { - if let Some(text) = object.get("text").and_then(Value::as_str) { - blocks.push(serde_json::json!({ "type": "text", "text": text })); - } - } - } - _ => {} - } - } - blocks -} - fn codex_arguments(value: Option<&Value>) -> Value { - let Some(raw) = value.and_then(Value::as_str) else { return serde_json::json!({}) }; + 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()]); - let mut blocks = Vec::new(); - for item in values { - match item { - Value::String(text) => blocks.push(serde_json::json!({ "type": "text", "text": text })), - Value::Object(object) => { - if object.get("type").and_then(Value::as_str) == Some("text") { - if let Some(text) = object.get("text").and_then(Value::as_str) { - blocks.push(serde_json::json!({ "type": "text", "text": text })); - } - } else if let Some(text) = object.get("text").and_then(Value::as_str) { - blocks.push(serde_json::json!({ "type": "text", "text": text })); - } + 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 })) } - _ => {} - } - } - blocks -} - -fn codex_assistant( - id: String, - content: Vec, - model: &str, - timestamp: i64, - stop_reason: &str, -) -> Value { - serde_json::json!({ - "role": "assistant", - "id": id, - "content": content, - "api": "openai-responses", - "provider": "codex", - "model": model, - "usage": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0 }, - "stopReason": stop_reason, - "timestamp": timestamp - }) + 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))) + .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")))) + .and_then(|payload| { + import_string(payload.get("session_id")).or_else(|| import_string(payload.get("id"))) + }) } -/// Remap a Codex `cwd` to a live-agent workdir. Sessions Codex ran inside its -/// own sandbox temp dir (`/Codex/…`) never existed on disk for the -/// user, so they are parked under the live-agent default project; any other -/// `cwd` is kept verbatim. -pub(crate) fn codex_remap_cwd(cwd: Option, codex_temp_root: &std::path::Path, default_project: &std::path::Path) -> Option { +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()); @@ -106,18 +79,9 @@ pub(crate) fn convert_codex_file( 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 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() => { - let timestamp = import_string(row.get("timestamp")).unwrap_or_default(); - rows.push((timestamp, row)); - } - _ => skipped_lines += 1, - } - } + 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)); } @@ -147,76 +111,128 @@ pub(crate) fn convert_codex_file( } } let model = model.unwrap_or_else(|| "codex".to_string()); + let config = ImportProviderConfig::codex(&model); let mut messages = Vec::new(); - let mut call_names = HashMap::new(); - let mut first_user_text = None; + 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 let Some(payload) = row.get("payload") { - if row.get("type").and_then(Value::as_str) != Some("response_item") { 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}")); - match item_type { - "message" => { - let role = payload.get("role").and_then(Value::as_str).unwrap_or_default(); - if role == "user" { - let content = codex_text_blocks(payload.get("content"), true); - if content.is_empty() { continue; } - let text = content.iter().filter_map(|v| v.get("text").and_then(Value::as_str)).collect::>().join("\n"); - first_user_text.get_or_insert(text); - messages.push(serde_json::json!({ "role": "user", "id": format!("{session_id}:{item_id}"), "content": content, "timestamp": timestamp })); - } else if role == "assistant" { - let content = codex_text_blocks(payload.get("content"), false); - if !content.is_empty() { messages.push(codex_assistant(format!("{session_id}:{item_id}"), content, &model, timestamp, "stop")); } + 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(codex_assistant(format!("{session_id}:{item_id}"), vec![serde_json::json!({ "type": "thinking", "thinking": summary })], &model, timestamp, "stop")); } - } - "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(codex_assistant(format!("{session_id}:{item_id}"), vec![serde_json::json!({ "type": "toolCall", "id": call_id, "name": name, "arguments": arguments })], &model, timestamp, "toolUse")); - } - "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()); - let content = codex_output_blocks(payload.get("output")); - messages.push(serde_json::json!({ "role": "toolResult", "toolCallId": call_id, "toolName": tool_name, "content": content, "isError": false, "timestamp": 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, + )); + } + _ => {} } } - if messages.is_empty() { - return Ok((None, skipped_lines)); - } let title = titles .get(&session_id) .cloned() - .filter(|s| !s.trim().is_empty()) - .or_else(|| first_user_text.map(|s| s.chars().take(80).collect())) - .unwrap_or_else(|| format!("Codex session {session_id}")); - let message_count = messages.len(); + .filter(|s| !s.trim().is_empty()); + let cwd = codex_remap_cwd(cwd, codex_temp_root, default_project); Ok(( - Some(ImportConversation { - id: format!("codex:{session_id}"), + finalize_import_conversation( + &config, session_id, - title, model, - cwd: codex_remap_cwd(cwd, codex_temp_root, default_project), - created_at: created_at.unwrap_or(last_timestamp), - updated_at: last_timestamp, + cwd, + created_at.unwrap_or(last_timestamp), + last_timestamp, + title, messages, - message_count, - already_imported: false, - }), + ), skipped_lines, )) } @@ -224,10 +240,17 @@ pub(crate) fn convert_codex_file( 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 }; + 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); } + if let (Some(id), Some(title)) = ( + import_string(row.get("id")), + import_string(row.get("thread_name")), + ) { + titles.insert(id, title); + } } } titles @@ -236,18 +259,34 @@ fn read_codex_titles(home: &std::path::Path) -> HashMap { 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)); } + if !root.exists() { + return Ok((Vec::new(), 0, 0)); + } let titles = read_codex_titles(&home); - // Codex parks throwaway sessions under /Codex; map those onto the - // live-agent default project so we don't surface a workdir the user never had. - 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 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) { + 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; } + 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)) => { @@ -264,27 +303,7 @@ fn scan_codex_sessions() -> Result<(Vec, usize, usize), Stri #[tauri::command] pub async fn chat_history_scan_codex() -> Result { - let (sessions, scanned_count, skipped_lines) = - tauri::async_runtime::spawn_blocking(|| -> Result<_, String> { - let (conversations, scanned_count, skipped_lines) = scan_codex_sessions()?; - 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(|e| format!("Codex 扫描失败:{e}"))??; - Ok(ImportPreview { - sessions, - scanned_count, - skipped_lines, - }) + scan_preview_command(scan_codex_sessions, "Codex").await } #[tauri::command] @@ -292,10 +311,11 @@ pub async fn chat_history_import_codex( gateway_controller: tauri::State<'_, Arc>, ids: Vec, ) -> Result { - 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_codex_sessions) - .await - .map_err(|e| format!("Codex 扫描失败:{e}"))??; - run_import(ImportProviderConfig::codex("codex"), conversations, skipped_lines, selected, &gateway_controller).await -} \ No newline at end of file + 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 index 354e6114e..14dc8484b 100644 --- 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 @@ -1,14 +1,9 @@ // Unified data model and persistence core shared by every provider importer -// (codex, claude_code, claude_official). Each provider only supplies: -// - how to locate & parse its source into `ImportConversation` (`scan_*`) +// (codex, claude_code, claude_official). 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. -// -// This is a real submodule of `chat_history`; `use super::*` gives us the -// parent's DB helpers (`get_summary_by_id`, `open_db`, `now_ms`, …) and types -// (`ChatHistorySummary`, `ChatHistoryUpsertInput`, …) without `include!`'s flat -// namespace. use super::*; use std::collections::HashSet; @@ -30,9 +25,6 @@ pub(crate) struct ImportPreview { pub skipped_lines: usize, } -/// The resolved payload of one source conversation. Fields map 1:1 to the -/// `ChatHistoryUpsertInput` we persist, except for the helpers (`messages`, -/// `already_imported`) that the DB write does not round-trip. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ImportConversation { @@ -49,20 +41,13 @@ pub(crate) struct ImportConversation { pub already_imported: bool, } -/// The only per-provider variation in the persistence path: which `provider_id` -/// and which `id` prefix the live-agent conversation gets, plus the -/// `selectedModelJson` written to the header. The model string itself comes -/// from the parser and is carried on each [`ImportConversation`]. pub(crate) struct ImportProviderConfig { provider_id: &'static str, id_prefix: &'static str, - /// `selectedModelJson` written to the header. `None` leaves it null (claude - /// official has no model selector of its own). selected_model_json: Option, } impl ImportProviderConfig { - /// Codex: live-agent conversations imported with the `codex` provider. pub(crate) fn codex(model: &str) -> Self { Self { provider_id: "codex", @@ -70,7 +55,6 @@ impl ImportProviderConfig { selected_model_json: Some(make_selected_model_json("codex", model)), } } - /// Claude Code (`~/.claude/projects`) backed by a builtin provider. pub(crate) fn claude_code(model: &str) -> Self { Self { provider_id: "claude_code", @@ -78,7 +62,6 @@ impl ImportProviderConfig { selected_model_json: Some(make_selected_model_json("builtin-claude_code", model)), } } - /// Claude official data export: no model selector, lives under its own provider. pub(crate) fn claude_official() -> Self { Self { provider_id: "claude_official", @@ -86,19 +69,27 @@ impl ImportProviderConfig { selected_model_json: None, } } - /// Build the live-agent conversation id `:`. 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() } -/// `import_*_conversation`, unified. Returns the persisted summary plus whether -/// the row was actually inserted (`false` when it was already present, so the -/// caller can skip the history-sync broadcast). pub(crate) fn import_conversation( config: &ImportProviderConfig, conn: &mut Connection, @@ -111,8 +102,18 @@ pub(crate) fn import_conversation( .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 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, @@ -126,7 +127,8 @@ pub(crate) fn import_conversation( "activeSegmentIndex": 0, "totalSegmentCount": 1, "totalMessageCount": message_count - }).to_string(), + }) + .to_string(), active_segment_index: 0, total_segment_count: 1, total_message_count: message_count, @@ -160,19 +162,22 @@ pub(crate) fn import_conversation( created_at: input.created_at, updated_at: input.updated_at, }; - let tx = conn.transaction().map_err(|error| format!("开启 {} 导入事务失败:{error}", config.provider_id))?; + 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)?; + 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))?; + tx.commit() + .map_err(|error| format!("提交 {} 导入事务失败:{error}", config.provider_id))?; Ok((get_summary_by_id(conn, input.id.trim())?, true)) } -/// Shared tail of every `import_*` tauri command: filter to selected ids, write -/// each via the unified path, then broadcast history-sync for rows that were -/// actually inserted. `scanned_count` is every conversation the parser produced -/// (selected or not); `skipped_lines` is parser-rejected source rows. Both pass -/// straight through to the result; the DB write only touches selected ids. pub(crate) async fn run_import( config: ImportProviderConfig, conversations: Vec, @@ -181,7 +186,7 @@ pub(crate) async fn run_import( gateway_controller: &Arc, ) -> Result { let scanned_count = conversations.len(); - let provider_label = config.provider_id.to_string(); + let provider_id = config.provider_id.to_string(); let summaries = tauri::async_runtime::spawn_blocking(move || { let mut conn = open_db()?; let summaries = conversations @@ -192,11 +197,13 @@ pub(crate) async fn run_import( Ok::<_, String>(summaries) }) .await - .map_err(|error| format!("{} 导入失败:{error}", provider_label))??; + .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; + gateway_controller + .publish_history_sync(build_history_sync_upsert(summary)) + .await; imported_count += 1; } } @@ -207,9 +214,161 @@ pub(crate) async fn run_import( }) } -// ---- small JSON helpers shared by the raw formatters ------------------------ +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, + }) +} -/// Trimmed non-empty string, or `None`. pub(crate) fn import_string(value: Option<&Value>) -> Option { value .and_then(Value::as_str) @@ -218,7 +377,6 @@ pub(crate) fn import_string(value: Option<&Value>) -> Option { .map(str::to_string) } -/// RFC-3339 timestamp → epoch millis. pub(crate) fn import_timestamp(value: Option<&str>) -> Option { value.and_then(|value| { chrono::DateTime::parse_from_rfc3339(value) @@ -227,61 +385,36 @@ pub(crate) fn import_timestamp(value: Option<&str>) -> Option { }) } -/// Extract `{type:"text"}` blocks from either a string or an array of content -/// blocks, dropping empty text. Shared by every provider's text extraction. -pub(crate) fn import_text_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()]); +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) if object.get("type").and_then(Value::as_str) == Some("text") => { - object - .get("text") + Value::Object(object) => { + let kind = object + .get("type") .and_then(Value::as_str) - .filter(|text| !text.is_empty()) - .map(|text| serde_json::json!({ "type": "text", "text": text })) + .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() } - -/// Build an assistant `message` JSON object with the Anthropic Messages shape. -/// Shared by the Claude Code and Claude-official parsers (both feed Anthropic -/// transcripts); Codex builds its own `openai-responses` variant locally. -pub(crate) fn claude_code_assistant( - id: String, - content: Vec, - model: &str, - timestamp: i64, - stop_reason: &str, -) -> Value { - serde_json::json!({ - "role": "assistant", - "id": id, - "content": content, - "api": "anthropic-messages", - "provider": "claude_code", - "model": model, - "usage": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0 }, - "stopReason": stop_reason, - "timestamp": timestamp - }) -} - -#[cfg(test)] -mod import_tests { - use super::*; - - #[test] - fn selected_model_json_pairs_custom_provider_with_model() { - let json = make_selected_model_json("codex", "gpt-5"); - let value: Value = serde_json::from_str(&json).unwrap(); - assert_eq!(value["customProviderId"], "codex"); - assert_eq!(value["model"], "gpt-5"); - } -} \ No newline at end of file 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 d6ec1a227..18658660e 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 @@ -4,7 +4,7 @@ use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::{ - collections::{HashMap, HashSet}, + collections::HashMap, sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, }; @@ -37,10 +37,10 @@ include!("share.rs"); include!("search.rs"); include!("commands.rs"); include!("replace.rs"); -mod import; -pub(crate) mod codex_import; pub(crate) mod claude_code_import; pub(crate) mod claude_official_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/commands/history/chat_history/tests.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs index 4ebf12395..3833f93f4 100644 --- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs @@ -1,7 +1,5 @@ #[cfg(test)] mod tests { - use super::codex_import::{convert_codex_file, codex_remap_cwd}; - use super::import::*; use super::*; use serde_json::json; @@ -2579,134 +2577,6 @@ mod tests { assert_eq!(context_meta["systemPrompt"], json!("keep me")); } - #[test] - fn codex_converter_maps_messages_tools_and_title() { - let temp = tempfile::tempdir().expect("create temp dir"); - let path = temp.path().join("rollout-test.jsonl"); - std::fs::write( - &path, - concat!( - r#"{"type":"session_meta","timestamp":"2026-01-01T00:00:00Z","payload":{"id":"session-1","session_id":"session-1","cwd":"/tmp/project"}}"#, - "\n", - r#"{"type":"turn_context","timestamp":"2026-01-01T00:00:01Z","payload":{"model":"gpt-5.4","cwd":"/tmp/project"}}"#, - "\n", - r#"{"type":"response_item","timestamp":"2026-01-01T00:00:02Z","payload":{"type":"message","id":"u1","role":"user","content":[{"type":"input_text","text":"Fix it"}]}}"#, - "\n", - "not-json\n", - r#"{"type":"response_item","timestamp":"2026-01-01T00:00:03Z","payload":{"type":"reasoning","id":"r1","summary":[{"type":"summary_text","text":"Inspect code"}]}}"#, - "\n", - r#"{"type":"response_item","timestamp":"2026-01-01T00:00:04Z","payload":{"type":"function_call","id":"c1","call_id":"call-1","name":"shell","arguments":"{\"command\":\"pwd\"}"}}"#, - "\n", - r#"{"type":"response_item","timestamp":"2026-01-01T00:00:05Z","payload":{"type":"function_call_output","id":"o1","call_id":"call-1","output":"/tmp/project"}}"#, - "\n", - r#"{"type":"response_item","timestamp":"2026-01-01T00:00:06Z","payload":{"type":"message","id":"a1","role":"assistant","content":[{"type":"output_text","text":"Done"}]}}"#, - "\n" - ), - ) - .expect("write rollout"); - let titles = HashMap::from([("session-1".to_string(), "Imported title".to_string())]); - - let (conversation, skipped) = convert_codex_file( - &path, - &titles, - std::path::Path::new("/nonexistent/Codex"), - std::path::Path::new("/nonexistent/default-project"), - ) - .expect("convert rollout"); - let conversation = conversation.expect("conversation"); - - assert_eq!(skipped, 1); - assert_eq!(conversation.id, "codex:session-1"); - assert_eq!(conversation.title, "Imported title"); - assert_eq!(conversation.model, "gpt-5.4"); - assert_eq!(conversation.cwd.as_deref(), Some("/tmp/project")); - assert_eq!(conversation.messages.len(), 5); - assert_eq!(conversation.messages[0]["role"], json!("user")); - assert_eq!(conversation.messages[1]["content"][0]["type"], json!("thinking")); - assert_eq!(conversation.messages[2]["content"][0]["name"], json!("shell")); - assert_eq!(conversation.messages[3]["toolCallId"], json!("call-1")); - assert_eq!(conversation.messages[4]["content"][0]["text"], json!("Done")); - } - - #[test] - fn codex_temporary_workdir_uses_default_project() { - let codex_temp = std::path::Path::new("/Users/tester/Documents/Codex"); - let default_project = std::path::Path::new("/Users/tester/.liveagent/default-project"); - assert_eq!( - codex_remap_cwd(Some("/Users/tester/Documents/Codex".to_string()), codex_temp, default_project).as_deref(), - Some("/Users/tester/.liveagent/default-project") - ); - assert_eq!( - codex_remap_cwd( - Some("/Users/tester/Documents/Codex/session-1".to_string()), - codex_temp, - default_project - ) - .as_deref(), - Some("/Users/tester/.liveagent/default-project") - ); - assert_eq!( - codex_remap_cwd(Some("/Users/tester/Projects/app".to_string()), codex_temp, default_project).as_deref(), - Some("/Users/tester/Projects/app") - ); - assert_eq!( - codex_remap_cwd(None, codex_temp, default_project).as_deref(), - None - ); - } - - #[test] - fn codex_import_skips_an_existing_conversation() { - let mut conn = open_test_db().expect("open test db"); - let config = ImportProviderConfig::codex("codex"); - let conversation = ImportConversation { - id: "codex:session-1".to_string(), - session_id: "session-1".to_string(), - title: "Imported".to_string(), - model: "gpt-5.4".to_string(), - cwd: Some("/tmp/project".to_string()), - created_at: 1_700_000_000_000, - updated_at: 1_700_000_000_100, - messages: vec![json!({ - "role": "user", - "id": "u1", - "content": [{ "type": "text", "text": "Hello" }], - "timestamp": 1_700_000_000_000_i64 - })], - message_count: 1, - already_imported: false, - }; - - let (_, inserted) = import_conversation(&config, &mut conn, conversation).expect("first import"); - assert!(inserted); - rename_chat_history_sync(&conn, "codex:session-1", "Keep my title").expect("rename"); - - let duplicate = ImportConversation { - id: "codex:session-1".to_string(), - session_id: "session-1".to_string(), - title: "Overwrite".to_string(), - model: "gpt-5.5".to_string(), - cwd: None, - created_at: 1_700_000_000_000, - updated_at: 1_700_000_000_200, - messages: vec![json!({ - "role": "user", - "id": "u2", - "content": [{ "type": "text", "text": "New" }], - "timestamp": 1_700_000_000_200_i64 - })], - message_count: 1, - already_imported: false, - }; - let (summary, inserted) = - import_conversation(&config, &mut conn, duplicate).expect("duplicate import"); - - assert!(!inserted); - assert_eq!(summary.title, "Keep my title"); - assert_eq!(summary.model, "gpt-5.4"); - assert_eq!(summary.message_count, 1); - } - #[test] fn branch_segments_cut_in_later_segment_midway() { // 锚点轮次的助手回复跨过分段边界:更早分段整段复制,切点段裁剪。 diff --git a/crates/agent-gui/src/lib/chat/history/chatHistory.ts b/crates/agent-gui/src/lib/chat/history/chatHistory.ts index 39f4999ae..0b1e6ebaa 100644 --- a/crates/agent-gui/src/lib/chat/history/chatHistory.ts +++ b/crates/agent-gui/src/lib/chat/history/chatHistory.ts @@ -249,13 +249,7 @@ export async function listChatHistory( }); } -export type ClaudeCodeImportResult = { - scannedCount: number; - importedCount: number; - skippedLines: number; -}; - -export type ClaudeCodeImportPreviewSession = { +export type ImportPreviewSession = { id: string; sessionId: string; title: string; @@ -267,73 +261,53 @@ export type ClaudeCodeImportPreviewSession = { alreadyImported: boolean; }; -export type ClaudeCodeImportPreview = { - sessions: ClaudeCodeImportPreviewSession[]; +export type ImportPreview = { + sessions: ImportPreviewSession[]; scannedCount: number; skippedLines: number; }; -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 type ClaudeOfficialImportResult = { +export type ImportResult = { scannedCount: number; importedCount: number; skippedLines: number; }; -export type ClaudeOfficialImportPreview = { - sessions: ClaudeCodeImportPreviewSession[]; - scannedCount: number; - skippedLines: number; -}; +// Backward-compatible aliases for existing call sites. +export type ClaudeCodeImportPreviewSession = ImportPreviewSession; +export type ClaudeCodeImportPreview = ImportPreview; +export type ClaudeCodeImportResult = ImportResult; +export type ClaudeOfficialImportPreview = ImportPreview; +export type ClaudeOfficialImportResult = 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 scanClaudeOfficialChatHistory(zipPath: string) { - return invoke("chat_history_scan_claude_official", { zipPath }); + return invoke("chat_history_scan_claude_official", { zipPath }); } export async function importClaudeOfficialChatHistory(zipPath: string, ids: string[]) { - return invoke("chat_history_import_claude_official", { + return invoke("chat_history_import_claude_official", { zipPath, ids, }); } -export type CodexImportResult = { - scannedCount: number; - importedCount: number; - skippedLines: number; -}; - -export type CodexImportPreviewSession = { - id: string; - sessionId: string; - title: string; - model: string; - cwd?: string; - createdAt: number; - updatedAt: number; - messageCount: number; - alreadyImported: boolean; -}; - -export type CodexImportPreview = { - sessions: CodexImportPreviewSession[]; - scannedCount: number; - skippedLines: number; -}; - export async function scanCodexChatHistory() { - return invoke("chat_history_scan_codex"); + return invoke("chat_history_scan_codex"); } export async function importCodexChatHistory(ids: string[]) { - return invoke("chat_history_import_codex", { ids }); + return invoke("chat_history_import_codex", { ids }); } export async function listChatHistoryWorkdirs() { 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/settings/ClaudeCodeImportDialog.tsx b/crates/agent-gui/src/pages/settings/ClaudeCodeImportDialog.tsx deleted file mode 100644 index 91b1b4449..000000000 --- a/crates/agent-gui/src/pages/settings/ClaudeCodeImportDialog.tsx +++ /dev/null @@ -1,369 +0,0 @@ -import { 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 { - ClaudeCodeImportPreview, - ClaudeCodeImportPreviewSession, - ClaudeOfficialImportPreview, -} from "../../lib/chat/history/chatHistory"; -import { useModalMotion } from "../../lib/shared/modalMotion"; -import { cn } from "../../lib/shared/utils"; - -type ClaudeCodeImportDialogProps = { - preview: ClaudeCodeImportPreview | ClaudeOfficialImportPreview; - onClose: () => void; - onConfirm: (ids: string[]) => void; - /// 官方导入没有 LiveAgent 工作区语义,始终汇入 Chat 模式;本机 Claude - /// Code 导入则保留其实际 cwd 分组。`variant` 也控制标题与副标题。 - variant: "claude-code" | "claude-official"; -}; - -const NO_CWD_KEY = "__liveagent_no_cwd__"; -const CHAT_MODE_KEY = "__liveagent_chat_mode__"; - -function workspaceLabel(cwd: string): string { - if (cwd === NO_CWD_KEY || cwd === CHAT_MODE_KEY) return ""; - // 显示路径末段,title 上带完整路径 - const segments = cwd.split(/[\\/]/).filter(Boolean); - return segments[segments.length - 1] ?? cwd; -} - -export function ClaudeCodeImportDialog({ - preview, - onClose, - onConfirm, - variant, -}: ClaudeCodeImportDialogProps) { - const { t } = useLocale(); - const { modalState, requestClose } = useModalMotion(onClose); - - const groups = useMemo(() => { - if (variant === "claude-official") { - return [[CHAT_MODE_KEY, preview.sessions] as [string, ClaudeCodeImportPreviewSession[]]]; - } - const map = new Map(); - for (const session of preview.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]); - } - // 按会话数倒序;无 cwd 的小组垫底 - return Array.from(map.entries()).sort((a, b) => { - const aIsNoCwd = a[0] === NO_CWD_KEY; - const bIsNoCwd = b[0] === NO_CWD_KEY; - if (aIsNoCwd !== bIsNoCwd) return aIsNoCwd ? 1 : -1; - return b[1].length - a[1].length; - }); - }, [preview.sessions, variant]); - - const [activeCwdKey, setActiveCwdKey] = useState(groups[0]?.[0] ?? null); - - const [selected, setSelected] = useState>(() => { - const set = new Set(); - for (const session of preview.sessions) { - if (!session.alreadyImported) set.add(session.id); - } - return set; - }); - - const activeSessions = useMemo( - () => groups.find(([key]) => key === activeCwdKey)?.[1] ?? [], - [groups, activeCwdKey], - ); - - const groupSelectedCount = (key: string) => { - const list = groups.find(([k]) => k === key)?.[1] ?? []; - return list.filter((s) => selected.has(s.id)).length; - }; - - function toggleSession(session: ClaudeCodeImportPreviewSession) { - 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; - }); - } - - function toggleGroup(key: string) { - const list = groups.find(([k]) => k === key)?.[1] ?? []; - const importable = list.filter((s) => !s.alreadyImported); - if (importable.length === 0) return; - const allSelected = importable.every((s) => selected.has(s.id)); - setSelected((prev) => { - const next = new Set(prev); - if (allSelected) { - for (const s of importable) next.delete(s.id); - } else { - for (const s of importable) next.add(s.id); - } - return next; - }); - } - - const activeSessionsAllSelected = - activeSessions.length > 0 && - activeSessions.filter((s) => !s.alreadyImported).every((s) => selected.has(s.id)); - - return createPortal( -
- -
- -
- {/* 左栏:工作区 */} -
-
- - {variant === "claude-official" - ? t("settings.executionMode") - : t("chat.history.claudeCodeImportDialogWorkspaces")} - - -
-
- {groups.length === 0 ? ( -

- {t("chat.history.claudeCodeImportDialogEmpty")} -

- ) : ( - groups.map(([key, list]) => { - const isActive = key === activeCwdKey; - const checked = groupSelectedCount(key); - const importable = list.filter((s) => !s.alreadyImported).length; - const allSelected = importable > 0 && checked === importable; - return ( - - {variant === "claude-official" ? ( - - ) : ( - - )} - - - {key === CHAT_MODE_KEY - ? t("settings.chatMode") - : key === NO_CWD_KEY - ? t("chat.history.claudeCodeImportDialogNoWorkspace") - : workspaceLabel(key)} - - {key !== NO_CWD_KEY && key !== CHAT_MODE_KEY ? ( - - {key} - - ) : null} - - - {checked}/{importable} - - - ); - }) - )} -
-
- - {/* 右栏:会话 */} -
-
- - {activeCwdKey === CHAT_MODE_KEY - ? t("settings.chatMode") - : activeCwdKey === NO_CWD_KEY - ? t("chat.history.claudeCodeImportDialogNoWorkspace") - : (activeCwdKey ?? "")} - - {activeSessions.length > 0 ? ( - - ) : null} -
-
- {activeSessions.length === 0 ? ( -

- {t("chat.history.claudeCodeImportDialogEmpty")} -

- ) : ( - activeSessions.map((session) => { - const checked = selected.has(session.id); - return ( - - ); - }) - )} -
-
-
- -
- - {t("chat.history.claudeCodeImportDialogSelected").replace( - "{count}", - String(selected.size), - )} - -
- - -
-
-
-
, - document.body, - ); -} diff --git a/crates/agent-gui/src/pages/settings/ConversationsSection.tsx b/crates/agent-gui/src/pages/settings/ConversationsSection.tsx index 530583e92..afad2a283 100644 --- a/crates/agent-gui/src/pages/settings/ConversationsSection.tsx +++ b/crates/agent-gui/src/pages/settings/ConversationsSection.tsx @@ -4,12 +4,8 @@ import { History, Loader2, Upload } from "../../components/icons"; import { Button } from "../../components/ui/button"; import { useLocale } from "../../i18n"; import { - type ClaudeCodeImportPreview, - type ClaudeCodeImportResult, - type ClaudeOfficialImportPreview, - type ClaudeOfficialImportResult, - type CodexImportPreview, - type CodexImportResult, + type ImportPreview, + type ImportResult, importClaudeCodeChatHistory, importClaudeOfficialChatHistory, importCodexChatHistory, @@ -17,13 +13,9 @@ import { scanClaudeOfficialChatHistory, scanCodexChatHistory, } from "../../lib/chat/history/chatHistory"; -import { ClaudeCodeImportDialog } from "./ClaudeCodeImportDialog"; -import { CodexImportDialog } from "./CodexImportDialog"; +import { ImportDialog, type ImportSource } from "./ImportDialog"; -type ImportSource = "codex" | "claude-code" | "claude-official"; -type ImportPreview = CodexImportPreview | ClaudeCodeImportPreview | ClaudeOfficialImportPreview; -type ImportResult = CodexImportResult | ClaudeCodeImportResult | ClaudeOfficialImportResult; -type ImportDialog = { +type ImportDialogState = { source: ImportSource; preview: ImportPreview; zipPath?: string; @@ -33,7 +25,7 @@ export function ConversationsSection() { const { t } = useLocale(); const [scanning, setScanning] = useState(null); const [importing, setImporting] = useState(null); - const [dialog, setDialog] = useState(null); + const [dialog, setDialog] = useState(null); const [result, setResult] = useState<{ source: ImportSource; value: ImportResult } | null>(null); const [error, setError] = useState(null); @@ -208,27 +200,12 @@ export function ConversationsSection() { {claudeImportCard()} {codexImportCard()} - {dialog?.source === "codex" ? ( - setDialog(null)} - onConfirm={(ids) => void handleConfirm("codex", ids)} - /> - ) : null} - {dialog?.source === "claude-official" ? ( - setDialog(null)} - onConfirm={(ids) => void handleConfirm("claude-official", ids, dialog.zipPath)} - variant="claude-official" - /> - ) : null} - {dialog?.source === "claude-code" ? ( - setDialog(null)} - onConfirm={(ids) => void handleConfirm("claude-code", ids)} - variant="claude-code" + onConfirm={(ids) => void handleConfirm(dialog.source, ids, dialog.zipPath)} /> ) : null}
diff --git a/crates/agent-gui/src/pages/settings/CodexImportDialog.tsx b/crates/agent-gui/src/pages/settings/ImportDialog.tsx similarity index 57% rename from crates/agent-gui/src/pages/settings/CodexImportDialog.tsx rename to crates/agent-gui/src/pages/settings/ImportDialog.tsx index 50e306c9c..5fa162e85 100644 --- a/crates/agent-gui/src/pages/settings/CodexImportDialog.tsx +++ b/crates/agent-gui/src/pages/settings/ImportDialog.tsx @@ -1,100 +1,150 @@ -import { useMemo, useState } from "react"; +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 { - CodexImportPreview, - CodexImportPreviewSession, -} from "../../lib/chat/history/chatHistory"; +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"; -type CodexImportDialogProps = { - preview: CodexImportPreview; +export type ImportSource = "codex" | "claude-code" | "claude-official"; + +type ImportDialogProps = { + source: ImportSource; + preview: ImportPreview; onClose: () => void; onConfirm: (ids: string[]) => void; }; const NO_CWD_KEY = "__liveagent_no_cwd__"; +const CHAT_MODE_KEY = "__liveagent_chat_mode__"; function workspaceLabel(cwd: string): string { - if (cwd === NO_CWD_KEY) return ""; - // 显示路径末段,title 上带完整路径 + if (cwd === NO_CWD_KEY || cwd === CHAT_MODE_KEY) return ""; const segments = cwd.split(/[\\/]/).filter(Boolean); return segments[segments.length - 1] ?? cwd; } -export function CodexImportDialog({ preview, onClose, onConfirm }: CodexImportDialogProps) { - const { t } = useLocale(); - const { modalState, requestClose } = useModalMotion(onClose); +const LABELS: Record< + ImportSource, + { + title: string; + subtitle: string; + workspaces: string; + noWorkspace: string; + chatMode?: 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", + }, + "claude-official": { + title: "chat.history.claudeOfficialImportDialogTitle", + subtitle: "chat.history.claudeOfficialImportDialogSubtitle", + workspaces: "settings.executionMode", + noWorkspace: "chat.history.claudeCodeImportDialogNoWorkspace", + chatMode: "settings.chatMode", + empty: "chat.history.claudeOfficialImportDialogEmpty", + all: "chat.history.claudeCodeImportDialogAll", + none: "chat.history.claudeCodeImportDialogNone", + selected: "chat.history.claudeCodeImportDialogSelected", + import: "chat.history.claudeCodeImportDialogImport", + alreadyImported: "chat.history.claudeCodeImportDialogAlreadyImported", + messages: "chat.history.claudeCodeImportDialogMessages", + }, +}; - const groups = useMemo(() => { - const map = new Map(); - for (const session of preview.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]); +function buildGroups( + source: ImportSource, + sessions: ImportPreviewSession[], +): [string, ImportPreviewSession[]][] { + if (source === "claude-official") { + return [[CHAT_MODE_KEY, sessions]]; + } + 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; } - // 按会话数倒序,default-project 置顶 - return Array.from(map.entries()).sort((a, b) => { - const aIsDefault = a[0].includes("default-project"); - const bIsDefault = b[0].includes("default-project"); - if (aIsDefault !== bIsDefault) return aIsDefault ? -1 : 1; - return b[1].length - a[1].length; - }); - }, [preview.sessions]); + 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 isOfficial = source === "claude-official"; + const WorkspaceIcon = isOfficial ? MessageSquareText : Folder; + const groups = useMemo(() => buildGroups(source, preview.sessions), [source, preview.sessions]); const [activeCwdKey, setActiveCwdKey] = useState(groups[0]?.[0] ?? null); - const [selected, setSelected] = useState>(() => { - const set = new Set(); - for (const session of preview.sessions) { - if (!session.alreadyImported) set.add(session.id); - } - return set; - }); + const { selected, toggleSession, toggleGroup, selectAll, selectNone, isAllSelected } = + useImportSelection(preview.sessions); + + const formatKey = useCallback( + (key: string) => { + if (key === CHAT_MODE_KEY) return t(labels.chatMode ?? "settings.chatMode"); + 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 groupSelectedCount = (key: string) => { - const list = groups.find(([k]) => k === key)?.[1] ?? []; - return list.filter((s) => selected.has(s.id)).length; - }; - - function toggleSession(session: CodexImportPreviewSession) { - 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; - }); - } - - function toggleGroup(key: string) { - const list = groups.find(([k]) => k === key)?.[1] ?? []; - const importable = list.filter((s) => !s.alreadyImported); - if (importable.length === 0) return; - const allSelected = importable.every((s) => selected.has(s.id)); - setSelected((prev) => { - const next = new Set(prev); - if (allSelected) { - for (const s of importable) next.delete(s.id); - } else { - for (const s of importable) next.add(s.id); - } - return next; - }); - } + const groupImportable = (list: ImportPreviewSession[]) => + list.filter((session) => !session.alreadyImported); const activeSessionsAllSelected = activeSessions.length > 0 && - activeSessions.filter((s) => !s.alreadyImported).every((s) => selected.has(s.id)); + groupImportable(activeSessions).every((session) => selected.has(session.id)); return createPortal(
-
+
{groups.length === 0 ? ( -

- {t("chat.history.codexImportDialogEmpty")} -

+

{t(labels.empty)}

) : ( groups.map(([key, list]) => { const isActive = key === activeCwdKey; - const checked = groupSelectedCount(key); - const importable = list.filter((s) => !s.alreadyImported).length; - const allSelected = importable > 0 && checked === importable; + const checked = list.filter((session) => selected.has(session.id)).length; + const importable = groupImportable(list); + const allSelected = importable.length > 0 && checked === importable.length; return ( ); @@ -227,26 +258,22 @@ export function CodexImportDialog({ preview, onClose, onConfirm }: CodexImportDi
- {activeCwdKey === NO_CWD_KEY - ? t("chat.history.codexImportDialogNoWorkspace") - : (activeCwdKey ?? "")} + {formatKey(activeCwdKey ?? NO_CWD_KEY)} {activeSessions.length > 0 ? ( ) : null}
{activeSessions.length === 0 ? (

- {t("chat.history.codexImportDialogEmpty")} + {t(labels.empty)}

) : ( activeSessions.map((session) => { @@ -278,13 +305,12 @@ export function CodexImportDialog({ preview, onClose, onConfirm }: CodexImportDi {session.title} - {session.model} · {session.messageCount}{" "} - {t("chat.history.codexImportDialogMessages")} + {session.model} · {session.messageCount} {t(labels.messages)} {session.alreadyImported ? ( - {t("chat.history.codexImportDialogAlreadyImported")} + {t(labels.alreadyImported)} ) : null} @@ -297,7 +323,7 @@ export function CodexImportDialog({ preview, onClose, onConfirm }: CodexImportDi
- {t("chat.history.codexImportDialogSelected").replace("{count}", String(selected.size))} + {t(labels.selected).replace("{count}", String(selected.size))}
From 078d901cdb0d9987f5c1003f9f4ae2b98b39e175 Mon Sep 17 00:00:00 2001 From: FlowerRealm Date: Thu, 30 Jul 2026 01:59:44 -0700 Subject: [PATCH 11/14] refactor(chat-history): remove Claude Official import feature Drop the official Claude data ZIP importer and its frontend wiring. Existing imported records (provider_id=claude_official) remain readable. - Delete claude_official_import.rs - Remove claude_official provider config from import.rs - Unregister official scan/import Tauri commands - Remove official source from ImportSource, ImportDialog, ConversationsSection - Remove related i18n keys and update generic Claude description --- .../chat_history/claude_official_import.rs | 277 ------------------ .../commands/history/chat_history/import.rs | 9 +- .../src/commands/history/chat_history/mod.rs | 1 - crates/agent-gui/src-tauri/src/lib.rs | 2 - crates/agent-gui/src/i18n/config.ts | 28 +- .../src/lib/chat/history/chatHistory.ts | 13 - .../pages/settings/ConversationsSection.tsx | 46 +-- .../src/pages/settings/ImportDialog.tsx | 29 +- 8 files changed, 13 insertions(+), 392 deletions(-) delete mode 100644 crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_official_import.rs diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_official_import.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_official_import.rs deleted file mode 100644 index 131009ed4..000000000 --- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/claude_official_import.rs +++ /dev/null @@ -1,277 +0,0 @@ -// Claude official importer. Locates & parses `conversations.json` inside a -// user-selected Claude data-export ZIP into [`ImportConversation`]. The shared -// struct, DB write and message builders live in `super::import`; this module -// just supplies the ZIP-specific scan + parse plus two tauri commands wired to -// that core. - -use super::import::*; -use super::*; -use std::collections::HashMap; -use std::sync::Arc; - -const CLAUDE_OFFICIAL_CONVERSATIONS_ENTRY: &str = "conversations.json"; - -fn read_claude_official_conversations(path: &std::path::Path) -> Result, String> { - if path.extension().and_then(|extension| extension.to_str()) != Some("zip") { - return Err("请选择 Claude 官方数据 ZIP 文件".to_string()); - } - let file = std::fs::File::open(path) - .map_err(|error| format!("读取 Claude 官方数据文件失败:{}: {error}", path.display()))?; - let mut archive = zip::ZipArchive::new(file) - .map_err(|error| format!("无法打开 Claude 官方数据 ZIP:{error}"))?; - let entry_index = (0..archive.len()) - .find(|index| { - archive - .by_index(*index) - .ok() - .is_some_and(|entry| entry.name() == CLAUDE_OFFICIAL_CONVERSATIONS_ENTRY) - }) - .ok_or_else(|| "此 ZIP 不包含 Claude 官方数据的 conversations.json".to_string())?; - let mut entry = archive - .by_index(entry_index) - .map_err(|error| format!("读取 conversations.json 失败:{error}"))?; - use std::io::Read; - let mut text = String::new(); - entry - .read_to_string(&mut text) - .map_err(|error| format!("读取 conversations.json 内容失败:{error}"))?; - serde_json::from_str::>(&text) - .map_err(|error| format!("Claude 官方数据的 conversations.json 格式无效:{error}")) -} - -fn claude_official_attachment_text(message: &Value) -> Vec { - let mut blocks = Vec::new(); - for attachment in message - .get("attachments") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - let name = import_string(attachment.get("file_name")) - .unwrap_or_else(|| "unnamed attachment".to_string()); - let file_type = import_string(attachment.get("file_type")); - let size = attachment.get("file_size").and_then(Value::as_u64); - let mut text = format!("[Imported attachment: {name}"); - if let Some(file_type) = file_type { - text.push_str(&format!(", {file_type}")); - } - if let Some(size) = size { - text.push_str(&format!(", {size} bytes")); - } - text.push(']'); - if let Some(extracted) = import_string(attachment.get("extracted_content")) { - text.push('\n'); - text.push_str(&extracted); - } - blocks.push(serde_json::json!({ "type": "text", "text": text })); - } - for file in message - .get("files") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - let Some(name) = import_string(file.get("file_name")) else { - continue; - }; - blocks.push(serde_json::json!({ - "type": "text", - "text": format!("[Imported file reference: {name}; the original file was not included in the Claude official data]") - })); - } - blocks -} - -fn convert_claude_official_conversation(conversation: &Value) -> Option { - let session_id = import_string(conversation.get("uuid"))?; - let created_at = import_timestamp(conversation.get("created_at").and_then(Value::as_str)) - .unwrap_or_else(now_ms); - let mut updated_at = import_timestamp(conversation.get("updated_at").and_then(Value::as_str)) - .unwrap_or(created_at); - let config = ImportProviderConfig::claude_official(); - let mut messages = Vec::new(); - let mut tool_names: HashMap = HashMap::new(); - - for (index, message) in conversation - .get("chat_messages") - .and_then(Value::as_array) - .into_iter() - .flatten() - .enumerate() - { - let timestamp = import_timestamp(message.get("created_at").and_then(Value::as_str)) - .unwrap_or(updated_at); - updated_at = updated_at.max(timestamp); - let message_id = - import_string(message.get("uuid")).unwrap_or_else(|| format!("{session_id}:{index}")); - let event_id = format!("{session_id}:{message_id}"); - match message.get("sender").and_then(Value::as_str) { - Some("human") => { - let mut content = import_text_blocks(message.get("text"), &["text"]); - if content.is_empty() { - content = message - .get("content") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter(|block| block.get("type").and_then(Value::as_str) == Some("text")) - .filter_map(|block| { - import_string(block.get("text")) - .map(|text| serde_json::json!({ "type": "text", "text": text })) - }) - .collect(); - } - content.extend(claude_official_attachment_text(message)); - if !content.is_empty() { - messages.push(user_message(event_id, content, timestamp)); - } - } - Some("assistant") => { - let mut assistant_blocks = Vec::new(); - let mut tool_results = Vec::new(); - for block in message - .get("content") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - match block.get("type").and_then(Value::as_str) { - Some("text") => { - if let Some(text) = import_string(block.get("text")) { - assistant_blocks - .push(serde_json::json!({ "type": "text", "text": text })); - } - } - Some("thinking") => { - if let Some(thinking) = import_string(block.get("thinking")) { - assistant_blocks.push( - serde_json::json!({ "type": "thinking", "thinking": thinking }), - ); - } - } - Some("tool_use") => { - let id = import_string(block.get("id")).unwrap_or_else(|| { - format!("claude-official-tool-{}", tool_names.len()) - }); - let name = import_string(block.get("name")) - .unwrap_or_else(|| "claude_official_tool".to_string()); - tool_names.insert(id.clone(), name.clone()); - assistant_blocks.push(serde_json::json!({ "type": "toolCall", "id": id, "name": name, "arguments": block.get("input").cloned().unwrap_or_else(|| serde_json::json!({})) })); - } - Some("tool_result") => { - let tool_call_id = import_string(block.get("tool_use_id")) - .or_else(|| import_string(block.get("tool_call_id"))) - .unwrap_or_else(|| { - format!("claude-official-tool-result-{}", tool_results.len()) - }); - let tool_name = tool_names - .get(&tool_call_id) - .cloned() - .unwrap_or_else(|| "claude_official_tool".to_string()); - tool_results.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, - )); - } - _ => {} - } - } - if assistant_blocks.is_empty() { - if let Some(text) = message - .get("text") - .and_then(Value::as_str) - .filter(|text| !text.is_empty()) - { - assistant_blocks.push(serde_json::json!({ "type": "text", "text": text })); - } else { - assistant_blocks = message - .get("text") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter(|block| { - block.get("type").and_then(Value::as_str) == Some("text") - }) - .filter_map(|block| { - import_string(block.get("text")) - .map(|text| serde_json::json!({ "type": "text", "text": text })) - }) - .collect(); - } - } - if !assistant_blocks.is_empty() { - messages.push(assistant_message( - event_id, - &config, - "claude-official", - assistant_blocks, - "stop", - timestamp, - )); - } - messages.extend(tool_results); - } - _ => {} - } - } - finalize_import_conversation( - &config, - session_id, - "claude-official".to_string(), - None, - created_at, - updated_at, - import_string(conversation.get("name")), - messages, - ) -} - -fn scan_claude_official( - zip_path: &std::path::Path, -) -> Result<(Vec, usize, usize), String> { - let records = read_claude_official_conversations(zip_path)?; - let scanned_count = records.len(); - let mut skipped_lines = 0; - let mut conversations = Vec::new(); - for record in &records { - match convert_claude_official_conversation(record) { - Some(conversation) => conversations.push(conversation), - None => skipped_lines += 1, - } - } - conversations.sort_by_key(|conversation| conversation.created_at); - Ok((conversations, scanned_count, skipped_lines)) -} - -#[tauri::command] -pub async fn chat_history_scan_claude_official(zip_path: String) -> Result { - let path = zip_path.clone(); - scan_preview_command( - move || scan_claude_official(std::path::Path::new(&path)), - "Claude 官方数据", - ) - .await -} - -#[tauri::command] -pub async fn chat_history_import_claude_official( - gateway_controller: tauri::State<'_, Arc>, - zip_path: String, - ids: Vec, -) -> Result { - let path = zip_path.clone(); - import_selected_command( - move || scan_claude_official(std::path::Path::new(&path)), - ImportProviderConfig::claude_official(), - 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 index 14dc8484b..c1cfd4e13 100644 --- 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 @@ -1,5 +1,5 @@ // Unified data model and persistence core shared by every provider importer -// (codex, claude_code, claude_official). Each provider supplies: +// (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 @@ -62,13 +62,6 @@ impl ImportProviderConfig { selected_model_json: Some(make_selected_model_json("builtin-claude_code", model)), } } - pub(crate) fn claude_official() -> Self { - Self { - provider_id: "claude_official", - id_prefix: "claude-official", - selected_model_json: None, - } - } pub(crate) fn id_prefix_with(&self, session_id: &str) -> String { format!("{}:{session_id}", self.id_prefix) } 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 18658660e..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 @@ -38,7 +38,6 @@ include!("search.rs"); include!("commands.rs"); include!("replace.rs"); pub(crate) mod claude_code_import; -pub(crate) mod claude_official_import; pub(crate) mod codex_import; mod import; include!("branch.rs"); diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index 0663c64c7..a03c2e4d1 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -53,8 +53,6 @@ macro_rules! app_invoke_handler { 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::claude_official_import::chat_history_scan_claude_official, - commands::chat_history::claude_official_import::chat_history_import_claude_official, 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 819b9d722..78ab203a8 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -141,14 +141,7 @@ export const translations: Record> = { "chat.history.persistFailed": "历史记录保存失败:{message}", "chat.history.codexImport": "导入 Codex 对话", "chat.history.claudeCodeImport": "导入本机 Claude Code", - "chat.history.claudeOfficialImport": "导入 Official ZIP", "chat.history.claudeImportFailed": "Claude 对话导入失败", - "chat.history.claudeOfficialImporting": "正在导入 Claude 官方对话…", - "chat.history.claudeOfficialImportFailed": "Claude 官方对话导入失败", - "chat.history.claudeOfficialImportDialogTitle": "导入 Claude 官方对话", - "chat.history.claudeOfficialImportDialogSubtitle": - "从官方 Claude 数据 ZIP 中选择需要导入的对话;会话将归入 Chat 模式。", - "chat.history.claudeOfficialImportDialogEmpty": "没有可导入的 Claude 官方对话。", "chat.history.claudeCodeImporting": "正在导入 Claude Code 对话…", "chat.history.claudeCodeImportFailed": "Claude Code 对话导入失败", "chat.history.claudeCodeImportDialogTitle": "导入 Claude Code 对话", @@ -984,13 +977,9 @@ export const translations: Record> = { "settings.codexImportDescription": "从 ~/.codex/sessions 导入尚未存在于 LiveAgent 的会话。", "settings.codexImportResult": "已导入 {imported} 个会话,共扫描 {scanned} 个 Codex 会话文件。", "settings.claudeImportTitle": "Claude 对话", - "settings.claudeImportDescription": "从本机 Claude Code 或 Official Claude 数据 ZIP 导入对话。", + "settings.claudeImportDescription": "从本机 Claude Code 导入对话。", "settings.claudeImportResult": "已导入 {imported} 个会话,共读取 {scanned} 个 Claude 会话。", "settings.claudeCodeImportTitle": "Claude Code 对话", - "settings.claudeOfficialImportTitle": "Claude 官方对话", - "settings.claudeOfficialImportDescription": "选择 Claude 官方数据 ZIP;会话将归入 Chat 模式。", - "settings.claudeOfficialImportResult": - "已导入 {imported} 个会话,共读取 {scanned} 个 Claude 官方对话。", "settings.claudeCodeImportDescription": "从 ~/.claude/projects 导入 Claude Code 主会话;子代理和工作流记录不会导入。", "settings.claudeCodeImportResult": @@ -2464,14 +2453,7 @@ export const translations: Record> = { "chat.history.persistFailed": "Failed to save history: {message}", "chat.history.codexImport": "Import Codex conversations", "chat.history.claudeCodeImport": "Import local Claude Code", - "chat.history.claudeOfficialImport": "Import Official ZIP", "chat.history.claudeImportFailed": "Failed to import Claude conversations", - "chat.history.claudeOfficialImporting": "Importing official Claude conversations…", - "chat.history.claudeOfficialImportFailed": "Failed to import official Claude conversations", - "chat.history.claudeOfficialImportDialogTitle": "Import official Claude conversations", - "chat.history.claudeOfficialImportDialogSubtitle": - "Choose conversations from an official Claude data ZIP. They are imported into Chat mode.", - "chat.history.claudeOfficialImportDialogEmpty": "No official Claude conversations to import.", "chat.history.claudeCodeImporting": "Importing Claude Code conversations…", "chat.history.claudeCodeImportFailed": "Failed to import Claude Code conversations", "chat.history.claudeCodeImportDialogTitle": "Import Claude Code conversations", @@ -3337,16 +3319,10 @@ export const translations: Record> = { "settings.codexImportResult": "Imported {imported} conversations from {scanned} scanned Codex session files.", "settings.claudeImportTitle": "Claude conversations", - "settings.claudeImportDescription": - "Import conversations from local Claude Code or an Official Claude data ZIP.", + "settings.claudeImportDescription": "Import conversations from local Claude Code.", "settings.claudeImportResult": "Imported {imported} conversations from {scanned} Claude conversations.", "settings.claudeCodeImportTitle": "Claude Code conversations", - "settings.claudeOfficialImportTitle": "Official Claude conversations", - "settings.claudeOfficialImportDescription": - "Choose an official Claude data ZIP. Conversations are imported into Chat mode.", - "settings.claudeOfficialImportResult": - "Imported {imported} conversations from {scanned} official Claude conversations.", "settings.claudeCodeImportDescription": "Import primary sessions from ~/.claude/projects. Subagent and workflow transcripts are excluded.", "settings.claudeCodeImportResult": diff --git a/crates/agent-gui/src/lib/chat/history/chatHistory.ts b/crates/agent-gui/src/lib/chat/history/chatHistory.ts index 0b1e6ebaa..68cd07687 100644 --- a/crates/agent-gui/src/lib/chat/history/chatHistory.ts +++ b/crates/agent-gui/src/lib/chat/history/chatHistory.ts @@ -277,8 +277,6 @@ export type ImportResult = { export type ClaudeCodeImportPreviewSession = ImportPreviewSession; export type ClaudeCodeImportPreview = ImportPreview; export type ClaudeCodeImportResult = ImportResult; -export type ClaudeOfficialImportPreview = ImportPreview; -export type ClaudeOfficialImportResult = ImportResult; export type CodexImportPreviewSession = ImportPreviewSession; export type CodexImportPreview = ImportPreview; export type CodexImportResult = ImportResult; @@ -291,17 +289,6 @@ export async function importClaudeCodeChatHistory(ids: string[]) { return invoke("chat_history_import_claude_code", { ids }); } -export async function scanClaudeOfficialChatHistory(zipPath: string) { - return invoke("chat_history_scan_claude_official", { zipPath }); -} - -export async function importClaudeOfficialChatHistory(zipPath: string, ids: string[]) { - return invoke("chat_history_import_claude_official", { - zipPath, - ids, - }); -} - export async function scanCodexChatHistory() { return invoke("chat_history_scan_codex"); } diff --git a/crates/agent-gui/src/pages/settings/ConversationsSection.tsx b/crates/agent-gui/src/pages/settings/ConversationsSection.tsx index afad2a283..2561a044a 100644 --- a/crates/agent-gui/src/pages/settings/ConversationsSection.tsx +++ b/crates/agent-gui/src/pages/settings/ConversationsSection.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { useState } from "react"; import { History, Loader2, Upload } from "../../components/icons"; import { Button } from "../../components/ui/button"; @@ -7,10 +6,8 @@ import { type ImportPreview, type ImportResult, importClaudeCodeChatHistory, - importClaudeOfficialChatHistory, importCodexChatHistory, scanClaudeCodeChatHistory, - scanClaudeOfficialChatHistory, scanCodexChatHistory, } from "../../lib/chat/history/chatHistory"; import { ImportDialog, type ImportSource } from "./ImportDialog"; @@ -18,7 +15,6 @@ import { ImportDialog, type ImportSource } from "./ImportDialog"; type ImportDialogState = { source: ImportSource; preview: ImportPreview; - zipPath?: string; }; export function ConversationsSection() { @@ -34,35 +30,19 @@ export function ConversationsSection() { setResult(null); setError(null); try { - const zipPath = - source === "claude-official" - ? await invoke("system_pick_file", { - initialWorkdir: undefined, - filterName: "Claude data export", - extensions: ["zip"], - }) - : undefined; - if (source === "claude-official" && !zipPath) return; - const officialZipPath = zipPath ?? ""; const preview = - source === "codex" - ? await scanCodexChatHistory() - : source === "claude-code" - ? await scanClaudeCodeChatHistory() - : await scanClaudeOfficialChatHistory(officialZipPath); + source === "codex" ? await scanCodexChatHistory() : await scanClaudeCodeChatHistory(); if (preview.sessions.length === 0) { setError( t( source === "codex" ? "chat.history.codexImportDialogEmpty" - : source === "claude-code" - ? "chat.history.claudeCodeImportDialogEmpty" - : "chat.history.claudeOfficialImportDialogEmpty", + : "chat.history.claudeCodeImportDialogEmpty", ), ); return; } - setDialog({ source, preview, zipPath: zipPath ?? undefined }); + setDialog({ source, preview }); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } finally { @@ -70,7 +50,7 @@ export function ConversationsSection() { } } - async function handleConfirm(source: ImportSource, ids: string[], zipPath?: string) { + async function handleConfirm(source: ImportSource, ids: string[]) { setDialog(null); setImporting(source); setError(null); @@ -78,9 +58,7 @@ export function ConversationsSection() { const value = source === "codex" ? await importCodexChatHistory(ids) - : source === "claude-code" - ? await importClaudeCodeChatHistory(ids) - : await importClaudeOfficialChatHistory(zipPath ?? "", ids); + : await importClaudeCodeChatHistory(ids); setResult({ source, value }); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); @@ -113,18 +91,6 @@ export function ConversationsSection() { ) : null} {t("chat.history.claudeCodeImport")} -
{sourceResult ? ( @@ -205,7 +171,7 @@ export function ConversationsSection() { source={dialog.source} preview={dialog.preview} onClose={() => setDialog(null)} - onConfirm={(ids) => void handleConfirm(dialog.source, ids, dialog.zipPath)} + 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 index 5fa162e85..9cc77b839 100644 --- a/crates/agent-gui/src/pages/settings/ImportDialog.tsx +++ b/crates/agent-gui/src/pages/settings/ImportDialog.tsx @@ -8,7 +8,7 @@ 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" | "claude-official"; +export type ImportSource = "codex" | "claude-code"; type ImportDialogProps = { source: ImportSource; @@ -18,10 +18,9 @@ type ImportDialogProps = { }; const NO_CWD_KEY = "__liveagent_no_cwd__"; -const CHAT_MODE_KEY = "__liveagent_chat_mode__"; function workspaceLabel(cwd: string): string { - if (cwd === NO_CWD_KEY || cwd === CHAT_MODE_KEY) return ""; + if (cwd === NO_CWD_KEY) return ""; const segments = cwd.split(/[\\/]/).filter(Boolean); return segments[segments.length - 1] ?? cwd; } @@ -33,7 +32,6 @@ const LABELS: Record< subtitle: string; workspaces: string; noWorkspace: string; - chatMode?: string; empty: string; all: string; none: string; @@ -69,29 +67,12 @@ const LABELS: Record< alreadyImported: "chat.history.claudeCodeImportDialogAlreadyImported", messages: "chat.history.claudeCodeImportDialogMessages", }, - "claude-official": { - title: "chat.history.claudeOfficialImportDialogTitle", - subtitle: "chat.history.claudeOfficialImportDialogSubtitle", - workspaces: "settings.executionMode", - noWorkspace: "chat.history.claudeCodeImportDialogNoWorkspace", - chatMode: "settings.chatMode", - empty: "chat.history.claudeOfficialImportDialogEmpty", - 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[]][] { - if (source === "claude-official") { - return [[CHAT_MODE_KEY, sessions]]; - } const map = new Map(); for (const session of sessions) { const key = session.cwd?.trim() ? session.cwd : NO_CWD_KEY; @@ -116,8 +97,7 @@ export function ImportDialog({ source, preview, onClose, onConfirm }: ImportDial const { t } = useLocale(); const { modalState, requestClose } = useModalMotion(onClose); const labels = LABELS[source]; - const isOfficial = source === "claude-official"; - const WorkspaceIcon = isOfficial ? MessageSquareText : Folder; + const WorkspaceIcon = Folder; const groups = useMemo(() => buildGroups(source, preview.sessions), [source, preview.sessions]); const [activeCwdKey, setActiveCwdKey] = useState(groups[0]?.[0] ?? null); @@ -127,7 +107,6 @@ export function ImportDialog({ source, preview, onClose, onConfirm }: ImportDial const formatKey = useCallback( (key: string) => { - if (key === CHAT_MODE_KEY) return t(labels.chatMode ?? "settings.chatMode"); if (key === NO_CWD_KEY) return t(labels.noWorkspace); return workspaceLabel(key); }, @@ -235,7 +214,7 @@ export function ImportDialog({ source, preview, onClose, onConfirm }: ImportDial /> {formatKey(key)} - {key !== NO_CWD_KEY && key !== CHAT_MODE_KEY ? ( + {key !== NO_CWD_KEY ? ( Date: Fri, 31 Jul 2026 01:15:17 -0700 Subject: [PATCH 12/14] fix(ui): clarify Claude Code import action --- crates/agent-gui/src/i18n/config.ts | 4 ++-- .../pages/settings/ConversationsSection.tsx | 23 ++++++++----------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 233ab0697..fee760c49 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -140,7 +140,7 @@ export const translations: Record> = { "chat.history.openFailed": "读取历史对话失败", "chat.history.persistFailed": "历史记录保存失败:{message}", "chat.history.codexImport": "导入 Codex 对话", - "chat.history.claudeCodeImport": "导入本机 Claude Code", + "chat.history.claudeCodeImport": "导入 Claude Code 对话", "chat.history.claudeImportFailed": "Claude 对话导入失败", "chat.history.claudeCodeImporting": "正在导入 Claude Code 对话…", "chat.history.claudeCodeImportFailed": "Claude Code 对话导入失败", @@ -2452,7 +2452,7 @@ export const translations: Record> = { "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 local Claude Code", + "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", diff --git a/crates/agent-gui/src/pages/settings/ConversationsSection.tsx b/crates/agent-gui/src/pages/settings/ConversationsSection.tsx index 2561a044a..5f87afb71 100644 --- a/crates/agent-gui/src/pages/settings/ConversationsSection.tsx +++ b/crates/agent-gui/src/pages/settings/ConversationsSection.tsx @@ -79,19 +79,16 @@ export function ConversationsSection() { {t("settings.claudeImportDescription")}

-
- -
+
{sourceResult ? (
From dae2ae4ca58a8056e858dd21d956fe0bb2f33374 Mon Sep 17 00:00:00 2001 From: FlowerRealm Date: Fri, 31 Jul 2026 02:21:10 -0700 Subject: [PATCH 13/14] chore: retrigger PR checks From d50f047a842bc0f67bb2724432f1b61c5ef6b00a Mon Sep 17 00:00:00 2001 From: FlowerRealm Date: Sat, 1 Aug 2026 17:29:56 -0700 Subject: [PATCH 14/14] fix: skip Claude meta messages during import --- .../src/commands/history/chat_history/claude_code_import.rs | 3 +++ 1 file changed, 3 insertions(+) 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 index 8f31ec089..fe629a6ec 100644 --- 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 @@ -77,6 +77,9 @@ fn convert_claude_code_file( 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; };