Files
CC-Switch/src-tauri/src/session_manager/providers/utils.rs
T
BlueOcean bd3cfb7741 fix: parse tool_use/tool_result messages and add OpenCode SQLite backend (#1401)
* fix: parse tool_use/tool_result messages and add OpenCode SQLite backend

  - Claude: reclassify user messages containing tool_result as "tool" role
  - Codex: handle function_call and function_call_output payload types
  - Gemini: support array content and toolCalls extraction, filter info/error types
  - OpenCode: add SQLite session scan, load and delete alongside legacy JSON
  - utils: extend parse_timestamp_to_ms for integer timestamps, extract tool_use/tool_result in shared extract_text

* fix: address remaining issues from tool_use/tool_result parsing commit
  - Claude: fix role misclassification for mixed user+tool_result messages (any → all)
  - OpenCode: extract duplicate part text logic into extract_part_text()
  - OpenCode: add path validation for SQLite delete to prevent foreign DB access
  - OpenCode: wrap SQLite deletion in transaction for atomicity
  - openclaw_config: remove redundant as_deref() on Option<&str>
2026-03-22 22:02:35 +08:00

175 lines
5.2 KiB
Rust

use std::fs::File;
use std::io::{self, BufRead, BufReader, Seek, SeekFrom};
use std::path::Path;
use chrono::{DateTime, FixedOffset};
use serde_json::Value;
/// Read the first `head_n` lines and last `tail_n` lines from a file.
/// For small files (< 16 KB), reads all lines once to avoid unnecessary seeking.
pub fn read_head_tail_lines(
path: &Path,
head_n: usize,
tail_n: usize,
) -> io::Result<(Vec<String>, Vec<String>)> {
let file = File::open(path)?;
let file_len = file.metadata()?.len();
// For small files, read all lines once and split
if file_len < 16_384 {
let reader = BufReader::new(file);
let all: Vec<String> = reader.lines().map_while(Result::ok).collect();
let head = all.iter().take(head_n).cloned().collect();
let skip = all.len().saturating_sub(tail_n);
let tail = all.into_iter().skip(skip).collect();
return Ok((head, tail));
}
// Read head lines from the beginning
let reader = BufReader::new(file);
let head: Vec<String> = reader.lines().take(head_n).map_while(Result::ok).collect();
// Seek to last ~16 KB for tail lines
let seek_pos = file_len.saturating_sub(16_384);
let mut file2 = File::open(path)?;
file2.seek(SeekFrom::Start(seek_pos))?;
let tail_reader = BufReader::new(file2);
let all_tail: Vec<String> = tail_reader.lines().map_while(Result::ok).collect();
// Skip first partial line if we seeked into the middle of a line
let skip_first = if seek_pos > 0 { 1 } else { 0 };
let usable: Vec<String> = all_tail.into_iter().skip(skip_first).collect();
let skip = usable.len().saturating_sub(tail_n);
let tail = usable.into_iter().skip(skip).collect();
Ok((head, tail))
}
pub fn parse_timestamp_to_ms(value: &Value) -> Option<i64> {
// Integer: milliseconds (>1e12) or seconds
if let Some(n) = value.as_i64() {
return Some(if n > 1_000_000_000_000 { n } else { n * 1000 });
}
if let Some(n) = value.as_f64() {
let n = n as i64;
return Some(if n > 1_000_000_000_000 { n } else { n * 1000 });
}
// RFC3339 string
let raw = value.as_str()?;
DateTime::parse_from_rfc3339(raw)
.ok()
.map(|dt: DateTime<FixedOffset>| dt.timestamp_millis())
}
pub fn extract_text(content: &Value) -> String {
match content {
Value::String(text) => text.to_string(),
Value::Array(items) => items
.iter()
.filter_map(extract_text_from_item)
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n"),
Value::Object(map) => map
.get("text")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
_ => String::new(),
}
}
fn extract_text_from_item(item: &Value) -> Option<String> {
let item_type = item.get("type").and_then(Value::as_str).unwrap_or("");
// tool_use: show tool name
if item_type == "tool_use" {
let name = item
.get("name")
.and_then(Value::as_str)
.unwrap_or("unknown");
return Some(format!("[Tool: {name}]"));
}
// tool_result: extract nested content
if item_type == "tool_result" {
if let Some(content) = item.get("content") {
let text = extract_text(content);
if !text.is_empty() {
return Some(text);
}
}
return None;
}
if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
return Some(text.to_string());
}
if let Some(text) = item.get("input_text").and_then(|v| v.as_str()) {
return Some(text.to_string());
}
if let Some(text) = item.get("output_text").and_then(|v| v.as_str()) {
return Some(text.to_string());
}
if let Some(content) = item.get("content") {
let text = extract_text(content);
if !text.is_empty() {
return Some(text);
}
}
None
}
pub fn truncate_summary(text: &str, max_chars: usize) -> String {
let trimmed = text.trim();
if trimmed.is_empty() {
return String::new();
}
if trimmed.chars().count() <= max_chars {
return trimmed.to_string();
}
let mut result = trimmed.chars().take(max_chars).collect::<String>();
result.push_str("...");
result
}
pub fn path_basename(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
let normalized = trimmed.trim_end_matches(['/', '\\']);
let last = normalized
.split(['/', '\\'])
.next_back()
.filter(|segment| !segment.is_empty())?;
Some(last.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parse_timestamp_to_ms_supports_integers_and_rfc3339() {
assert_eq!(
parse_timestamp_to_ms(&json!(1_771_061_953_033_i64)),
Some(1_771_061_953_033)
);
assert_eq!(
parse_timestamp_to_ms(&json!(1_771_061_953_i64)),
Some(1_771_061_953_000)
);
assert_eq!(
parse_timestamp_to_ms(&json!("1970-01-01T00:00:01Z")),
Some(1_000)
);
}
}