Skip to main content

formal_ai/
shared_dialog.rs

1//! Convert captured shared chat transcripts into the portable `demo_memory`
2//! event log used by the browser demo and CLI.
3
4use std::error::Error;
5use std::fmt;
6
7use serde_json::{Map, Value};
8
9use crate::memory::{export_links_notation, MemoryEvent};
10use crate::seed;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum SharedDialogFormat {
14    Auto,
15    ChatGptShareHtml,
16    MarkdownTranscript,
17    /// Normalized JSON emitted by `web-capture shared-dialog`.
18    WebCaptureJson,
19}
20
21#[derive(Debug, Default, Clone, PartialEq, Eq)]
22pub struct SharedDialogMetadata {
23    pub source_url: Option<String>,
24    pub demo_label: Option<String>,
25    pub conversation_id: Option<String>,
26    pub conversation_title: Option<String>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct SharedDialog {
31    pub title: Option<String>,
32    pub conversation_id: Option<String>,
33    pub turns: Vec<SharedDialogTurn>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct SharedDialogTurn {
38    pub id: String,
39    pub role: String,
40    pub content: String,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum SharedDialogError {
45    EmptyDialog,
46    Parse(String),
47    UnsupportedFormat(String),
48}
49
50impl fmt::Display for SharedDialogError {
51    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
52        match self {
53            Self::EmptyDialog => formatter.write_str("no visible dialog turns were found"),
54            Self::Parse(message) | Self::UnsupportedFormat(message) => formatter.write_str(message),
55        }
56    }
57}
58
59impl Error for SharedDialogError {}
60
61pub fn convert_shared_dialog_to_demo_memory(
62    input: &str,
63    format: SharedDialogFormat,
64    metadata: &SharedDialogMetadata,
65) -> Result<String, SharedDialogError> {
66    let dialog = parse_shared_dialog(input, format, metadata)?;
67    let events = shared_dialog_to_memory_events(&dialog, metadata);
68    Ok(export_links_notation(&events))
69}
70
71pub fn parse_shared_dialog(
72    input: &str,
73    format: SharedDialogFormat,
74    metadata: &SharedDialogMetadata,
75) -> Result<SharedDialog, SharedDialogError> {
76    let concrete_format = match format {
77        SharedDialogFormat::Auto => detect_format(input)?,
78        other => other,
79    };
80    match concrete_format {
81        SharedDialogFormat::Auto => unreachable!("auto format is resolved before parsing"),
82        SharedDialogFormat::ChatGptShareHtml => parse_chatgpt_share_html(input, metadata),
83        SharedDialogFormat::MarkdownTranscript => parse_markdown_transcript(input, metadata),
84        SharedDialogFormat::WebCaptureJson => parse_web_capture_json(input, metadata),
85    }
86}
87
88#[must_use]
89pub fn shared_dialog_to_memory_events(
90    dialog: &SharedDialog,
91    metadata: &SharedDialogMetadata,
92) -> Vec<MemoryEvent> {
93    let conversation_id = metadata
94        .conversation_id
95        .clone()
96        .or_else(|| dialog.conversation_id.clone());
97    let conversation_title = metadata
98        .conversation_title
99        .clone()
100        .or_else(|| dialog.title.clone());
101    let evidence: Vec<String> = metadata.source_url.iter().cloned().collect();
102
103    dialog
104        .turns
105        .iter()
106        .enumerate()
107        .map(|(index, turn)| MemoryEvent {
108            id: if turn.id.is_empty() {
109                format!("shared-dialog-turn-{}", index + 1)
110            } else {
111                turn.id.clone()
112            },
113            role: Some(turn.role.clone()),
114            content: Some(turn.content.clone()),
115            demo_label: metadata.demo_label.clone(),
116            conversation_id: conversation_id.clone(),
117            conversation_title: conversation_title.clone(),
118            evidence: evidence.clone(),
119            ..MemoryEvent::default()
120        })
121        .collect()
122}
123
124fn detect_format(input: &str) -> Result<SharedDialogFormat, SharedDialogError> {
125    if input.contains("__reactRouterContext.streamController.enqueue(")
126        || input.contains("linear_conversation")
127    {
128        return Ok(SharedDialogFormat::ChatGptShareHtml);
129    }
130    if looks_like_google_ai_mode_interstitial(input) {
131        return Err(SharedDialogError::UnsupportedFormat(String::from(
132            "static Google AI Mode capture did not include a replayable transcript; use browser-backed web-capture support",
133        )));
134    }
135    if input.trim_start().starts_with('{')
136        && input.contains("\"provider\"")
137        && input.contains("\"turns\"")
138        && input.contains("\"diagnostics\"")
139    {
140        return Ok(SharedDialogFormat::WebCaptureJson);
141    }
142    Ok(SharedDialogFormat::MarkdownTranscript)
143}
144
145fn parse_web_capture_json(
146    input: &str,
147    metadata: &SharedDialogMetadata,
148) -> Result<SharedDialog, SharedDialogError> {
149    let capture: Value = serde_json::from_str(input).map_err(|error| {
150        SharedDialogError::Parse(capture_diagnostic(
151            "shared_dialog_capture_parse_failed",
152            &[("error", &error.to_string())],
153        ))
154    })?;
155    let object = capture.as_object().ok_or_else(|| {
156        SharedDialogError::Parse(String::from(
157            "web-capture shared-dialog JSON root was not an object",
158        ))
159    })?;
160    if object.get("status").and_then(Value::as_str) != Some("ok") {
161        let provider = object
162            .get("provider")
163            .and_then(Value::as_str)
164            .unwrap_or("unknown");
165        let diagnostics = object.get("diagnostics").and_then(Value::as_object);
166        let reason = diagnostics
167            .and_then(|value| value.get("unsupportedReason"))
168            .and_then(Value::as_str)
169            .unwrap_or("unsupported_capture");
170        let message = diagnostics
171            .and_then(|value| value.get("message"))
172            .and_then(Value::as_str)
173            .unwrap_or("the capture did not contain replayable transcript data");
174        return Err(SharedDialogError::UnsupportedFormat(capture_diagnostic(
175            "shared_dialog_capture_unsupported",
176            &[
177                ("provider", provider),
178                ("reason", reason),
179                ("message", message),
180            ],
181        )));
182    }
183
184    let mut turns = Vec::new();
185    for item in object
186        .get("turns")
187        .and_then(Value::as_array)
188        .into_iter()
189        .flatten()
190    {
191        let Some(role) = item.get("role").and_then(Value::as_str) else {
192            continue;
193        };
194        if role != "user" && role != "assistant" {
195            continue;
196        }
197        let content = item
198            .get("content")
199            .and_then(Value::as_str)
200            .unwrap_or_default()
201            .trim();
202        if content.is_empty() {
203            continue;
204        }
205        turns.push(SharedDialogTurn {
206            id: item
207                .get("id")
208                .and_then(Value::as_str)
209                .unwrap_or_default()
210                .to_owned(),
211            role: role.to_owned(),
212            content: content.to_owned(),
213        });
214    }
215    if turns.is_empty() {
216        return Err(SharedDialogError::EmptyDialog);
217    }
218
219    Ok(SharedDialog {
220        title: metadata.conversation_title.clone().or_else(|| {
221            object
222                .get("title")
223                .and_then(Value::as_str)
224                .map(str::to_owned)
225        }),
226        conversation_id: metadata.conversation_id.clone().or_else(|| {
227            object
228                .get("conversationId")
229                .and_then(Value::as_str)
230                .map(str::to_owned)
231        }),
232        turns,
233    })
234}
235
236fn capture_diagnostic(intent: &str, values: &[(&str, &str)]) -> String {
237    let mut rendered = seed::response_for(intent, "en").unwrap_or_else(|| intent.to_owned());
238    for (name, value) in values {
239        rendered = rendered.replace(&format!("{{{name}}}"), value);
240    }
241    rendered
242}
243
244fn parse_chatgpt_share_html(
245    input: &str,
246    metadata: &SharedDialogMetadata,
247) -> Result<SharedDialog, SharedDialogError> {
248    let table = extract_chatgpt_devalue_table(input)?;
249    let resolved = resolve_devalue_root(&table)?;
250    let data = find_object_with_array_key(&resolved, "linear_conversation").ok_or_else(|| {
251        SharedDialogError::Parse(String::from(
252            "ChatGPT share capture did not contain linear_conversation data",
253        ))
254    })?;
255    let linear = data
256        .get("linear_conversation")
257        .and_then(Value::as_array)
258        .ok_or_else(|| {
259            SharedDialogError::Parse(String::from(
260                "ChatGPT linear_conversation field was not an array",
261            ))
262        })?;
263    let mut turns = Vec::new();
264    for item in linear {
265        if let Some(turn) = chatgpt_turn_from_item(item) {
266            turns.push(turn);
267        }
268    }
269    if turns.is_empty() {
270        return Err(SharedDialogError::EmptyDialog);
271    }
272
273    let title = metadata
274        .conversation_title
275        .clone()
276        .or_else(|| string_field(data, "title"))
277        .or_else(|| title_from_html(input));
278    let conversation_id = metadata
279        .conversation_id
280        .clone()
281        .or_else(|| string_field(data, "conversation_id"))
282        .or_else(|| chatgpt_share_id(metadata));
283
284    Ok(SharedDialog {
285        title,
286        conversation_id,
287        turns,
288    })
289}
290
291fn chatgpt_turn_from_item(item: &Value) -> Option<SharedDialogTurn> {
292    let message = item.get("message").unwrap_or(item);
293    let role = message
294        .get("author")
295        .and_then(|author| author.get("role"))
296        .and_then(Value::as_str)?;
297    if role != "user" && role != "assistant" {
298        return None;
299    }
300    if message_is_hidden(message) {
301        return None;
302    }
303    let content = message
304        .get("content")
305        .map(message_content_text)
306        .unwrap_or_default()
307        .trim()
308        .to_owned();
309    if content.is_empty() {
310        return None;
311    }
312    let id = message
313        .get("id")
314        .and_then(Value::as_str)
315        .unwrap_or_default()
316        .to_owned();
317    Some(SharedDialogTurn {
318        id,
319        role: role.to_owned(),
320        content,
321    })
322}
323
324fn message_is_hidden(message: &Value) -> bool {
325    message
326        .get("metadata")
327        .and_then(|metadata| metadata.get("is_visually_hidden_from_conversation"))
328        .and_then(Value::as_bool)
329        .unwrap_or(false)
330}
331
332fn message_content_text(content: &Value) -> String {
333    if let Some(parts) = content.get("parts").and_then(Value::as_array) {
334        let mut texts = Vec::new();
335        for part in parts {
336            if let Some(text) = part.as_str() {
337                if !text.is_empty() {
338                    texts.push(text.to_owned());
339                }
340            } else if let Some(text) = part.get("text").and_then(Value::as_str) {
341                if !text.is_empty() {
342                    texts.push(text.to_owned());
343                }
344            }
345        }
346        return texts.join("\n\n");
347    }
348    content
349        .get("text")
350        .and_then(Value::as_str)
351        .unwrap_or_default()
352        .to_owned()
353}
354
355fn extract_chatgpt_devalue_table(input: &str) -> Result<Vec<Value>, SharedDialogError> {
356    for chunk in extract_react_router_stream_chunks(input)? {
357        let trimmed = chunk.trim_start();
358        if !trimmed.starts_with('[') {
359            continue;
360        }
361        let value = serde_json::from_str::<Value>(trimmed).map_err(|error| {
362            SharedDialogError::Parse(format!(
363                "failed to parse ChatGPT React Router payload as JSON: {error}"
364            ))
365        })?;
366        if let Value::Array(table) = value {
367            return Ok(table);
368        }
369    }
370    Err(SharedDialogError::Parse(String::from(
371        "ChatGPT share capture did not contain a JSON devalue table",
372    )))
373}
374
375fn extract_react_router_stream_chunks(input: &str) -> Result<Vec<String>, SharedDialogError> {
376    const ENQUEUE_MARKER: &str = "window.__reactRouterContext.streamController.enqueue(";
377
378    let mut chunks = Vec::new();
379    let mut cursor = 0;
380    let bytes = input.as_bytes();
381    while let Some(relative) = input[cursor..].find(ENQUEUE_MARKER) {
382        let mut literal_start = cursor + relative + ENQUEUE_MARKER.len();
383        while matches!(bytes.get(literal_start), Some(b' ' | b'\n' | b'\r' | b'\t')) {
384            literal_start += 1;
385        }
386        if bytes.get(literal_start) != Some(&b'"') {
387            cursor = literal_start;
388            continue;
389        }
390        let (literal, literal_len) = extract_json_string_literal(&input[literal_start..])?;
391        let chunk = serde_json::from_str::<String>(literal).map_err(|error| {
392            SharedDialogError::Parse(format!(
393                "failed to decode ChatGPT React Router stream string: {error}"
394            ))
395        })?;
396        chunks.push(chunk);
397        cursor = literal_start + literal_len;
398    }
399    if chunks.is_empty() {
400        return Err(SharedDialogError::Parse(String::from(
401            "ChatGPT share capture did not contain React Router stream chunks",
402        )));
403    }
404    Ok(chunks)
405}
406
407fn extract_json_string_literal(input: &str) -> Result<(&str, usize), SharedDialogError> {
408    let bytes = input.as_bytes();
409    if bytes.first() != Some(&b'"') {
410        return Err(SharedDialogError::Parse(String::from(
411            "expected a JSON string literal",
412        )));
413    }
414    let mut index = 1;
415    while index < bytes.len() {
416        match bytes[index] {
417            b'\\' => index += 2,
418            b'"' => return Ok((&input[..=index], index + 1)),
419            _ => index += 1,
420        }
421    }
422    Err(SharedDialogError::Parse(String::from(
423        "unterminated JSON string literal",
424    )))
425}
426
427fn resolve_devalue_root(table: &[Value]) -> Result<Value, SharedDialogError> {
428    if table.is_empty() {
429        return Err(SharedDialogError::Parse(String::from(
430            "ChatGPT devalue table was empty",
431        )));
432    }
433    Ok(resolve_table_index(table, 0, &mut Vec::new()))
434}
435
436fn resolve_table_index(table: &[Value], index: usize, stack: &mut Vec<usize>) -> Value {
437    if index >= table.len() || stack.contains(&index) {
438        return Value::Null;
439    }
440    stack.push(index);
441    let value = resolve_table_value(table, &table[index], stack);
442    stack.pop();
443    value
444}
445
446fn resolve_table_value(table: &[Value], value: &Value, stack: &mut Vec<usize>) -> Value {
447    match value {
448        Value::Array(items) => Value::Array(
449            items
450                .iter()
451                .map(|item| resolve_encoded_reference(table, item, stack))
452                .collect(),
453        ),
454        Value::Object(map) => {
455            let mut resolved = Map::new();
456            for (encoded_key, encoded_value) in map {
457                let key = resolve_object_key(table, encoded_key, stack);
458                let value = resolve_encoded_reference(table, encoded_value, stack);
459                resolved.insert(key, value);
460            }
461            Value::Object(resolved)
462        }
463        other => other.clone(),
464    }
465}
466
467fn resolve_encoded_reference(table: &[Value], value: &Value, stack: &mut Vec<usize>) -> Value {
468    if let Some(index) = value.as_i64() {
469        if index < 0 {
470            return Value::Null;
471        }
472        let Ok(index) = usize::try_from(index) else {
473            return Value::Null;
474        };
475        return resolve_table_index(table, index, stack);
476    }
477    resolve_table_value(table, value, stack)
478}
479
480fn resolve_object_key(table: &[Value], encoded_key: &str, stack: &mut Vec<usize>) -> String {
481    if let Some(raw_index) = encoded_key.strip_prefix('_') {
482        if let Ok(index) = raw_index.parse::<usize>() {
483            if let Value::String(key) = resolve_table_index(table, index, stack) {
484                return key;
485            }
486        }
487    }
488    encoded_key.to_owned()
489}
490
491fn find_object_with_array_key<'a>(value: &'a Value, key: &str) -> Option<&'a Map<String, Value>> {
492    match value {
493        Value::Object(map) => {
494            if map.get(key).and_then(Value::as_array).is_some() {
495                return Some(map);
496            }
497            map.values()
498                .find_map(|child| find_object_with_array_key(child, key))
499        }
500        Value::Array(items) => items
501            .iter()
502            .find_map(|child| find_object_with_array_key(child, key)),
503        _ => None,
504    }
505}
506
507fn parse_markdown_transcript(
508    input: &str,
509    metadata: &SharedDialogMetadata,
510) -> Result<SharedDialog, SharedDialogError> {
511    let mut turns = Vec::new();
512    let mut current_role: Option<&'static str> = None;
513    let mut current_lines = Vec::new();
514
515    for line in input.lines() {
516        if let Some((role, rest)) = markdown_turn_prefix(line) {
517            push_markdown_turn(&mut turns, current_role.take(), &current_lines);
518            current_role = Some(role);
519            current_lines.clear();
520            current_lines.push(rest.trim_start().to_owned());
521        } else if current_role.is_some() {
522            current_lines.push(line.to_owned());
523        }
524    }
525    push_markdown_turn(&mut turns, current_role.take(), &current_lines);
526
527    if turns.is_empty() {
528        return Err(SharedDialogError::EmptyDialog);
529    }
530
531    Ok(SharedDialog {
532        title: metadata.conversation_title.clone(),
533        conversation_id: metadata.conversation_id.clone(),
534        turns,
535    })
536}
537
538fn markdown_turn_prefix(line: &str) -> Option<(&'static str, &str)> {
539    let trimmed = line.trim_start();
540    for (prefix, role) in [
541        ("U:", "user"),
542        ("User:", "user"),
543        ("A:", "assistant"),
544        ("Assistant:", "assistant"),
545    ] {
546        if trimmed.len() >= prefix.len() && trimmed[..prefix.len()].eq_ignore_ascii_case(prefix) {
547            return Some((role, &trimmed[prefix.len()..]));
548        }
549    }
550    None
551}
552
553fn push_markdown_turn(
554    turns: &mut Vec<SharedDialogTurn>,
555    role: Option<&'static str>,
556    lines: &[String],
557) {
558    let Some(role) = role else {
559        return;
560    };
561    let content = trimmed_content_lines(lines);
562    if content.is_empty() {
563        return;
564    }
565    let id = format!("markdown-turn-{}", turns.len() + 1);
566    turns.push(SharedDialogTurn {
567        id,
568        role: role.to_owned(),
569        content,
570    });
571}
572
573fn trimmed_content_lines(lines: &[String]) -> String {
574    let Some(start) = lines.iter().position(|line| !line.trim().is_empty()) else {
575        return String::new();
576    };
577    let end = lines
578        .iter()
579        .rposition(|line| !line.trim().is_empty())
580        .unwrap_or(start);
581    lines[start..=end].join("\n").trim().to_owned()
582}
583
584fn string_field(map: &Map<String, Value>, key: &str) -> Option<String> {
585    map.get(key).and_then(Value::as_str).map(ToOwned::to_owned)
586}
587
588fn title_from_html(input: &str) -> Option<String> {
589    let start = input.find("<title>")? + "<title>".len();
590    let end = input[start..].find("</title>")? + start;
591    let title = &input[start..end];
592    Some(
593        title
594            .strip_prefix("ChatGPT - ")
595            .unwrap_or(title)
596            .replace("&#x27;", "'"),
597    )
598}
599
600fn chatgpt_share_id(metadata: &SharedDialogMetadata) -> Option<String> {
601    let url = metadata.source_url.as_deref()?;
602    let marker = "/share/";
603    let start = url.find(marker)? + marker.len();
604    let tail = &url[start..];
605    let end = tail.find(['?', '/', '#']).unwrap_or(tail.len());
606    Some(tail[..end].to_owned())
607}
608
609fn looks_like_google_ai_mode_interstitial(input: &str) -> bool {
610    input.contains("share.google/aimode")
611        || (input.contains("Google Search")
612            && input.contains("If you're having trouble accessing Google Search"))
613        || (input.contains("/search?q=") && input.contains("enablejs"))
614}