use std::collections::BTreeMap;
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
pub const CONVERSATION_LOG_SUFFIX: &str = ".conversation.jsonl";
static WRITE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DialogConversationLog {
pub timestamp_unix_ms: u128,
pub dialog_id: String,
pub request_id: String,
pub messages: Vec<Value>,
}
#[must_use]
pub fn conversation_log_path(directory: &Path, dialog_id: &str) -> PathBuf {
directory.join(format!("{dialog_id}{CONVERSATION_LOG_SUFFIX}"))
}
#[must_use]
pub fn turns_in_exchange(request_body: Option<&str>, response_body: Option<&str>) -> Vec<Value> {
let mut turns = request_body
.and_then(extract_request_messages)
.unwrap_or_default();
if let Some(body) = response_body {
append_with_overlap(&mut turns, extract_response_messages(body));
}
turns
}
pub fn write_conversation_record(
directory: &Path,
record: &DialogConversationLog,
) -> io::Result<Option<PathBuf>> {
if record.messages.is_empty() {
return Ok(None);
}
fs::create_dir_all(directory)?;
let path = conversation_log_path(directory, &record.dialog_id);
let _guard = WRITE_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
serde_json::to_writer(&mut file, record).map_err(io::Error::other)?;
file.write_all(b"\n")?;
file.flush()?;
Ok(Some(path))
}
pub fn load_conversation_records(
directory: &Path,
dialog_id: &str,
) -> io::Result<Vec<DialogConversationLog>> {
let path = conversation_log_path(directory, dialog_id);
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(error),
};
text.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str::<DialogConversationLog>(line).map_err(io::Error::other))
.collect()
}
#[must_use]
pub fn latest_conversation_id(directory: &Path) -> Option<String> {
let mut newest: Option<(u128, String)> = None;
for entry in fs::read_dir(directory).ok()?.flatten() {
let name = entry.file_name();
let Some(dialog_id) = name.to_str()?.strip_suffix(CONVERSATION_LOG_SUFFIX) else {
continue;
};
let Some(timestamp) = last_record_timestamp(&entry.path()) else {
continue;
};
if newest
.as_ref()
.is_none_or(|(previous, _)| timestamp > *previous)
{
newest = Some((timestamp, dialog_id.to_owned()));
}
}
newest.map(|(_, dialog_id)| dialog_id)
}
fn last_record_timestamp(path: &Path) -> Option<u128> {
let text = fs::read_to_string(path).ok()?;
text.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<DialogConversationLog>(line).ok())
.map(|record| record.timestamp_unix_ms)
.next_back()
}
#[must_use]
pub fn transcript_from_records(records: &[DialogConversationLog]) -> Vec<Value> {
let mut transcript = Vec::new();
for record in records {
append_with_overlap(&mut transcript, record.messages.clone());
}
transcript
}
pub fn append_with_overlap(transcript: &mut Vec<Value>, incoming: Vec<Value>) {
let limit = transcript.len().min(incoming.len());
let overlap = (1..=limit)
.rev()
.find(|size| transcript[transcript.len() - size..] == incoming[..*size])
.unwrap_or(0);
transcript.extend(incoming.into_iter().skip(overlap));
}
#[must_use]
pub fn extract_request_messages(body: &str) -> Option<Vec<Value>> {
let root = serde_json::from_str::<Value>(body).ok()?;
for key in ["messages", "input", "contents"] {
if let Some(array) = root.get(key).and_then(Value::as_array) {
return Some(array.clone());
}
}
root.get("input")
.filter(|input| !input.is_null())
.map(|input| vec![json!({"role": "user", "content": input})])
}
#[must_use]
pub fn extract_response_messages(body: &str) -> Vec<Value> {
serde_json::from_str::<Value>(body).map_or_else(
|_| extract_sse_message(body).into_iter().collect(),
|root| response_messages_from_value(&root),
)
}
fn response_messages_from_value(root: &Value) -> Vec<Value> {
if let Some(choices) = root.get("choices").and_then(Value::as_array) {
return choices
.iter()
.filter_map(|choice| choice.get("message").cloned())
.collect();
}
if let Some(output) = root.get("output").and_then(Value::as_array) {
return output
.iter()
.filter(|item| {
item.get("type")
.and_then(Value::as_str)
.is_none_or(|kind| kind == "message")
})
.cloned()
.collect();
}
if let Some(candidates) = root.get("candidates").and_then(Value::as_array) {
return candidates
.iter()
.filter_map(|candidate| candidate.get("content").cloned())
.collect();
}
if root.get("role").is_some() && root.get("content").is_some() {
return vec![root.clone()];
}
Vec::new()
}
#[derive(Default)]
struct StreamingToolCall {
id: String,
kind: String,
name: String,
arguments: String,
}
impl StreamingToolCall {
fn into_value(self) -> Value {
json!({
"id": self.id,
"type": if self.kind.is_empty() { "function" } else { &self.kind },
"function": {
"name": self.name,
"arguments": self.arguments,
},
})
}
}
fn extract_sse_message(body: &str) -> Option<Value> {
let mut role = None;
let mut content = String::new();
let mut tool_calls = BTreeMap::<u64, StreamingToolCall>::new();
for line in body.lines() {
let Some(data) = line.trim().strip_prefix("data:").map(str::trim) else {
continue;
};
if data.is_empty() || data == "[DONE]" {
continue;
}
let Ok(event) = serde_json::from_str::<Value>(data) else {
continue;
};
let Some(delta) = event
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("delta"))
.and_then(Value::as_object)
else {
continue;
};
if let Some(value) = delta.get("role").and_then(Value::as_str) {
role = Some(value.to_owned());
}
if let Some(value) = delta.get("content").and_then(Value::as_str) {
content.push_str(value);
}
let Some(calls) = delta.get("tool_calls").and_then(Value::as_array) else {
continue;
};
for (position, call) in calls.iter().enumerate() {
let index = call
.get("index")
.and_then(Value::as_u64)
.unwrap_or(position as u64);
let accumulated = tool_calls.entry(index).or_default();
if let Some(value) = call.get("id").and_then(Value::as_str) {
accumulated.id.push_str(value);
}
if let Some(value) = call.get("type").and_then(Value::as_str) {
accumulated.kind.push_str(value);
}
if let Some(function) = call.get("function").and_then(Value::as_object) {
if let Some(value) = function.get("name").and_then(Value::as_str) {
accumulated.name.push_str(value);
}
if let Some(value) = function.get("arguments").and_then(Value::as_str) {
accumulated.arguments.push_str(value);
}
}
}
}
if role.is_none() && content.is_empty() && tool_calls.is_empty() {
return None;
}
let mut message = Map::new();
message.insert(
String::from("role"),
Value::String(role.unwrap_or_else(|| String::from("assistant"))),
);
message.insert(
String::from("content"),
if content.is_empty() {
Value::Null
} else {
Value::String(content)
},
);
if !tool_calls.is_empty() {
message.insert(
String::from("tool_calls"),
Value::Array(
tool_calls
.into_values()
.map(StreamingToolCall::into_value)
.collect(),
),
);
}
Some(Value::Object(message))
}