aura-gui 0.2.2

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Conversión de mensajes a JSON y de vuelta, para guardar conversaciones en disco.

use crate::ai::types::{Block, Message, Role};
use serde_json::{Value, json};

fn block_to_json(block: &Block) -> Value {
    match block {
        Block::Text(text) => json!({ "type": "text", "text": text }),
        Block::Image { media_type, data_base64, name } => {
            json!({ "type": "image", "media_type": media_type, "data": data_base64, "name": name })
        }
        Block::ToolUse { id, name, input } => json!({ "type": "tool_use", "id": id, "name": name, "input": input }),
        Block::ToolResult { id, content, is_error } => {
            json!({ "type": "tool_result", "id": id, "content": content, "is_error": is_error })
        }
    }
}

fn block_from_json(value: &Value) -> Option<Block> {
    let text = |key: &str| value[key].as_str().map(str::to_owned);
    match value["type"].as_str()? {
        "text" => Some(Block::Text(text("text")?)),
        "image" => Some(Block::Image {
            media_type: text("media_type")?,
            data_base64: text("data")?,
            name: value["name"].as_str().map(str::to_owned),
        }),
        "tool_use" => Some(Block::ToolUse { id: text("id")?, name: text("name")?, input: value["input"].clone() }),
        "tool_result" => Some(Block::ToolResult {
            id: text("id")?,
            content: text("content")?,
            is_error: value["is_error"].as_bool().unwrap_or(false),
        }),
        _ => None,
    }
}

pub fn messages_to_json(messages: &[Message]) -> Value {
    messages
        .iter()
        .map(|message| {
            let role = if message.role == Role::User { "user" } else { "assistant" };
            json!({ "role": role, "blocks": message.blocks.iter().map(block_to_json).collect::<Vec<_>>() })
        })
        .collect()
}

/// Lee los mensajes guardados; un mensaje o bloque ilegible se omite en vez de perder toda la conversación.
pub fn messages_from_json(value: &Value) -> Vec<Message> {
    value
        .as_array()
        .into_iter()
        .flatten()
        .filter_map(|message| {
            let role = match message["role"].as_str()? {
                "user" => Role::User,
                "assistant" => Role::Assistant,
                _ => return None,
            };
            let blocks: Vec<Block> = message["blocks"].as_array()?.iter().filter_map(block_from_json).collect();
            (!blocks.is_empty()).then_some(Message { role, blocks })
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ida_y_vuelta_conserva_todo() {
        let messages = vec![
            Message {
                role: Role::User,
                blocks: vec![
                    Block::Text("mira".into()),
                    Block::Image {
                        media_type: "image/png".into(),
                        data_base64: "AAA".into(),
                        name: Some("foto.png".into()),
                    },
                ],
            },
            Message::assistant(vec![
                Block::Text("miro".into()),
                Block::ToolUse { id: "t".into(), name: "ls".into(), input: json!({"p": [1, 2]}) },
            ]),
            Message {
                role: Role::User,
                blocks: vec![Block::ToolResult { id: "t".into(), content: "a".into(), is_error: true }],
            },
        ];
        assert_eq!(messages_from_json(&messages_to_json(&messages)), messages);
    }

    #[test]
    fn lo_ilegible_se_omite() {
        let value = json!([
            {"role": "user", "blocks": [{"type": "text", "text": "ok"}, {"type": "raro"}]},
            {"role": "otro", "blocks": []},
            {"nada": 1},
        ]);
        assert_eq!(messages_from_json(&value), [Message::user("ok")]);
    }
}