mirror of
https://github.com/pionxzh/chatgpt-exporter.git
synced 2026-09-21 13:37:42 -05:00
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:
co-authored by
Pascal Perle
Pionxzh
parent
45aca51d87
commit
c397f0df53
+12
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "すべてエクスポートの上限",
|
||||
|
||||
@@ -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": "Лимит экспорта всех",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "批量导出上限",
|
||||
|
||||
@@ -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": "批量匯出上限",
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user