Skip to content

Commit 49c6c9a

Browse files
committed
Condensed download routines ↞ [auto-sync from https://github.com/adamlui/ai-web-extensions/tree/main/duckduckgpt]
1 parent 71e7e50 commit 49c6c9a

1 file changed

Lines changed: 234 additions & 17 deletions

File tree

chatgpt/duckduckgpt/duckduckgpt.user.js

Lines changed: 234 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@
148148
// @description:zu Yengeza izimpendulo ze-AI ku-DuckDuckGo (inikwa amandla yi-GPT-4o!)
149149
// @author KudoAI
150150
// @namespace https://kudoai.com
151-
// @version 2025.12.16
151+
// @version 2026.1.3
152152
// @license MIT
153153
// @icon https://assets.ddgpt.com/images/icons/app/icon48.png?v=533ce0f
154154
// @icon64 https://assets.ddgpt.com/images/icons/app/icon64.png?v=533ce0f
@@ -251,6 +251,229 @@
251251

252252
// Documentation: https://docs.ddgpt.com
253253

254+
255+
// Requires components/replyBubble.js + lib/<dom|Tone>.js + <app|get|prompts|show|tooltip|xhr>
256+
257+
window.buttons = {
258+
reply: {
259+
bubble: {
260+
types: ['copy', 'share', 'regen', 'speak'], // right-to-left
261+
styles: 'float: right ; cursor: pointer ;',
262+
263+
create() { // requires lib/<dom|Tone>.js + <app|get|prompts|show|tooltip|xhr>
264+
if (this.share) return
265+
266+
// Copy button
267+
this.copy = dom.create.elem('btn', {
268+
id: `${app.slug}-copy-btn`, class: 'no-mobile-tap-outline',
269+
style: this.styles + 'display: flex ; margin-top: 1.5px'
270+
})
271+
const copySVGs = {
272+
copy: icons.create({ key: 'copy' }), copied: icons.create({ key: 'checkmarkDouble' })}
273+
Object.entries(copySVGs).forEach(([svgType, svg]) => {
274+
svg.id = `${app.slug}-${svgType}-icon`
275+
;['width', 'height'].forEach(attr => svg.setAttribute(attr, 13))
276+
})
277+
this.copy.append(copySVGs.copy)
278+
this.copy.listeners = {}
279+
if (!env.browser.isMobile) // store/add tooltip listeners
280+
['onmouseenter', 'onmouseleave'].forEach(eventType =>
281+
this.copy[eventType] = this.copy.listeners[eventType] = tooltip.toggle)
282+
this.copy.listeners.onclick = this.copy.onclick = ({ currentTarget }) => { // copy text, update icon + tooltip status
283+
const copyBtn = currentTarget
284+
if (!copyBtn.firstChild.matches('[id$=copy-icon]')) return // since clicking on Copied icon
285+
const textContainer = (
286+
currentTarget.parentNode.className.includes('reply-header')
287+
? app.div.querySelector('.reply-pre') // reply container
288+
: currentTarget.closest('code') // code container
289+
)
290+
const textToCopy = textContainer.textContent.replace(/^>> /, '').trim()
291+
copyBtn.style.cursor = 'default' // remove finger
292+
copyBtn.firstChild.replaceWith(copySVGs.copied.cloneNode(true)) // change to Copied icon
293+
tooltip.update(currentTarget) // to 'Copied to clipboard!'
294+
setTimeout(() => { // restore icon/cursor/tooltip after a bit
295+
copyBtn.firstChild.replaceWith(copySVGs.copy.cloneNode(true))
296+
copyBtn.style.cursor = 'pointer'
297+
if (copyBtn.matches(':hover')) // restore tooltip
298+
copyBtn.dispatchEvent(new Event('mouseenter'))
299+
}, 1355)
300+
navigator.clipboard.writeText(textToCopy) // copy text to clipboard
301+
}
302+
303+
// Share button
304+
this.share = dom.create.elem('btn', {
305+
id: `${app.slug}-share-btn`, class: 'no-mobile-tap-outline',
306+
style: this.styles + 'margin-right: 10px ;' + (
307+
app.slug == 'bravegpt' ? 'position: relative ; bottom: 2px' : '' )
308+
})
309+
this.share.append(icons.create({ key: 'arrowShare', size: 16 }))
310+
if (!env.browser.isMobile) this.share.onmouseenter = this.share.onmouseleave = tooltip.toggle
311+
this.share.onclick = ({ currentTarget }) => {
312+
if (show.reply.shareURL) return modals.shareChat(show.reply.shareURL)
313+
this.share.style.cursor = 'default' // remove finger
314+
if (!config.fgAnimationsDisabled) this.share.style.animation = 'spinY 1s linear infinite'
315+
tooltip.update(currentTarget) // to 'Generating HTML...'
316+
xhr({
317+
method: 'POST', url: app.urls.apis.chatShare,
318+
headers: { 'Content-Type': 'application/json', 'Referer': location.href },
319+
data: JSON.stringify({ messages: prompts.stripAugments(app.msgChain) }),
320+
onload: ({ responseText }) => {
321+
modals.shareChat(show.reply.shareURL = JSON.parse(responseText).url)
322+
this.share.style.animation = '' ; this.share.style.cursor = 'pointer'
323+
}
324+
})
325+
}
326+
327+
// Regenerate button
328+
this.regen = dom.create.elem('btn', {
329+
id: `${app.slug}-regen-btn`, class: 'no-mobile-tap-outline',
330+
style: this.styles + 'position: relative ; top: 1px ; margin: 0 9px 0 5px'
331+
})
332+
const regenSVGwrapper = dom.create.elem('div', { // to spin while respecting ini icon tilt
333+
style: 'display: flex' }) // wrap the icon tightly
334+
regenSVGwrapper.append(icons.create({ key: 'arrowsCyclic', size: 14 }))
335+
this.regen.append(regenSVGwrapper)
336+
if (!env.browser.isMobile) this.regen.onmouseenter = this.regen.onmouseleave = tooltip.toggle
337+
this.regen.onclick = ({ currentTarget }) => {
338+
get.reply({ msgs: app.msgChain, src: 'regen' })
339+
regenSVGwrapper.style.cursor = 'default' // remove finger
340+
if (config.fgAnimationsDisabled) regenSVGwrapper.style.transform = 'rotate(90deg)'
341+
else regenSVGwrapper.style.animation = 'rotate 1s infinite cubic-bezier(0, 1.05, 0.79, 0.44)'
342+
tooltip.update(currentTarget) // to 'Regenerating...'
343+
show.reply.chatbarFocused = false ; show.reply.userInteracted = true
344+
}
345+
346+
// Speak button
347+
this.speak = dom.create.elem('btn', {
348+
id: `${app.slug}-speak-btn`, class: 'no-mobile-tap-outline',
349+
style: this.styles + 'margin: -1px 3px 0 0'
350+
})
351+
const speakSVGwrapper = dom.create.elem('div', { // to show 1 icon at a time during scroll
352+
style: 'width: 19px ; height: 19px ; overflow: hidden' })
353+
const speakSVGscroller = dom.create.elem('div', { // to scroll the icons
354+
style: `display: flex ; /* align the SVGs horizontally */
355+
width: 41px ; height: 22px /* rectangle to fit both icons */` })
356+
const speakSVGs = { speak: icons.create({ key: 'soundwave', id: `${app.slug}-speak-icon` })}
357+
;['generating', 'playing'].forEach(state => {
358+
speakSVGs[state] = []
359+
for (let i = 0 ; i < 2 ; i++) { // push/id 2 of each state icon for continuous scroll animation
360+
speakSVGs[state].push(
361+
icons.create({ key: `soundwave${ state == 'generating' ? 'Short' : 'Tall' }`}))
362+
speakSVGs[state][i].id = `${app.slug}-${state}-icon-${i+1}`
363+
if (i == 1) // close gap of 2nd icon during scroll
364+
speakSVGs[state][i].style.marginLeft = `-${ state == 'generating' ? 3 : 5 }px`
365+
}
366+
})
367+
speakSVGscroller.append(speakSVGs.speak) ; speakSVGwrapper.append(speakSVGscroller)
368+
this.speak.append(speakSVGwrapper)
369+
if (!env.browser.isMobile) this.speak.onmouseenter = this.speak.onmouseleave = tooltip.toggle
370+
this.speak.onclick = async ({ currentTarget }) => {
371+
if (this.speak.contains(speakSVGs.generating[0])) return
372+
if (window.currentlyPlayingAudio) {
373+
window.currentlyPlayingAudio.stop() ; handleAudioEnded() ; return }
374+
375+
this.speak.style.cursor = 'default' // remove finger
376+
377+
// Update icon to Generating ones
378+
speakSVGscroller.textContent = '' // rid Speak icon
379+
speakSVGscroller.append(speakSVGs.generating[0], speakSVGs.generating[1]) // add Generating icons
380+
if (!config.fgAnimationsDisabled) { // animate icons
381+
speakSVGscroller.style.animation = 'icon-scroll 1s cubic-bezier(0.68, -0.55, 0.27, 1.55) infinite'
382+
speakSVGwrapper.style.maskImage = ( // fade edges
383+
'linear-gradient(to right, transparent, black 20%, black 81%, transparent)' )
384+
}
385+
386+
tooltip.update(currentTarget) // to 'Generating audio...'
387+
388+
// Init Sogou TTS dialect map
389+
window.sgtDialectMap ||= await get.json(`${app.urls.aiwebAssets}/data/sogou-tts-lang-codes.json`)
390+
.catch(err => log.error(err.message)) ; if (!window.sgtDialectMap) return
391+
Object.entries(window.sgtDialectMap).forEach(([sgtCode, langData]) => {
392+
langData.isoOrNamePattern = new RegExp(langData.isoOrNamePattern, 'i')
393+
langData.sgtCode = sgtCode
394+
})
395+
396+
// Init other config/data
397+
const wholeAnswer = app.div.querySelector('.reply-pre').textContent
398+
const cjsSpeakConfig = { voice: 2, pitch: 1, speed: 1.5, onend: handleAudioEnded }
399+
const sgtDialectData = Object.values(window.sgtDialectMap).find(langData =>
400+
langData.isoOrNamePattern.test(config.replyLang)
401+
) || window.sgtDialectMap.en
402+
const payload = { text: wholeAnswer, curTime: Date.now(), spokenDialect: sgtDialectData.sgtCode }
403+
const key = CryptoJS.enc.Utf8.parse('76350b1840ff9832eb6244ac6d444366')
404+
const iv = CryptoJS.enc.Utf8.parse(
405+
atob('AAAAAAAAAAAAAAAAAAAAAA==') || '76350b1840ff9832eb6244ac6d444366')
406+
const securePayload = CryptoJS.AES.encrypt(JSON.stringify(payload), key, {
407+
iv, mode: CryptoJS.mode.CBC, pad: CryptoJS.pad.Pkcs7 }).toString()
408+
409+
// Play reply
410+
xhr({
411+
url: 'https://fanyi.sogou.com/openapi/external/getWebTTS?S-AppId=102356845&S-Param='
412+
+ encodeURIComponent(securePayload),
413+
method: 'GET', responseType: 'arraybuffer',
414+
onload: resp => {
415+
416+
// Update icons to Playing ones
417+
speakSVGscroller.textContent = '' // rid Generating icons
418+
speakSVGscroller.append(speakSVGs.playing[0], speakSVGs.playing[1]) // add Playing icons
419+
if (!config.fgAnimationsDisabled) // animate icons
420+
speakSVGscroller.style.animation = 'icon-scroll 0.5s linear infinite'
421+
422+
if (this.speak.matches(':hover')) // restore tooltip
423+
this.speak.dispatchEvent(new Event('mouseenter'))
424+
425+
// Play audio
426+
if (resp.status != 200) {
427+
buttons.reply.bubble.speak.style.cursor = 'pointer'
428+
window.currentlyPlayingAudio = chatgpt.speak(wholeAnswer, cjsSpeakConfig)
429+
} else {
430+
const audioContext = new (window.webkitAudioContext || window.AudioContext)()
431+
audioContext.decodeAudioData(resp.response, buffer => {
432+
buttons.reply.bubble.speak.style.cursor = 'pointer'
433+
const player = new Tone.Player(buffer), speed = 1.5
434+
const fx = { // applied in top-down order
435+
pitchShifter: new Tone.PitchShift(12 * Math.log2(1/speed)), // keep og pitch
436+
phaser: new Tone.Phaser({ // make robotic
437+
frequency: 55, octaves: 5, baseFrequency: 1000 }),
438+
chorus: new Tone.Chorus(4, 2.5, 0.5),
439+
eq: new Tone.EQ3({ // apply smile curve
440+
low: 12, mid: 0, high: 9, lowFrequency: 300, highFrequency: 500 })
441+
}
442+
let outputNode = player
443+
Object.values(fx).forEach(effect => { // chain 'em
444+
outputNode.connect(effect) ; outputNode = effect })
445+
outputNode.toDestination()
446+
player.playbackRate = speed ; player.start()
447+
window.currentlyPlayingAudio = player ; player.onstop = handleAudioEnded
448+
}).catch(() => {
449+
buttons.reply.bubble.speak.style.cursor = 'pointer'
450+
window.currentlyPlayingAudio = chatgpt.speak(wholeAnswer, cjsSpeakConfig)
451+
})
452+
}
453+
}
454+
})
455+
456+
function handleAudioEnded() {
457+
speakSVGscroller.textContent = speakSVGscroller.style.animation = '' // rid Playing icons
458+
speakSVGscroller.append(speakSVGs.speak) // restore Speak icon
459+
if (buttons.reply.bubble.speak.matches(':hover')) // restore tooltip
460+
buttons.reply.bubble.speak.dispatchEvent(new Event('mouseenter'))
461+
window.currentlyPlayingAudio = null
462+
}
463+
}
464+
},
465+
466+
insert() { // requires components/replyBubble.js + lib/dom.js
467+
if (!this.share) this.create() ; if (!replyBubble.preHeader) replyBubble.create()
468+
replyBubble.preHeader.append(
469+
this.preHeaderBtnsDiv ||= dom.create.elem('div', { class: 'reply-header-btns' }))
470+
this.preHeaderBtnsDiv.append(this.copy, this.share, this.regen, this.speak)
471+
}
472+
}
473+
}
474+
};
475+
476+
254477
(async () => {
255478
'use strict'
256479

@@ -1416,17 +1639,12 @@
14161639

14171640
// Download code
14181641
const code = codeBlock.textContent.replace(/^>> /, '').trim() + '\n'
1419-
const dlLink = dom.create.anchor(URL.createObjectURL(new Blob([code], { type: 'text/plain' })))
1420-
const now = new Date(), formattedDate = [ // YYYY-MM-DD
1421-
now.getFullYear(),
1422-
String(now.getMonth() +1).padStart(2, '0'),
1423-
String(now.getDate()).padStart(2, '0')
1424-
].join('-')
1425-
dlLink.download /* filename */ = `${app.slug}_${blockLang.name.toLowerCase() || 'code'}_${
1426-
formattedDate}_${Date.now().toString(36)}${
1427-
blockLang.fileExtension ? '.' + blockLang.fileExtension : '' }`
1428-
document.body.append(dlLink) ; dlLink.click() ; dlLink.remove() // download code
1429-
URL.revokeObjectURL(dlLink.href) // prevent memory leaks
1642+
const date = new Date().toISOString().split('T')[0] // YYYY-MM-DD
1643+
const download = `${app.slug}_${blockLang.name.toLowerCase() || 'code'}_${
1644+
date}_${Date.now().toString(36)}${blockLang.fileExtension ? '.' + blockLang.fileExtension : ''}`
1645+
const href = URL.createObjectURL(new Blob([code], { type: 'text/plain' }))
1646+
const a = dom.create.anchor(href, '', { download, style: 'display: none' })
1647+
document.body.append(a) ; a.click() ; a.remove() ; URL.revokeObjectURL(href)
14301648
}
14311649
downloadBtn.onmouseenter = downloadBtn.onmouseleave = tooltip.toggle
14321650

@@ -2544,15 +2762,14 @@
25442762
xhr({
25452763
method: 'GET', url: shareURL,
25462764
onload: ({ responseText }) => {
2547-
const dlLink = dom.create.anchor(
2548-
URL.createObjectURL(new Blob([responseText], { type: 'text/html' })))
2549-
dlLink.download /* filename */ = responseText.match(/<title>([^<]+)<\/title>/i)[1]
2765+
const download = responseText.match(/<title>([^<]+)<\/title>/i)[1]
25502766
.replace(/\s*[|/]+\s*/g, ' ') // convert symbols to space for hyphen-casing
25512767
.replace(/\.{2,}/g, '') // strip ellipsis
25522768
.toLowerCase().trim().replace(/\s+/g, '-') // hyphen-case
25532769
+ '.html'
2554-
document.body.append(dlLink) ; dlLink.click() ; dlLink.remove() // download HTML
2555-
URL.revokeObjectURL(dlLink.href) // prevent memory leaks
2770+
const href = URL.createObjectURL(new Blob([responseText], { type: 'text/html' }))
2771+
const a = dom.create.anchor(href, '', { download, style: 'display: none' })
2772+
document.body.append(a) ; a.click() ; a.remove() ; URL.revokeObjectURL(href)
25562773
},
25572774
onerror: err => log.error('Failed to download chat:', err)
25582775
})

0 commit comments

Comments
 (0)