codex-mobile-bridge 0.2.11

Remote bridge and service manager for codex-mobile.
Documentation
use serde_json::Value;

use super::super::helpers::optional_string;

pub(super) fn timeline_entry_title(entry_type: &str, item: &Value) -> Option<String> {
    match entry_type {
        "userMessage" => Some("".to_string()),
        "agentMessage" => {
            let phase = optional_string(item, "phase");
            if matches!(phase.as_deref(), Some("final_answer")) {
                Some("最终回复".to_string())
            } else if matches!(phase.as_deref(), Some("commentary")) {
                Some("中间回复".to_string())
            } else {
                Some("Codex".to_string())
            }
        }
        "hookPrompt" => Some("系统提示".to_string()),
        "plan" => Some("执行计划".to_string()),
        "reasoning" | "reasoning_text" | "summary_text" => Some("思考过程".to_string()),
        "commandExecution" => {
            optional_string(item, "command").or_else(|| Some("命令输出".to_string()))
        }
        "fileChange" => Some("文件改动".to_string()),
        "mcpToolCall" => {
            let server = optional_string(item, "server");
            let tool = optional_string(item, "tool");
            match (server, tool) {
                (Some(server), Some(tool)) => Some(format!("{server} / {tool}")),
                (_, Some(tool)) => Some(tool),
                _ => Some("MCP 工具".to_string()),
            }
        }
        "dynamicToolCall" => optional_string(item, "tool").or_else(|| Some("动态工具".to_string())),
        "collabToolCall" => optional_string(item, "tool").or_else(|| Some("协作代理".to_string())),
        "webSearch" => optional_string(item, "query").or_else(|| Some("网络搜索".to_string())),
        "imageView" => Some("查看图片".to_string()),
        "imageGeneration" => Some("图像生成".to_string()),
        "function_call" => optional_string(item, "name").or_else(|| Some("函数调用".to_string())),
        "function_call_output" => {
            optional_string(item, "name").or_else(|| Some("函数结果".to_string()))
        }
        "custom_tool_call" => {
            optional_string(item, "name").or_else(|| Some("自定义工具".to_string()))
        }
        "custom_tool_call_output" => {
            optional_string(item, "name").or_else(|| Some("自定义工具结果".to_string()))
        }
        "file_search_call" => Some("文件搜索".to_string()),
        "web_search_call" => Some("网络搜索".to_string()),
        "code_interpreter_call" => Some("代码解释器".to_string()),
        "shell_call" | "local_shell_call" => Some("Shell 调用".to_string()),
        "shell_call_output" | "local_shell_call_output" => Some("Shell 输出".to_string()),
        "apply_patch_call" => Some("补丁调用".to_string()),
        "apply_patch_call_output" => Some("补丁结果".to_string()),
        "image_generation_call" => Some("图像生成".to_string()),
        "mcp_call" => {
            let server = optional_string(item, "server_label");
            let tool = optional_string(item, "name");
            match (server, tool) {
                (Some(server), Some(tool)) => Some(format!("{server} / {tool}")),
                (_, Some(tool)) => Some(tool),
                _ => Some("MCP 调用".to_string()),
            }
        }
        "mcp_approval_request" => Some("MCP 审批请求".to_string()),
        "mcp_approval_response" => Some("MCP 审批结果".to_string()),
        "mcp_list_tools" => Some("MCP 工具列表".to_string()),
        "enteredReviewMode" => Some("进入 Review 模式".to_string()),
        "exitedReviewMode" => Some("退出 Review 模式".to_string()),
        "contextCompaction" | "compaction" => Some("上下文压缩".to_string()),
        other => Some(other.to_string()),
    }
}

pub(super) fn timeline_entry_status(
    entry_type: &str,
    item: &Value,
    is_streaming: bool,
) -> Option<String> {
    let explicit_status = match entry_type {
        "userMessage"
        | "agentMessage"
        | "hookPrompt"
        | "reasoning"
        | "plan"
        | "reasoning_text"
        | "summary_text"
        | "commandExecution"
        | "fileChange"
        | "mcpToolCall"
        | "dynamicToolCall"
        | "collabToolCall"
        | "imageGeneration"
        | "function_call"
        | "function_call_output"
        | "custom_tool_call"
        | "custom_tool_call_output"
        | "file_search_call"
        | "web_search_call"
        | "computer_call"
        | "computer_call_output"
        | "code_interpreter_call"
        | "shell_call"
        | "shell_call_output"
        | "local_shell_call"
        | "local_shell_call_output"
        | "apply_patch_call"
        | "apply_patch_call_output"
        | "image_generation_call"
        | "mcp_call"
        | "mcp_approval_request"
        | "mcp_approval_response"
        | "mcp_list_tools" => optional_string(item, "status"),
        _ => None,
    };
    explicit_status.or_else(|| is_streaming.then(|| "inProgress".to_string()))
}

pub(super) fn timeline_text_from_thread_item(entry_type: &str, item: &Value) -> String {
    match entry_type {
        "userMessage" => item
            .get("content")
            .and_then(Value::as_array)
            .map(|items| extract_content_items_text(items))
            .filter(|text| !text.trim().is_empty())
            .or_else(|| optional_string(item, "text"))
            .unwrap_or_default(),
        "hookPrompt" => pretty_json(item.get("fragments").unwrap_or(&Value::Null)),
        "agentMessage" => optional_string(item, "text")
            .or_else(|| {
                item.get("content")
                    .and_then(Value::as_array)
                    .map(|items| extract_content_items_text(items))
            })
            .unwrap_or_default(),
        "plan" => optional_string(item, "text").unwrap_or_default(),
        "reasoning" | "reasoning_text" | "summary_text" => build_reasoning_text(
            item.get("summary")
                .and_then(Value::as_array)
                .map(Vec::as_slice),
            item.get("content")
                .and_then(Value::as_array)
                .map(Vec::as_slice),
        ),
        "commandExecution" => optional_string(item, "aggregatedOutput").unwrap_or_default(),
        "fileChange" => format_file_changes(item.get("changes").and_then(Value::as_array)),
        "mcpToolCall" => format_tool_result(item, &["result", "error", "arguments"]),
        "dynamicToolCall" => format_tool_result(item, &["contentItems", "arguments"]),
        "collabToolCall" => format_tool_result(item, &["agentsStates", "prompt"]),
        "webSearch" => build_web_search_text(item),
        "imageView" => optional_string(item, "path").unwrap_or_default(),
        "imageGeneration" => optional_string(item, "result").unwrap_or_default(),
        "function_call" => format_tool_result(item, &["arguments"]),
        "function_call_output" => format_tool_result(item, &["output"]),
        "custom_tool_call" => format_tool_result(item, &["input", "arguments", "call_input"]),
        "custom_tool_call_output" => format_tool_result(item, &["output"]),
        "file_search_call" => format_tool_result(item, &["queries", "results"]),
        "web_search_call" => format_tool_result(item, &["query", "action", "results"]),
        "computer_call" => format_tool_result(item, &["action", "arguments"]),
        "computer_call_output" => format_tool_result(item, &["output"]),
        "code_interpreter_call" => format_tool_result(item, &["code", "outputs"]),
        "shell_call" | "local_shell_call" => {
            format_tool_result(item, &["action", "command", "commands"])
        }
        "shell_call_output" | "local_shell_call_output" => {
            format_tool_result(item, &["output", "stdout", "stderr"])
        }
        "apply_patch_call" => format_tool_result(item, &["operation"]),
        "apply_patch_call_output" => format_tool_result(item, &["output"]),
        "image_generation_call" => optional_string(item, "result").unwrap_or_default(),
        "mcp_call" => format_tool_result(item, &["arguments", "output", "error"]),
        "mcp_approval_request" => format_tool_result(item, &["arguments", "reason"]),
        "mcp_approval_response" => format_tool_result(item, &["reason"]),
        "mcp_list_tools" => format_tool_result(item, &["tools", "error"]),
        "enteredReviewMode" => "已进入 Review 模式。".to_string(),
        "exitedReviewMode" => "已退出 Review 模式。".to_string(),
        "contextCompaction" | "compaction" => "上下文已压缩。".to_string(),
        _ => pretty_json(item),
    }
}

pub(super) fn build_reasoning_text(summary: Option<&[Value]>, content: Option<&[Value]>) -> String {
    let summary_text = summary
        .map(extract_content_items_text)
        .unwrap_or_default()
        .trim()
        .to_string();
    let content_text = content
        .map(extract_content_items_text)
        .unwrap_or_default()
        .trim()
        .to_string();

    match (summary_text.is_empty(), content_text.is_empty()) {
        (false, false) => format!("思考摘要\n{summary_text}\n\n思考内容\n{content_text}"),
        (false, true) => summary_text,
        (true, false) => content_text,
        (true, true) => String::new(),
    }
}

pub(super) fn format_plan_payload(explanation: Option<&str>, plan: Option<&Vec<Value>>) -> String {
    let mut lines = Vec::new();
    if let Some(explanation) = explanation.filter(|value| !value.trim().is_empty()) {
        lines.push(explanation.trim().to_string());
    }
    if let Some(plan) = plan {
        for step in plan {
            let step_text = optional_string(step, "step").unwrap_or_else(|| pretty_json(step));
            let status = optional_string(step, "status")
                .map(|value| format_plan_status(&value))
                .unwrap_or("待处理");
            lines.push(format!("[{status}] {step_text}"));
        }
    }
    lines.join("\n")
}

fn extract_content_items_text(items: &[Value]) -> String {
    items
        .iter()
        .filter_map(extract_content_item_text)
        .collect::<Vec<_>>()
        .join("\n")
}

fn extract_content_item_text(item: &Value) -> Option<String> {
    let item_type = optional_string(item, "type");
    let text = optional_string(item, "text");
    match item_type.as_deref() {
        Some("input_text")
        | Some("output_text")
        | Some("text")
        | Some("reasoning_text")
        | Some("summary_text") => text,
        Some("input_image") => {
            optional_string(item, "image_url").map(|url| format!("[图片] {url}"))
        }
        Some("input_file") => optional_string(item, "filename")
            .or_else(|| optional_string(item, "file_id"))
            .map(|name| format!("[文件] {name}")),
        Some("refusal") => optional_string(item, "refusal"),
        _ => text.or_else(|| {
            (!item.is_null() && !item.is_object())
                .then(|| item.to_string())
                .or_else(|| {
                    let pretty = pretty_json(item);
                    (!pretty.is_empty()).then_some(pretty)
                })
        }),
    }
}

fn build_web_search_text(item: &Value) -> String {
    let query = optional_string(item, "query").unwrap_or_default();
    let action = item.get("action").cloned().unwrap_or(Value::Null);
    if action.is_null() {
        return query;
    }
    if query.trim().is_empty() {
        return pretty_json(&action);
    }
    format!("{query}\n\n{}", pretty_json(&action))
}

fn format_tool_result(item: &Value, keys: &[&str]) -> String {
    let sections = keys
        .iter()
        .filter_map(|key| {
            item.get(*key)
                .filter(|value| !value.is_null())
                .map(|value| {
                    let label = match *key {
                        "arguments" => "参数",
                        "result" | "contentItems" => "结果",
                        "error" => "错误",
                        "agentsStates" => "代理状态",
                        "prompt" => "提示词",
                        _ => *key,
                    };
                    format!("{label}\n{}", pretty_json(value))
                })
        })
        .collect::<Vec<_>>();
    sections.join("\n\n")
}

fn format_file_changes(changes: Option<&Vec<Value>>) -> String {
    let Some(changes) = changes else {
        return String::new();
    };
    let summary = changes
        .iter()
        .map(|change| {
            let kind = optional_string(change, "type")
                .or_else(|| optional_string(change, "status"))
                .unwrap_or_else(|| "change".to_string());
            let path = optional_string(change, "path")
                .or_else(|| optional_string(change, "filePath"))
                .unwrap_or_else(|| pretty_json(change));
            format!("{kind}: {path}")
        })
        .collect::<Vec<_>>()
        .join("\n");

    if summary.trim().is_empty() {
        pretty_json(&Value::Array(changes.clone()))
    } else {
        summary
    }
}

fn pretty_json(value: &Value) -> String {
    match value {
        Value::Null => String::new(),
        Value::String(text) => text.clone(),
        _ => serde_json::to_string_pretty(value).unwrap_or_default(),
    }
}

fn format_plan_status(status: &str) -> &'static str {
    match status {
        "completed" => "已完成",
        "inProgress" | "in_progress" => "进行中",
        "pending" => "待处理",
        _ => "待处理",
    }
}