feat: export currently open temporary chats (#375)

Temporary chats are served by the regular conversation API but are hidden
from the history list and never put their id in the URL. Capture the id from
the /backend-api/f/conversation stream, then export through the existing API
path. Export is blocked with a clear alert until the id has been observed.

---------

Co-authored-by: Pascal Perle <17356275+pperle@users.noreply.github.com>
Co-authored-by: Pionxzh <hi@pionxzh.com>
This commit is contained in:
Pascal
2026-08-18 17:57:09 +08:00
committed by GitHub
co-authored by Pascal Perle Pionxzh
parent 45aca51d87
commit c397f0df53
17 changed files with 181 additions and 1 deletions
+12 -1
View File
@@ -1,6 +1,7 @@
import urlcat from 'urlcat'
import { apiUrl, baseUrl } from './constants'
import { getChatIdFromUrl, getConversationFromSharePage, isSharePage } from './page'
import { getChatIdFromUrl, getConversationFromSharePage, isSharePage, isTemporaryChat } from './page'
import { getTemporaryChatId } from './temporaryChat'
import { blobToDataURL } from './utils/dom'
import { memorize } from './utils/memorize'
@@ -443,6 +444,16 @@ export async function getCurrentChatId(): Promise<string> {
return `__share__${getChatIdFromUrl()}`
}
// A temporary chat is absent from the history list and never puts its id in
// the URL, so without this the lookup below would fall through to the most
// recent conversation and export the wrong chat. Guarded by
// `checkIfTemporaryChatIsExportable` at every export entry point.
if (isTemporaryChat()) {
const temporaryChatId = getTemporaryChatId()
if (!temporaryChatId) throw new Error('No temporary chat id found.')
return temporaryChatId
}
const chatId = getChatIdFromUrl()
if (chatId) return chatId
+6
View File
@@ -4,6 +4,7 @@ import { KEY_SOURCES_ENABLED, KEY_THINKING_ENABLED, KEY_TIMESTAMP_24H, KEY_TIMES
import i18n from '../i18n'
import { checkIfConversationStarted, getUserAvatar } from '../page'
import templateHtml from '../template.html?raw'
import { checkIfTemporaryChatIsExportable } from '../temporaryChat'
import { transformContentReferences } from '../utils/citations'
import { buildZipFileName, downloadFile, getFileNameWithFormat } from '../utils/download'
import { fromMarkdown, toHtml } from '../utils/markdown'
@@ -20,6 +21,11 @@ export async function exportToHtml(fileNameFormat: string, metaList: ExportMeta[
return false
}
if (!checkIfTemporaryChatIsExportable()) {
alert(i18n.t('Temporary chat could not be captured'))
return false
}
const userAvatar = await getUserAvatar()
const chatId = await getCurrentChatId()
+16
View File
@@ -2,6 +2,7 @@ import JSZip from 'jszip'
import { fetchConversation, getCurrentChatId, processConversation } from '../api'
import i18n from '../i18n'
import { checkIfConversationStarted } from '../page'
import { checkIfTemporaryChatIsExportable } from '../temporaryChat'
import { convertToOoba, convertToTavern } from '../utils/conversion'
import { buildJsonBatchFileName, buildZipFileName, downloadFile, getFileNameWithFormat } from '../utils/download'
import type { ApiConversationWithId } from '../api'
@@ -14,6 +15,11 @@ export async function exportToJson(fileNameFormat: string) {
return false
}
if (!checkIfTemporaryChatIsExportable()) {
alert(i18n.t('Temporary chat could not be captured'))
return false
}
const chatId = await getCurrentChatId()
const rawConversation = await fetchConversation(chatId, false)
const conversation = processConversation(rawConversation)
@@ -34,6 +40,11 @@ export async function exportToTavern(fileNameFormat: string) {
return false
}
if (!checkIfTemporaryChatIsExportable()) {
alert(i18n.t('Temporary chat could not be captured'))
return false
}
const chatId = await getCurrentChatId()
const rawConversation = await fetchConversation(chatId, false)
const conversation = processConversation(rawConversation)
@@ -51,6 +62,11 @@ export async function exportToOoba(fileNameFormat: string) {
return false
}
if (!checkIfTemporaryChatIsExportable()) {
alert(i18n.t('Temporary chat could not be captured'))
return false
}
const chatId = await getCurrentChatId()
const rawConversation = await fetchConversation(chatId, false)
const conversation = processConversation(rawConversation)
+6
View File
@@ -3,6 +3,7 @@ import { fetchConversation, getCurrentChatId, processConversation, shouldSkipMes
import { KEY_SOURCES_ENABLED, KEY_THINKING_ENABLED, KEY_TIMESTAMP_24H, KEY_TIMESTAMP_ENABLED, KEY_TIMESTAMP_MARKDOWN, baseUrl } from '../constants'
import i18n from '../i18n'
import { checkIfConversationStarted } from '../page'
import { checkIfTemporaryChatIsExportable } from '../temporaryChat'
import { transformContentReferences } from '../utils/citations'
import { buildZipFileName, downloadFile, getFileNameWithFormat } from '../utils/download'
import { fromMarkdown, toMarkdown } from '../utils/markdown'
@@ -19,6 +20,11 @@ export async function exportToMarkdown(fileNameFormat: string, metaList: ExportM
return false
}
if (!checkIfTemporaryChatIsExportable()) {
alert(i18n.t('Temporary chat could not be captured'))
return false
}
const chatId = await getCurrentChatId()
const rawConversation = await fetchConversation(chatId, true)
const enableThinking = ScriptStorage.get<boolean>(KEY_THINKING_ENABLED) ?? false
+6
View File
@@ -1,6 +1,7 @@
import { fetchConversation, getCurrentChatId, processConversation, shouldSkipMessageInExport } from '../api'
import i18n from '../i18n'
import { checkIfConversationStarted } from '../page'
import { checkIfTemporaryChatIsExportable } from '../temporaryChat'
import { transformContentReferences } from '../utils/citations'
import { copyToClipboard } from '../utils/clipboard'
import { flatMap, fromMarkdown, toMarkdown } from '../utils/markdown'
@@ -14,6 +15,11 @@ export async function exportToText() {
return false
}
if (!checkIfTemporaryChatIsExportable()) {
alert(i18n.t('Temporary chat could not be captured'))
return false
}
const chatId = await getCurrentChatId()
// All image in text output will be replaced with `[image]`
// So we don't need to waste time to download them
+1
View File
@@ -44,6 +44,7 @@
"Conversation Delete Alert": "Are you sure you want to delete all selected conversations?",
"Conversation Deleted Message": "All selected conversations have been deleted. Please refresh the page to see the changes.",
"Please start a conversation first": "Please start a conversation first.",
"Temporary chat could not be captured": "This temporary chat could not be read. It may have started before the exporter was loaded. You can still export it as a PNG screenshot.",
"Select Project": "Select Project",
"(no project)": "(no project)",
"Export All Limit": "Export All Limit",
+1
View File
@@ -44,6 +44,7 @@
"Conversation Delete Alert": "¿Estás seguro que quieres borrar todas las conversaciones seleccionadas?",
"Conversation Deleted Message": "Todos las conversaciones seleccionadas se han borrado. Por favor refresca la página para ver los cambios.",
"Please start a conversation first": "Por favor empieza una conversación antes.",
"Temporary chat could not be captured": "No se pudo leer este chat temporal. Es posible que haya comenzado antes de que se cargara el exportador. Aún puedes exportarlo como una captura de pantalla PNG.",
"Select Project": "Seleccionar proyecto",
"(no project)": "(sin proyecto)",
"Export All Limit": "Límite de Exportar Todos",
+1
View File
@@ -44,6 +44,7 @@
"Conversation Delete Alert": "Êtes-vous sûr de vouloir supprimer toutes les conversations sélectionnées ?",
"Conversation Deleted Message": "Toutes les conversations sélectionnées ont été supprimées. Veuillez actualiser la page pour voir les changements.",
"Please start a conversation first": "Veuillez commencer une conversation d'abord.",
"Temporary chat could not be captured": "Ce chat éphémère n'a pas pu être lu. Il a peut-être commencé avant le chargement de l'exportateur. Vous pouvez toujours l'exporter en capture d'écran PNG.",
"Select Project": "Sélectionner un projet",
"(no project)": "(aucun projet)",
"Export All Limit": "Limite d'Exportation Multiple",
+1
View File
@@ -44,6 +44,7 @@
"Conversation Delete Alert": "Apakah Anda yakin ingin menghapus semua percakapan yang dipilih?",
"Conversation Deleted Message": "Semua percakapan yang dipilih telah dihapus. Harap segarkan halaman untuk melihat perubahan.",
"Please start a conversation first": "Harap mulai percakapan terlebih dahulu.",
"Temporary chat could not be captured": "Obrolan sementara ini tidak dapat dibaca. Mungkin obrolan dimulai sebelum pengekspor dimuat. Anda masih dapat mengekspornya sebagai tangkapan layar PNG.",
"Select Project": "Pilih Proyek",
"(no project)": "(tidak ada proyek)",
"Export All Limit": "Batas Ekspor Semua",
+1
View File
@@ -44,6 +44,7 @@
"Conversation Delete Alert": "選択したすべての会話を削除してもよろしいですか?",
"Conversation Deleted Message": "選択したすべての会話が削除されました。変更を表示するには、ページを更新してください。",
"Please start a conversation first": "まず会話を開始してください。",
"Temporary chat could not be captured": "この一時チャットを読み取れませんでした。エクスポーターが読み込まれる前に開始された可能性があります。PNG スクリーンショットとしてエクスポートすることは可能です。",
"Select Project": "プロジェクトを選択",
"(no project)": "(プロジェクトなし)",
"Export All Limit": "すべてエクスポートの上限",
+1
View File
@@ -44,6 +44,7 @@
"Conversation Delete Alert": "Вы уверены, что хотите удалить все выбранные разговоры?",
"Conversation Deleted Message": "Все выбранные разговоры были удалены. Пожалуйста, обновите страницу, чтобы увидеть изменения.",
"Please start a conversation first": "Пожалуйста, начните разговор первым.",
"Temporary chat could not be captured": "Не удалось прочитать этот временный чат. Возможно, он был начат до загрузки экспортёра. Вы всё ещё можете экспортировать его как PNG-скриншот.",
"Select Project": "Выберите проект",
"(no project)": "(нет проекта)",
"Export All Limit": "Лимит экспорта всех",
+1
View File
@@ -44,6 +44,7 @@
"Conversation Delete Alert": "Seçilen tüm konuşmaları silmek istediğinizden emin misiniz?",
"Conversation Deleted Message": "Seçilen tüm konuşmalar silindi. Değişiklikleri görmek için sayfayı yenileyin.",
"Please start a conversation first": "Lütfen önce bir konuşma başlatın.",
"Temporary chat could not be captured": "Bu geçici sohbet okunamadı. Dışa aktarıcı yüklenmeden önce başlamış olabilir. Yine de PNG ekran görüntüsü olarak dışa aktarabilirsiniz.",
"Select Project": "Proje Seç",
"(no project)": "(proje yok)",
"Export All Limit": "Tümünü Dışa Aktarma Limiti",
+1
View File
@@ -44,6 +44,7 @@
"Conversation Delete Alert": "确定要删除所有选取的对话?",
"Conversation Deleted Message": "所有所选的对话已删除。请刷新页面。",
"Please start a conversation first": "请先开始对话。",
"Temporary chat could not be captured": "无法读取此临时聊天,它可能在导出工具加载前就已开始。你仍可以将其导出为 PNG 截图。",
"Select Project": "选择项目",
"(no project)": "(无项目)",
"Export All Limit": "批量导出上限",
+1
View File
@@ -44,6 +44,7 @@
"Conversation Delete Alert": "確定要刪除所有選取的對話?",
"Conversation Deleted Message": "所有選取的對話已刪除。請重新整理頁面。",
"Please start a conversation first": "請先開始對話。",
"Temporary chat could not be captured": "無法讀取此暫存對話,它可能在匯出工具載入前就已開始。你仍可以將其匯出為 PNG 截圖。",
"Select Project": "選擇專案",
"(no project)": "(無專案)",
"Export All Limit": "批量匯出上限",
+5
View File
@@ -2,6 +2,7 @@ import { render } from 'preact'
import sentinel from 'sentinel-js'
import { fetchConversation, processConversation } from './api'
import { getChatIdFromUrl, isSharePage } from './page'
import { watchTemporaryChatId } from './temporaryChat'
import { Menu } from './ui/Menu'
import { onloadSafe } from './utils/utils'
@@ -11,6 +12,10 @@ import './styles/missing-tailwind.css'
main()
function main() {
// Installed before the page is ready so it is in place by the time the
// user can send the first message of a temporary chat.
watchTemporaryChatId()
onloadSafe(() => {
// eslint-disable-next-line no-console
console.log('[Exporter] Loaded')
+11
View File
@@ -49,6 +49,17 @@ export function getChatIdFromUrl() {
return null
}
/**
* Temporary chats are hidden from the history list and their id never reaches
* the URL, although the conversation API still serves them once the id is
* known (see temporaryChat.ts). Without this check the exporter falls through
* to the most recent conversation in the history and silently exports the
* wrong chat.
*/
export function isTemporaryChat() {
return new URLSearchParams(location.search).get('temporary-chat') === 'true'
}
export function isSharePage() {
return location.pathname.startsWith('/share')
&& !location.pathname.endsWith('/continue')
+110
View File
@@ -0,0 +1,110 @@
import { unsafeWindow } from 'vite-plugin-monkey/dist/client'
import { isTemporaryChat } from './page'
/** The endpoint that streams a message exchange back to the page. */
const CONVERSATION_STREAM_PATH = '/backend-api/f/conversation'
const DATA_PREFIX = 'data:'
/** Give up if the id has not shown up in the first chunks of the stream. */
const MAX_SCAN_LENGTH = 200_000
let temporaryChatId: string | null = null
export function getTemporaryChatId() {
return temporaryChatId
}
/**
* Mirrors `checkIfConversationStarted`: a temporary chat can only be exported
* once its id has been observed.
*/
export function checkIfTemporaryChatIsExportable() {
return !isTemporaryChat() || temporaryChatId !== null
}
/**
* Temporary chats are hidden from the conversation list and their id never
* reaches the URL, so there is no way to ask for one by name. They are served
* by the regular conversation endpoint once the id is known, and the server
* announces that id in the response that streams the reply back to the page,
* so record it as it goes past.
*/
export function watchTemporaryChatId() {
const originalFetch = unsafeWindow.fetch
unsafeWindow.fetch = async (input, init) => {
// `fetch` needs its original receiver, or Chrome throws on invocation.
const response = await originalFetch.call(unsafeWindow, input, init)
if (!isTemporaryChat() || !response.body) return response
const url = typeof input === 'string'
? input
: input instanceof URL ? input.href : input.url
if (!url.includes(CONVERSATION_STREAM_PATH)) return response
// Read a copy so the page still receives the untouched stream.
readConversationId(response.clone())
return response
}
}
async function readConversationId(response: Response) {
const reader = response.body?.getReader()
if (!reader) return
const decoder = new TextDecoder()
let buffer = ''
let scanned = 0
try {
while (scanned < MAX_SCAN_LENGTH) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
scanned += chunk.length
buffer += chunk
// Hold back the trailing partial line until the next chunk.
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
const conversationId = findConversationId(lines)
if (conversationId) {
temporaryChatId = conversationId
break
}
}
}
catch (error) {
console.error('[Exporter] Failed to read the temporary chat id', error)
}
finally {
// Release the copy. Cancelling one branch of a teed stream leaves the
// branch the page is reading alone.
reader.cancel().catch(() => {})
}
}
/**
* Read the id off the stream's own events. Only the `conversation_id` field is
* taken; the message content the events carry is never inspected or kept.
*/
function findConversationId(lines: string[]): string | null {
for (const line of lines) {
if (!line.startsWith(DATA_PREFIX)) continue
const payload = line.slice(DATA_PREFIX.length).trim()
if (!payload || payload === '[DONE]') continue
try {
const { conversation_id: conversationId } = JSON.parse(payload)
if (typeof conversationId === 'string') return conversationId
}
catch {
// Not every event in the stream carries a JSON payload.
}
}
return null
}