Skip to content

Commit 2e20acc

Browse files
committed
feat: add Gemini AI Studio support with export functionality
1 parent 9dbb235 commit 2e20acc

11 files changed

Lines changed: 1516 additions & 1457 deletions

package-lock.json

Lines changed: 1222 additions & 1397 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@
6767
"run": {
6868
"startUrl": [
6969
"https://chatgpt.com",
70-
"https://claude.ai"
70+
"https://claude.ai",
71+
"https://aistudio.google.com"
7172
]
7273
}
7374
},

readme.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ ChatGPT Button:
3131

3232
![ChatGPT Export Button](https://github.com/user-attachments/assets/9a030781-4e8d-47a0-87b2-c15461f08ce4)
3333

34+
Gemini AIStudio Button:
35+
36+
<!-- ![Gemini AIStudio Export Button]() -->
37+
3438
## Getting started
3539

3640
<!--
@@ -52,7 +56,7 @@ The build step will create the `dist` folder, this folder will contain the gener
5256
Using [web-ext](https://extensionworkshop.com/documentation/develop/getting-started-with-web-ext/) is recommended for automatic reloading and running in a dedicated browser instance. Alternatively you can load the extension manually (see below).
5357

5458
1. Run `npm run watch` to watch for file changes and build continuously
55-
1. Run `npm install --global web-ext` (only only for the first time)
59+
1. Run `npm install --global web-ext` (only for the first time)
5660
1. In another terminal, run `web-ext run -t chromium`
5761
1. Check that the extension is loaded by opening the extension options ([in Firefox](media/extension_options_firefox.png) or [in Chrome](media/extension_options_chrome.png)).
5862

src/content.ts

Lines changed: 61 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,42 +3,75 @@ import browser from 'webextension-polyfill'
33
import { detectSite } from '@/modules/site-detection'
44
import { createExportButton } from '@/modules/ui'
55

6-
console.log('💈 Content script loaded for', browser.runtime.getManifest().name)
6+
console.log(' Content script loaded for', browser.runtime.getManifest().name)
77

88
function init() {
99
const site = detectSite()
1010

11-
const observer = new MutationObserver((mutations) => {
12-
mutations.forEach((mutation) => {
13-
if (mutation.addedNodes.length) {
14-
if (site === 'claude') {
15-
// for Claude.ai, look for the share button container
16-
const shareButtonContainer = document.querySelector('button[data-testid="share-button"]')
17-
?.parentElement?.parentElement
18-
if (
19-
shareButtonContainer &&
20-
!document.querySelector('[data-testid="export-chat-button"]')
21-
) {
22-
const exportButton = createExportButton()
23-
shareButtonContainer.parentElement?.appendChild(exportButton)
24-
}
25-
} else {
26-
// for ChatGPT, look for the share button
27-
const shareButton = document.querySelector('[data-testid="share-chat-button"]')
28-
if (shareButton && !document.querySelector('[data-testid="export-chat-button"]')) {
29-
const exportButton = createExportButton()
30-
shareButton.parentElement?.insertBefore(exportButton, shareButton)
11+
// track if we've already added the button to prevent infinite loops
12+
let buttonAdded = false
13+
14+
// for Gemini, wait a bit before starting to observe since the Angular app needs time to load
15+
const startDelay = site === 'gemini' ? 2000 : 0
16+
17+
setTimeout(() => {
18+
const observer = new MutationObserver((mutations) => {
19+
mutations.forEach((mutation) => {
20+
if (mutation.addedNodes.length) {
21+
if (site === 'claude') {
22+
// for Claude.ai, look for the share button container
23+
const shareButtonContainer = document.querySelector(
24+
'button[data-testid="share-button"]'
25+
)?.parentElement?.parentElement
26+
if (
27+
shareButtonContainer &&
28+
!document.querySelector('[data-testid="export-chat-button"]')
29+
) {
30+
const exportButton = createExportButton()
31+
shareButtonContainer.parentElement?.appendChild(exportButton)
32+
}
33+
} else if (site === 'gemini' && !buttonAdded) {
34+
// for Gemini (aistudio.google.com), add retry logic due to delayed Angular UI loading
35+
const tryAddGeminiButton = (retryCount = 0) => {
36+
const toolbarRight = document.querySelector('ms-toolbar .toolbar-right')
37+
if (
38+
toolbarRight &&
39+
!document.querySelector('[data-testid="export-chat-button"]') &&
40+
!buttonAdded
41+
) {
42+
const exportButton = createExportButton()
43+
// insert before the last button (tune/settings button)
44+
const lastButton = toolbarRight.lastElementChild
45+
if (lastButton && lastButton.tagName === 'BUTTON') {
46+
toolbarRight.insertBefore(exportButton, lastButton)
47+
} else {
48+
toolbarRight.appendChild(exportButton)
49+
}
50+
buttonAdded = true
51+
} else if (!toolbarRight && retryCount < 4 && !buttonAdded) {
52+
// retry up to 5 times with increasing delay
53+
setTimeout(() => tryAddGeminiButton(retryCount + 1), 1000 + retryCount * 500)
54+
}
55+
}
56+
tryAddGeminiButton()
57+
} else {
58+
// for ChatGPT, look for the share button
59+
const shareButton = document.querySelector('[data-testid="share-chat-button"]')
60+
if (shareButton && !document.querySelector('[data-testid="export-chat-button"]')) {
61+
const exportButton = createExportButton()
62+
shareButton.parentElement?.insertBefore(exportButton, shareButton)
63+
}
3164
}
3265
}
33-
}
66+
})
3467
})
35-
})
3668

37-
// start observing
38-
observer.observe(document.body, {
39-
childList: true,
40-
subtree: true,
41-
})
69+
// start observing
70+
observer.observe(document.body, {
71+
childList: true,
72+
subtree: true,
73+
})
74+
}, startDelay)
4275
}
4376

4477
init()

src/manifest.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://json.schemastore.org/chrome-manifest",
33
"name": "Chat Export",
4-
"version": "0.0.2",
4+
"version": "0.0.3",
55
"description": "Export LLM chat conversations to Markdown, XML, JSON, and HTML",
66
"manifest_version": 3,
77
"minimum_chrome_version": "121",
@@ -18,7 +18,12 @@
1818
"host_permissions": ["https://chat.openai.com/*"],
1919
"content_scripts": [
2020
{
21-
"matches": ["https://chat.openai.com/*", "https://chatgpt.com/*", "https://claude.ai/*"],
21+
"matches": [
22+
"https://chat.openai.com/*",
23+
"https://chatgpt.com/*",
24+
"https://claude.ai/*",
25+
"https://aistudio.google.com/*"
26+
],
2227
"js": ["content.ts"],
2328
"css": ["styles/content.css"],
2429
"run_at": "document_end"

src/modules/chat-content.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,120 @@ export async function getChatContent(restoreClipboard: boolean = false): Promise
9797

9898
messages.push({ role, content })
9999
}
100+
} else if (site === 'gemini') {
101+
// aistudio.google.com (Gemini)
102+
const chatTurns = document.querySelectorAll('ms-chat-turn')
103+
104+
for (const turn of chatTurns) {
105+
// scroll this specific turn into view to ensure it's loaded
106+
turn.scrollIntoView({
107+
behavior: 'smooth',
108+
block: 'center',
109+
})
110+
111+
// wait for content to load
112+
await new Promise((resolve) => setTimeout(resolve, 500))
113+
114+
// determine role based on the chat-turn-container class
115+
const container = turn.querySelector('.chat-turn-container')
116+
if (!container) {
117+
continue
118+
}
119+
120+
const role = container.classList.contains('user') ? 'user' : 'assistant'
121+
122+
// find the more options button
123+
const optionsElement = turn.querySelector('ms-chat-turn-options')
124+
if (!optionsElement) {
125+
continue
126+
}
127+
128+
const moreOptionsButton = optionsElement.querySelector(
129+
'button[aria-label="Open options"]'
130+
) as HTMLButtonElement
131+
if (!moreOptionsButton) {
132+
continue
133+
}
134+
135+
// click the more options button to open the overlay menu
136+
moreOptionsButton.click()
137+
138+
// wait for overlay to appear
139+
await new Promise((resolve) => setTimeout(resolve, 300))
140+
141+
// find the overlay container (it's a sibling of body)
142+
const overlayContainer = document.querySelector('.cdk-overlay-container')
143+
if (!overlayContainer) {
144+
// if we can't find the overlay, skip this message
145+
continue
146+
}
147+
148+
// find the "Copy as markdown" button in the overlay
149+
let copyMarkdownButton: HTMLButtonElement | null = null
150+
151+
// try different approaches to find the copy as markdown button
152+
const buttons = Array.from(overlayContainer.querySelectorAll('button'))
153+
for (const btn of buttons) {
154+
if (btn.textContent?.includes('Copy as markdown')) {
155+
copyMarkdownButton = btn as HTMLButtonElement
156+
break
157+
}
158+
}
159+
160+
// also try finding by the specific icon class mentioned in the HTML
161+
if (!copyMarkdownButton) {
162+
copyMarkdownButton = overlayContainer.querySelector(
163+
'button .copy-markdown-button'
164+
) as HTMLButtonElement
165+
}
166+
167+
if (!copyMarkdownButton) {
168+
// close the overlay by clicking outside or pressing Escape
169+
const backdrop = document.querySelector('.cdk-overlay-backdrop')
170+
if (backdrop) {
171+
;(backdrop as HTMLElement).click()
172+
}
173+
continue
174+
}
175+
176+
// click the copy as markdown button
177+
copyMarkdownButton.click()
178+
179+
// wait for clipboard to be updated
180+
await new Promise((resolve) => setTimeout(resolve, 200))
181+
182+
// get the content from clipboard
183+
const content = await navigator.clipboard.readText()
184+
185+
// Check for thinking content in Gemini messages
186+
let thinkingContent = ''
187+
if (role === 'assistant') {
188+
const thinkingChunk = turn.querySelector('ms-thought-chunk')
189+
if (thinkingChunk) {
190+
// Find the thinking panel that contains "Thoughts" text
191+
const thinkingPanel = thinkingChunk.querySelector('mat-panel-title')
192+
if (thinkingPanel && thinkingPanel.textContent?.includes('Thoughts')) {
193+
// Extract thinking content from the expanded panel
194+
const thinkingBody = thinkingChunk.querySelector('.mat-expansion-panel-body')
195+
if (thinkingBody) {
196+
const thinkingText = thinkingBody.textContent?.trim() || ''
197+
if (thinkingText) {
198+
thinkingContent = `*Thinking*\n${thinkingText}\n\n`
199+
}
200+
}
201+
}
202+
}
203+
}
204+
205+
if (content && content.trim()) {
206+
// Combine thinking content (if any) with regular content
207+
const finalContent = thinkingContent + content.trim()
208+
messages.push({ role, content: finalContent })
209+
}
210+
211+
// wait a bit to avoid rate limiting
212+
await new Promise((resolve) => setTimeout(resolve, 100))
213+
}
100214
} else {
101215
// claude.ai
102216
const messageDivs = document.querySelectorAll('div.font-claude-message, div.font-user-message')

src/modules/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export const CLAUDE_URLS = ['https://claude.ai']
22
export const CHATGPT_URLS = ['https://chat.openai.com', 'https://chatgpt.com']
3+
export const GEMINI_URLS = ['https://aistudio.google.com']
34
export const SURROUND_PASTE_FILE_IN_BACKTICKS = true

src/modules/content-formatting.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Message } from './types'
22

33
export async function formatContent(messages: Array<Message>, format: string): Promise<string> {
4-
console.log(`Messages: ${JSON.stringify(messages)}`)
4+
// console.log(`Messages: ${JSON.stringify(messages)}`)
55

66
switch (format) {
77
case 'markdown':

src/modules/site-detection.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CHATGPT_URLS, CLAUDE_URLS } from './constants'
1+
import { CHATGPT_URLS, CLAUDE_URLS, GEMINI_URLS } from './constants'
22
import { Site } from './types'
33

44
export function detectSite(): Site {
@@ -9,5 +9,8 @@ export function detectSite(): Site {
99
if (CLAUDE_URLS.some((u) => url.includes(u))) {
1010
return 'claude'
1111
}
12+
if (GEMINI_URLS.some((u) => url.includes(u))) {
13+
return 'gemini'
14+
}
1215
throw new Error('Unsupported site')
1316
}

src/modules/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type Site = 'chatgpt' | 'claude'
1+
export type Site = 'chatgpt' | 'claude' | 'gemini'
22

33
export interface Message {
44
role: string

0 commit comments

Comments
 (0)