From 85ff0dd4562473d964ee07efa4cbaae941dd0f0b Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 3 Oct 2026 18:56:44 +0800 Subject: [PATCH] refactor(sessions): reuse shared block helpers across the session parsers Behaviour-preserving cleanup; parsed output is byte-identical on 2,482 real local sessions (Codex, Claude, Hermes, OpenCode, Grok Build, Pi, OpenClaw). - Build Event blocks with SessionBlock::event instead of 15 hand-written literals. - Drop codex_items' copies of count_diff_lines and saturating_u32. - Move Hermes' OpenAI-shaped tool_calls parser into blocks and reuse it for Grok Build, which had a line-for-line copy. - Call the OpenCode / Hermes parse_sqlite_source from content.rs instead of keeping verbatim copies there. - Add CallSpec::new / ResultSpec::empty so the Codex call and result literals only spell out non-default fields, and remove the always-empty FileChangeInfo.output. --- src-tauri/src/session_manager/cache.rs | 7 +- src-tauri/src/session_manager/content.rs | 29 +-- .../src/session_manager/providers/blocks.rs | 33 +++- .../src/session_manager/providers/claude.rs | 29 ++- .../src/session_manager/providers/codex.rs | 183 ++++++------------ .../session_manager/providers/codex_items.rs | 87 +++++---- .../src/session_manager/providers/gemini.rs | 7 +- .../session_manager/providers/grokbuild.rs | 33 +--- .../src/session_manager/providers/hermes.rs | 38 +--- .../src/session_manager/providers/opencode.rs | 2 +- .../providers/opencode_blocks.rs | 40 ++-- .../session_manager/providers/pi_blocks.rs | 25 +-- 12 files changed, 187 insertions(+), 326 deletions(-) diff --git a/src-tauri/src/session_manager/cache.rs b/src-tauri/src/session_manager/cache.rs index a5fef17da..8300c9ec2 100644 --- a/src-tauri/src/session_manager/cache.rs +++ b/src-tauri/src/session_manager/cache.rs @@ -610,12 +610,7 @@ mod tests { SessionMessage::from_blocks( "assistant", None, - vec![SessionBlock::Event { - kind: EventKind::Aborted, - text: None, - url: None, - full: None, - }], + vec![SessionBlock::event(EventKind::Aborted, None, None)], ), ]; diff --git a/src-tauri/src/session_manager/content.rs b/src-tauri/src/session_manager/content.rs index 9d40b1a04..494169c92 100644 --- a/src-tauri/src/session_manager/content.rs +++ b/src-tauri/src/session_manager/content.rs @@ -18,6 +18,7 @@ use serde::Serialize; use serde_json::Value; use super::model::{ContentRef, ImageRef, ImageSource}; +use super::providers::{hermes, opencode}; use super::{canonicalize_existing_path, provider_roots}; /// 单次读取的全文上限 @@ -91,12 +92,12 @@ pub fn validate_source(provider_id: &str, source_path: &str) -> Result validate_sqlite_source( source_path, - parse_opencode_sqlite(source_path), + opencode::parse_sqlite_source(source_path), &crate::opencode_config::get_opencode_db_path(), )?, "hermes" if source_path.starts_with("sqlite:") => validate_sqlite_source( source_path, - parse_hermes_sqlite(source_path), + hermes::parse_sqlite_source(source_path), &crate::hermes_config::get_hermes_dir().join("state.db"), )?, _ => { @@ -149,24 +150,6 @@ pub(super) fn resolve_under_roots( )) } -/// `sqlite::`(与 `providers::opencode::parse_sqlite_source` 同规则) -fn parse_opencode_sqlite(source: &str) -> Option<(PathBuf, String)> { - let rest = source.strip_prefix("sqlite:")?; - let sep = rest.rfind(":ses_")?; - Some((PathBuf::from(&rest[..sep]), rest[sep + 1..].to_string())) -} - -/// `sqlite:#`(与 `providers::hermes::parse_sqlite_source` 同规则) -fn parse_hermes_sqlite(source: &str) -> Option<(PathBuf, String)> { - let rest = source.strip_prefix("sqlite:")?; - let hash = rest.rfind('#')?; - let session_id = &rest[hash + 1..]; - if session_id.is_empty() { - return None; - } - Some((PathBuf::from(&rest[..hash]), session_id.to_string())) -} - fn validate_sqlite_source( raw: &str, parsed: Option<(PathBuf, String)>, @@ -709,14 +692,14 @@ mod tests { std::fs::write(&other, "").unwrap(); let raw = format!("sqlite:{}:ses_1", own.display()); - let ok = validate_sqlite_source(&raw, parse_opencode_sqlite(&raw), &own).unwrap(); + let ok = validate_sqlite_source(&raw, opencode::parse_sqlite_source(&raw), &own).unwrap(); assert!( matches!(ok, SourceLocation::Sqlite { ref session_id, .. } if session_id == "ses_1") ); let raw = format!("sqlite:{}:ses_1", other.display()); - assert!(validate_sqlite_source(&raw, parse_opencode_sqlite(&raw), &own).is_err()); - assert!(parse_hermes_sqlite("sqlite:/x/state.db#").is_none()); + assert!(validate_sqlite_source(&raw, opencode::parse_sqlite_source(&raw), &own).is_err()); + assert!(hermes::parse_sqlite_source("sqlite:/x/state.db#").is_none()); } /// 超长注入文本与压缩摘要只下发预览:`full` 必须能取回原文(Claude 与 Codex 解析器) diff --git a/src-tauri/src/session_manager/providers/blocks.rs b/src-tauri/src/session_manager/providers/blocks.rs index a10fcd586..40d3b4536 100644 --- a/src-tauri/src/session_manager/providers/blocks.rs +++ b/src-tauri/src/session_manager/providers/blocks.rs @@ -251,7 +251,7 @@ pub fn preview_with(text: &str, max_lines: usize, max_chars: usize) -> Preview { } } -fn saturating_u32(n: usize) -> u32 { +pub(super) fn saturating_u32(n: usize) -> u32 { u32::try_from(n).unwrap_or(u32::MAX) } @@ -567,6 +567,37 @@ fn str_field_allow_empty<'a>(input: &'a Value, keys: &[&str]) -> Option<&'a str> .find_map(|key| input.get(*key).and_then(Value::as_str)) } +/// OpenAI 形状的 `tool_calls[]`(`{id, function{name, arguments}}`,也兼容扁平 `{id, name, arguments}`) +/// → ToolCall。`arguments_ref(pointer)` 为参数全文生成引用,`pointer` 是参数在 `tool_calls` +/// 数组内的 JSON Pointer(`/{i}/function/arguments` 或扁平形状的 `/{i}/arguments`)。 +pub(super) fn openai_tool_calls( + calls: Option<&Value>, + arguments_ref: impl Fn(String) -> Option, +) -> Vec { + calls + .and_then(Value::as_array) + .into_iter() + .flatten() + .enumerate() + .map(|(i, call)| { + let name = call + .pointer("/function/name") + .or_else(|| call.get("name")) + .and_then(Value::as_str) + .unwrap_or("unknown"); + let id = call.get("id").and_then(Value::as_str).unwrap_or_default(); + let (pointer, raw) = match call.pointer("/function/arguments") { + Some(raw) => (format!("/{i}/function/arguments"), Some(raw)), + None => (format!("/{i}/arguments"), call.get("arguments")), + }; + let input = raw.map(parse_arguments).unwrap_or(Value::Null); + tool_call_block(ToolSource::Generic, id, name, &input, || { + arguments_ref(pointer) + }) + }) + .collect() +} + /// 由参数构造 `ToolCall`:归一化 kind、提炼标题、参数预览;参数超长时调用 `input_full` 生成引用。 /// Edit/Write 先按参数估算 diff,解析器拿到更准的数据再覆盖。 pub fn tool_call_block( diff --git a/src-tauri/src/session_manager/providers/claude.rs b/src-tauri/src/session_manager/providers/claude.rs index e7e000c14..b14cf515e 100644 --- a/src-tauri/src/session_manager/providers/claude.rs +++ b/src-tauri/src/session_manager/providers/claude.rs @@ -414,12 +414,7 @@ impl TranscriptBuilder { "system", ts, None, - vec![SessionBlock::Event { - kind, - text, - url, - full: None, - }], + vec![SessionBlock::event(kind, text, url)], ); } @@ -681,19 +676,17 @@ impl TranscriptBuilder { }; if let Some(first) = texts.iter().map(|(t, _)| t.trim()).find(|t| !t.is_empty()) { if first.starts_with("[Request interrupted by user") { - blocks.push(SessionBlock::Event { - kind: EventKind::Aborted, - text: Some(first.to_string()), - url: None, - full: None, - }); + blocks.push(SessionBlock::event( + EventKind::Aborted, + Some(first.to_string()), + None, + )); } else if let Some(command) = slash_command(first) { - blocks.push(SessionBlock::Event { - kind: EventKind::SlashCommand, - text: Some(command), - url: None, - full: None, - }); + blocks.push(SessionBlock::event( + EventKind::SlashCommand, + Some(command), + None, + )); } else if first.starts_with("") { blocks.extend(user_bash_blocks(first, uuid.as_deref())); } else if is_injected_text(first) { diff --git a/src-tauri/src/session_manager/providers/codex.rs b/src-tauri/src/session_manager/providers/codex.rs index 81f940d71..04cdbc5dc 100644 --- a/src-tauri/src/session_manager/providers/codex.rs +++ b/src-tauri/src/session_manager/providers/codex.rs @@ -797,12 +797,11 @@ impl RolloutParser { "system", ts, None, - vec![SessionBlock::Event { - kind: EventKind::ModelChange, - text: Some(model), - url: None, - full: None, - }], + vec![SessionBlock::event( + EventKind::ModelChange, + Some(model), + None, + )], false, ); } @@ -819,12 +818,7 @@ impl RolloutParser { "system", ts, None, - vec![SessionBlock::Event { - kind: EventKind::Aborted, - text: Some(reason), - url: None, - full: None, - }], + vec![SessionBlock::event(EventKind::Aborted, Some(reason), None)], false, ); } @@ -1066,12 +1060,7 @@ impl RolloutParser { } else { format!("{author}: {}", first.trim()) }; - let mut blocks = vec![SessionBlock::Event { - kind: EventKind::SubAgent, - text: Some(event), - url: None, - full: None, - }]; + let mut blocks = vec![SessionBlock::event(EventKind::SubAgent, Some(event), None)]; if !rest.trim().is_empty() { blocks.push(SessionBlock::text(rest.trim().to_string())); } @@ -1094,27 +1083,14 @@ impl RolloutParser { Some(ToolStatus::Interrupted) => ToolStatus::Interrupted, _ => ToolStatus::Success, }; - let call = CallSpec { - id: id.clone(), - raw_name: "web_search".to_string(), - kind: ToolKind::Web, - title: web_action_title(&action), - detail: None, - server: None, - input: &input, - input_ref: None, - diff: None, - by_user: false, - }; - let result = ResultSpec { - call_id: id.clone(), - status, - output: preview(""), - full: None, - exit_code: None, - duration_ms: None, - images: Vec::new(), - }; + let call = CallSpec::new( + id.clone(), + "web_search", + ToolKind::Web, + web_action_title(&action), + &input, + ); + let result = ResultSpec::empty(id.clone(), status); self.push_assistant(ts, Some(id), vec![call.into_block(), result.into_block()]); } @@ -1244,16 +1220,10 @@ impl RolloutParser { }; let block = CallSpec { - id: call_id.clone(), - raw_name: name.to_string(), - kind, - title, detail, server: normalized.server, - input: arguments, input_ref: Some(span.content_ref("/payload/arguments")), - diff: None, - by_user: false, + ..CallSpec::new(call_id.clone(), name, kind, title, arguments) } .into_block(); self.register_call(ts, owned(&p.id), call_id, block, flavor, cmds); @@ -1267,16 +1237,14 @@ impl RolloutParser { .unwrap_or_default(); let mut spec = CallSpec { - id: call_id.clone(), - raw_name: name.to_string(), - kind: ToolKind::Other, - title: title_other(name), - detail: None, - server: None, - input, input_ref: Some(span.content_ref("/payload/input")), - diff: None, - by_user: false, + ..CallSpec::new( + call_id.clone(), + name, + ToolKind::Other, + title_other(name), + input, + ) }; let (flavor, cmds) = match name { "exec" => { @@ -1321,19 +1289,18 @@ impl RolloutParser { .or_else(|| owned(&p.id)) .unwrap_or_default(); let block = CallSpec { - id: call_id.clone(), - raw_name: "local_shell".to_string(), - kind: refine_shell_kind(&cmd), - title: title_shell(&cmd), detail: action .get("working_directory") .and_then(Value::as_str) .map(str::to_string), - server: None, - input: &input, input_ref: Some(span.content_ref("/payload/action")), - diff: None, - by_user: false, + ..CallSpec::new( + call_id.clone(), + "local_shell", + refine_shell_kind(&cmd), + title_shell(&cmd), + &input, + ) } .into_block(); let cmds = if cmd.is_empty() { @@ -1490,49 +1457,36 @@ impl RolloutParser { } ItemRecord::FileChange(info) => { let call = CallSpec { - id: info.id.clone(), - raw_name: "FileChange".to_string(), - kind: diff_kind(&info.diff), - title: diff_title(&info.diff), - detail: None, - server: None, - input: "", - input_ref: None, diff: Some(info.diff.clone()), - by_user: false, - }; - let result = ResultSpec { - call_id: info.id.clone(), - status: info.status, - output: info.output.clone(), - full: None, - exit_code: None, - duration_ms: None, - images: Vec::new(), + ..CallSpec::new( + info.id.clone(), + "FileChange", + diff_kind(&info.diff), + diff_title(&info.diff), + "", + ) }; + let result = ResultSpec::empty(info.id.clone(), info.status); (info.id, vec![call.into_block(), result.into_block()]) } ItemRecord::Mcp(info) => { let call = CallSpec { - id: info.id.clone(), - raw_name: info.tool.clone(), - kind: ToolKind::Mcp, - title: title_mcp(&info.server, &info.tool), detail: info.detail.clone(), server: Some(info.server.clone()), - input: &info.input, - input_ref: None, - diff: None, - by_user: false, + ..CallSpec::new( + info.id.clone(), + info.tool.clone(), + ToolKind::Mcp, + title_mcp(&info.server, &info.tool), + &info.input, + ) }; let result = ResultSpec { - call_id: info.id.clone(), - status: info.status, output: info.output.clone(), full: info.output_ref.clone(), - exit_code: None, duration_ms: info.duration_ms, images: info.images.clone(), + ..ResultSpec::empty(info.id.clone(), info.status) }; let blocks = vec![call.into_block(), result.into_block()]; (info.id, blocks) @@ -1544,27 +1498,14 @@ impl RolloutParser { }], ), ItemRecord::Web(info) => { - let call = CallSpec { - id: info.id.clone(), - raw_name: "web_search".to_string(), - kind: ToolKind::Web, - title: info.title.clone(), - detail: None, - server: None, - input: &info.input, - input_ref: None, - diff: None, - by_user: false, - }; - let result = ResultSpec { - call_id: info.id.clone(), - status: ToolStatus::Success, - output: preview(""), - full: None, - exit_code: None, - duration_ms: None, - images: Vec::new(), - }; + let call = CallSpec::new( + info.id.clone(), + "web_search", + ToolKind::Web, + info.title.clone(), + &info.input, + ); + let result = ResultSpec::empty(info.id.clone(), ToolStatus::Success); let blocks = vec![call.into_block(), result.into_block()]; (info.id, blocks) } @@ -1593,13 +1534,12 @@ impl RolloutParser { None => { let status = output_status(&out, None); let result = ResultSpec { - call_id, - status, output: preview(&out.text), full: out.full, exit_code: out.exit_code, duration_ms: out.duration_ms, images: out.images, + ..ResultSpec::empty(call_id, status) }; self.push("tool", ts, id, vec![result.into_block()], false); } @@ -1766,8 +1706,6 @@ impl RolloutParser { images }; ResultSpec { - call_id: last_id, - status, output: preview(&out.text), full: out.full, exit_code: last_command.and_then(|c| c.exit_code).or(out.exit_code), @@ -1776,6 +1714,7 @@ impl RolloutParser { .or(out.duration_ms) .or(mcp.as_ref().and_then(|m| m.duration_ms)), images, + ..ResultSpec::empty(last_id, status) } } None => match last_command { @@ -1786,15 +1725,7 @@ impl RolloutParser { } spec } - None => ResultSpec { - call_id: last_id, - status: ToolStatus::Interrupted, - output: preview(""), - full: None, - exit_code: None, - duration_ms: None, - images: Vec::new(), - }, + None => ResultSpec::empty(last_id, ToolStatus::Interrupted), }, }; results.push(spec.into_block()); diff --git a/src-tauri/src/session_manager/providers/codex_items.rs b/src-tauri/src/session_manager/providers/codex_items.rs index 8529ce49d..79a0cd966 100644 --- a/src-tauri/src/session_manager/providers/codex_items.rs +++ b/src-tauri/src/session_manager/providers/codex_items.rs @@ -23,8 +23,9 @@ use crate::session_manager::model::{ }; use super::blocks::{ - kind_from_codex_parsed_cmd, one_line_title, preview, preview_chars, refine_shell_kind, - title_mcp, title_path, title_shell, title_web, Preview, INPUT_PREVIEW_CHARS, + count_diff_lines, kind_from_codex_parsed_cmd, one_line_title, preview, preview_chars, + refine_shell_kind, saturating_u32, title_mcp, title_path, title_shell, title_web, Preview, + INPUT_PREVIEW_CHARS, }; use super::utils::JsonlSpan; @@ -294,7 +295,6 @@ pub(super) struct FileChangeInfo { pub id: String, pub diff: DiffSummary, pub status: ToolStatus, - pub output: Preview, } #[derive(Debug, Clone)] @@ -420,7 +420,7 @@ impl RawItem<'_> { "/payload/item/changes/{}/unified_diff", escape_pointer_token(path) )); - count_unified_diff(diff) + count_diff_lines(diff) } else { let lines = change .get("content") @@ -457,7 +457,6 @@ impl RawItem<'_> { id, diff: diff_summary(files, full), status, - output: preview(""), }) } @@ -810,26 +809,6 @@ pub(super) fn parse_legacy_shell_output(text: &str) -> Option<(String, Option u32 { - u32::try_from(n).unwrap_or(u32::MAX) -} - -/// unified diff 的增删行数(不计 `+++` / `---` 文件头) -pub(super) fn count_unified_diff(diff: &str) -> (u32, u32) { - let (mut added, mut removed) = (0u32, 0u32); - for line in diff.lines() { - if line.starts_with("+++") || line.starts_with("---") { - continue; - } - if line.starts_with('+') { - added += 1; - } else if line.starts_with('-') { - removed += 1; - } - } - (added, removed) -} - pub(super) fn diff_summary(files: Vec, full: Option) -> DiffSummary { DiffSummary { added: files.iter().map(|f| f.added).sum(), @@ -968,7 +947,29 @@ pub(super) struct CallSpec<'s> { pub by_user: bool, } -impl CallSpec<'_> { +impl<'s> CallSpec<'s> { + /// 只填必需字段的调用,其余字段按需用 `..` 覆盖 + pub fn new( + id: String, + raw_name: impl Into, + kind: ToolKind, + title: String, + input: &'s str, + ) -> Self { + Self { + id, + raw_name: raw_name.into(), + kind, + title, + detail: None, + server: None, + input, + input_ref: None, + diff: None, + by_user: false, + } + } + pub fn into_block(self) -> SessionBlock { let input = preview_chars(self.input, INPUT_PREVIEW_CHARS); SessionBlock::ToolCall { @@ -1003,6 +1004,19 @@ pub(super) struct ResultSpec { } impl ResultSpec { + /// 没有输出的结果 + pub fn empty(call_id: String, status: ToolStatus) -> Self { + Self { + call_id, + status, + output: preview(""), + full: None, + exit_code: None, + duration_ms: None, + images: Vec::new(), + } + } + pub fn into_block(self) -> SessionBlock { let truncated = self.output.truncated; SessionBlock::ToolResult { @@ -1025,28 +1039,25 @@ impl CommandInfo { /// 单独成块的 CommandExecution 工具调用(没有对应 exec 时) pub fn call_spec(&self) -> CallSpec<'_> { CallSpec { - id: self.id.clone(), - raw_name: "CommandExecution".to_string(), - kind: self.kind, - title: title_shell(&self.command), detail: self.cwd.clone(), - server: None, - input: &self.command, - input_ref: None, - diff: None, by_user: self.by_user, + ..CallSpec::new( + self.id.clone(), + "CommandExecution", + self.kind, + title_shell(&self.command), + &self.command, + ) } } pub fn result_spec(&self, call_id: String) -> ResultSpec { ResultSpec { - call_id, - status: self.status, output: self.output.clone(), full: self.output_ref.clone(), exit_code: self.exit_code, duration_ms: self.duration_ms, - images: Vec::new(), + ..ResultSpec::empty(call_id, self.status) } } } @@ -1185,7 +1196,7 @@ mod tests { assert_eq!(diff_title(&diff), "a.rs 等 3 个文件"); assert_eq!( - count_unified_diff("--- a\n+++ b\n@@\n-x\n+y\n+z\n ctx"), + count_diff_lines("--- a\n+++ b\n@@\n-x\n+y\n+z\n ctx"), (2, 1) ); } diff --git a/src-tauri/src/session_manager/providers/gemini.rs b/src-tauri/src/session_manager/providers/gemini.rs index 22f96070b..9cc7cd861 100644 --- a/src-tauri/src/session_manager/providers/gemini.rs +++ b/src-tauri/src/session_manager/providers/gemini.rs @@ -111,12 +111,7 @@ pub fn load_messages(path: &Path) -> Result, String> { let mut message = SessionMessage::from_blocks( "system", ts, - vec![SessionBlock::Event { - kind: event_kind, - text: Some(text), - url: None, - full: None, - }], + vec![SessionBlock::event(event_kind, Some(text), None)], ); // info(登录、刷新等提示)默认折叠 message.injected = kind == "info"; diff --git a/src-tauri/src/session_manager/providers/grokbuild.rs b/src-tauri/src/session_manager/providers/grokbuild.rs index fc6a51974..ddc041326 100644 --- a/src-tauri/src/session_manager/providers/grokbuild.rs +++ b/src-tauri/src/session_manager/providers/grokbuild.rs @@ -6,9 +6,7 @@ use serde_json::Value; use crate::session_manager::model::{SessionBlock, ToolStatus}; use crate::session_manager::{SessionMessage, SessionMeta}; -use super::blocks::{ - assign_turn_ids, parse_arguments, tool_call_block, tool_result_block, ToolSource, -}; +use super::blocks::{assign_turn_ids, openai_tool_calls, tool_result_block}; use super::utils::{ extract_text, for_each_jsonl_value, parse_timestamp_to_ms, truncate_summary, TITLE_MAX_CHARS, }; @@ -97,32 +95,9 @@ pub fn load_messages(path: &Path) -> Result, String> { if !text.trim().is_empty() { blocks.push(SessionBlock::text(text)); } - for (j, call) in value - .get("tool_calls") - .and_then(Value::as_array) - .into_iter() - .flatten() - .enumerate() - { - let name = call - .pointer("/function/name") - .or_else(|| call.get("name")) - .and_then(Value::as_str) - .unwrap_or("unknown"); - let (input_pointer, raw_input) = match call.pointer("/function/arguments") { - Some(raw) => (format!("/tool_calls/{j}/function/arguments"), Some(raw)), - None => (format!("/tool_calls/{j}/arguments"), call.get("arguments")), - }; - let input = raw_input.map(parse_arguments).unwrap_or(Value::Null); - let id = call.get("id").and_then(Value::as_str).unwrap_or_default(); - blocks.push(tool_call_block( - ToolSource::Generic, - id, - name, - &input, - || Some(span.content_ref(input_pointer)), - )); - } + blocks.extend(openai_tool_calls(value.get("tool_calls"), |pointer| { + Some(span.content_ref(format!("/tool_calls{pointer}"))) + })); blocks } _ if text.trim().is_empty() => Vec::new(), diff --git a/src-tauri/src/session_manager/providers/hermes.rs b/src-tauri/src/session_manager/providers/hermes.rs index 8720ad18b..5c30930b7 100644 --- a/src-tauri/src/session_manager/providers/hermes.rs +++ b/src-tauri/src/session_manager/providers/hermes.rs @@ -9,10 +9,7 @@ use crate::hermes_config::get_hermes_dir; use crate::session_manager::model::{ContentRef, SessionBlock, ToolStatus}; use crate::session_manager::{SessionMessage, SessionMeta}; -use super::blocks::{ - assign_turn_ids, parse_arguments, thinking_block, tool_call_block, tool_result_block, - ToolSource, -}; +use super::blocks::{assign_turn_ids, openai_tool_calls, thinking_block, tool_result_block}; use super::utils::for_each_jsonl_value; use super::utils::{ extract_text, parse_timestamp_to_ms, read_head_tail_lines, truncate_summary, TITLE_MAX_CHARS, @@ -488,37 +485,6 @@ fn load_messages_from_conn( Ok(messages) } -/// OpenAI 形状的 `tool_calls[]`(`{id, function{name, arguments}}`,也兼容扁平 `{id, name, arguments}`) -/// → ToolCall。`arguments_ref(pointer)` 为参数全文生成引用,`pointer` 是参数在 `tool_calls` -/// 数组内的 JSON Pointer(`/{i}/function/arguments` 或扁平形状的 `/{i}/arguments`)。 -fn openai_tool_calls( - calls: Option<&Value>, - arguments_ref: impl Fn(String) -> Option, -) -> Vec { - calls - .and_then(Value::as_array) - .into_iter() - .flatten() - .enumerate() - .map(|(i, call)| { - let name = call - .pointer("/function/name") - .or_else(|| call.get("name")) - .and_then(Value::as_str) - .unwrap_or("unknown"); - let id = call.get("id").and_then(Value::as_str).unwrap_or_default(); - let (pointer, raw) = match call.pointer("/function/arguments") { - Some(raw) => (format!("/{i}/function/arguments"), Some(raw)), - None => (format!("/{i}/arguments"), call.get("arguments")), - }; - let input = raw.map(parse_arguments).unwrap_or(Value::Null); - tool_call_block(ToolSource::Generic, id, name, &input, || { - arguments_ref(pointer) - }) - }) - .collect() -} - /// `messages` 表某行某列的引用(`content::sqlite_allowed` 白名单内的列) fn messages_cell(id: i64, column: &str, pointer: String) -> ContentRef { ContentRef::Sqlite { @@ -574,7 +540,7 @@ pub fn delete_session_sqlite(session_id: &str, source: &str) -> Result 0) } -fn parse_sqlite_source(source: &str) -> Option<(PathBuf, String)> { +pub(crate) fn parse_sqlite_source(source: &str) -> Option<(PathBuf, String)> { let rest = source.strip_prefix("sqlite:")?; let hash_pos = rest.rfind('#')?; let db_path = PathBuf::from(&rest[..hash_pos]); diff --git a/src-tauri/src/session_manager/providers/opencode.rs b/src-tauri/src/session_manager/providers/opencode.rs index 098d03184..b8aa9e24e 100644 --- a/src-tauri/src/session_manager/providers/opencode.rs +++ b/src-tauri/src/session_manager/providers/opencode.rs @@ -130,7 +130,7 @@ fn scan_sessions_json() -> Vec { /// Uses `rfind(":ses_")` to split the path from the session ID because the /// db path itself may contain colons (e.g. `C:\Users\...` on Windows). /// This relies on the OpenCode convention that session IDs start with `ses_`. -fn parse_sqlite_source(source: &str) -> Option<(PathBuf, String)> { +pub(crate) fn parse_sqlite_source(source: &str) -> Option<(PathBuf, String)> { let rest = source.strip_prefix("sqlite:")?; let sep = rest.rfind(":ses_")?; let db_path = PathBuf::from(&rest[..sep]); diff --git a/src-tauri/src/session_manager/providers/opencode_blocks.rs b/src-tauri/src/session_manager/providers/opencode_blocks.rs index 38b3f406b..f707a54e2 100644 --- a/src-tauri/src/session_manager/providers/opencode_blocks.rs +++ b/src-tauri/src/session_manager/providers/opencode_blocks.rs @@ -113,12 +113,11 @@ fn push_part_blocks(blocks: &mut Vec, locator: &PartLocator, part: Some("tool") => push_tool_blocks(blocks, locator, part), Some("agent") => { if let Some(name) = part.get("name").and_then(Value::as_str) { - blocks.push(SessionBlock::Event { - kind: EventKind::Other, - text: Some(format!("@{name}")), - url: None, - full: None, - }); + blocks.push(SessionBlock::event( + EventKind::Other, + Some(format!("@{name}")), + None, + )); } } Some("patch") => push_patch_blocks(blocks, part), @@ -327,12 +326,11 @@ fn push_file_block(blocks: &mut Vec, locator: &PartLocator, part: } } if let Some(text) = filename.or_else(|| (!url.is_empty()).then_some(url)) { - blocks.push(SessionBlock::Event { - kind: EventKind::Other, - text: Some(text.to_string()), - url: None, - full: None, - }); + blocks.push(SessionBlock::event( + EventKind::Other, + Some(text.to_string()), + None, + )); } } @@ -341,24 +339,18 @@ fn message_error_event(info: &Value) -> Option { let error = info.get("error")?; let name = error.get("name").and_then(Value::as_str).unwrap_or(""); if name == "MessageAbortedError" { - return Some(SessionBlock::Event { - kind: EventKind::Aborted, - text: None, - url: None, - full: None, - }); + return Some(SessionBlock::event(EventKind::Aborted, None, None)); } let text = error .pointer("/data/message") .and_then(Value::as_str) .filter(|s| !s.is_empty()) .unwrap_or(name); - Some(SessionBlock::Event { - kind: EventKind::Error, - text: (!text.is_empty()).then(|| text.to_string()), - url: None, - full: None, - }) + Some(SessionBlock::event( + EventKind::Error, + (!text.is_empty()).then(|| text.to_string()), + None, + )) } /// 消息级 `cost/tokens/modelID/providerID/time/finish` → meta(0 视为缺省)。 diff --git a/src-tauri/src/session_manager/providers/pi_blocks.rs b/src-tauri/src/session_manager/providers/pi_blocks.rs index 2ca801877..ef60e993a 100644 --- a/src-tauri/src/session_manager/providers/pi_blocks.rs +++ b/src-tauri/src/session_manager/providers/pi_blocks.rs @@ -170,18 +170,12 @@ impl PiTranscript { // 中断 / 截断 / 出错追加事件 match msg.get("stopReason").and_then(Value::as_str) { - Some("aborted") => blocks.push(SessionBlock::Event { - kind: EventKind::Aborted, - text: None, - url: None, - full: None, - }), - Some("length") => blocks.push(SessionBlock::Event { - kind: EventKind::Other, - text: Some("truncated".into()), - url: None, - full: None, - }), + Some("aborted") => blocks.push(SessionBlock::event(EventKind::Aborted, None, None)), + Some("length") => blocks.push(SessionBlock::event( + EventKind::Other, + Some("truncated".into()), + None, + )), Some("error") => blocks.push(SessionBlock::Event { kind: EventKind::Error, text: msg @@ -430,12 +424,7 @@ fn event_message(kind: EventKind, text: String, ts: Option) -> Option