1use 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 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(), ¤t_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(), ¤t_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("'", "'"),
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}