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")}
-
@@ -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(
+
+
+
+
+
+
+
+
+
+ {defaultWorkspaceOnly
+ ? t("chat.history.claudeOfficialImportDialogTitle")
+ : t("chat.history.claudeCodeImportDialogTitle")}
+
+
+ {defaultWorkspaceOnly
+ ? t("chat.history.claudeOfficialImportDialogSubtitle")
+ : t("chat.history.claudeCodeImportDialogSubtitle")}
+
+
+
+
+
+
+
+
+ {/* 左栏:工作区 */}
+
+
+ {t("chat.history.claudeCodeImportDialogWorkspaces")}
+ {
+ const anyUnselected = preview.sessions.some(
+ (s) => !s.alreadyImported && !selected.has(s.id),
+ );
+ setSelected(() => {
+ const next = new Set();
+ if (anyUnselected) {
+ preview.sessions.forEach((s) => {
+ if (!s.alreadyImported) next.add(s.id);
+ });
+ }
+ return next;
+ });
+ }}
+ className="text-primary hover:underline"
+ >
+ {preview.sessions.some((s) => !s.alreadyImported && !selected.has(s.id))
+ ? t("chat.history.claudeCodeImportDialogAll")
+ : t("chat.history.claudeCodeImportDialogNone")}
+
+
+
+ {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 (
+
setActiveCwdKey(key)}
+ className={cn(
+ "mb-1 flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors",
+ isActive ? "bg-primary/10" : "hover:bg-muted/50",
+ )}
+ >
+ {
+ e.stopPropagation();
+ toggleGroup(key);
+ }}
+ className={cn(
+ "flex h-4 w-4 shrink-0 items-center justify-center rounded border",
+ allSelected
+ ? "border-primary bg-primary text-primary-foreground"
+ : "border-border bg-background",
+ )}
+ >
+ {allSelected ? : null}
+
+
+
+
+ {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 ? (
+ toggleGroup(activeCwdKey ?? NO_CWD_KEY)}
+ className="text-[11px] text-primary hover:underline"
+ >
+ {activeSessionsAllSelected
+ ? t("chat.history.claudeCodeImportDialogNone")
+ : t("chat.history.claudeCodeImportDialogAll")}
+
+ ) : null}
+
+
+ {activeSessions.length === 0 ? (
+
+ {t("chat.history.claudeCodeImportDialogEmpty")}
+
+ ) : (
+ activeSessions.map((session) => {
+ const checked = selected.has(session.id);
+ return (
+
toggleSession(session)}
+ disabled={session.alreadyImported}
+ className={cn(
+ "mb-1 flex w-full items-start gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors",
+ session.alreadyImported
+ ? "cursor-not-allowed opacity-50"
+ : "hover:bg-muted/50",
+ )}
+ >
+
+ {checked ? : null}
+
+
+
+ {session.title}
+
+ {session.model} · {session.messageCount}{" "}
+ {t("chat.history.claudeCodeImportDialogMessages")}
+
+
+ {session.alreadyImported ? (
+
+ {t("chat.history.claudeCodeImportDialogAlreadyImported")}
+
+ ) : null}
+
+ );
+ })
+ )}
+
+
+
+
+
+
+ {t("chat.history.claudeCodeImportDialogSelected").replace(
+ "{count}",
+ String(selected.size),
+ )}
+
+
+
+ {t("settings.cancel")}
+
+ onConfirm(Array.from(selected))}
+ disabled={selected.size === 0}
+ >
+ {t("chat.history.claudeCodeImportDialogImport").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")}
+
+
+
+ void handleScan("claude-code")}
+ disabled={busy}
+ >
+ {scanning === "claude-code" || importing === "claude-code" ? (
+
+ ) : null}
+ {t("chat.history.claudeCodeImport")}
+
+ void handleScan("claude-official")}
+ disabled={busy}
+ >
+ {scanning === "claude-official" || importing === "claude-official" ? (
+
+ ) : (
+
+ )}
+ {t("chat.history.claudeOfficialImport")}
+
+
-
+ {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")}
-
void handleScan()} disabled={scanning || importing}>
- {scanning || importing ? (
+ void handleScan("codex")} disabled={busy}>
+ {scanning === "codex" || importing === "codex" ? (
) : (
)}
- {scanning || importing
+ {scanning === "codex" || importing === "codex"
? t("chat.history.codexImporting")
: t("chat.history.codexImport")}
-
- {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")}
+
{
@@ -218,21 +223,30 @@ export function ClaudeCodeImportDialog({
>
{allSelected ? : null}
-
+ {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