|
148 | 148 | // @description:zu Yengeza izimpendulo ze-AI ku-DuckDuckGo (inikwa amandla yi-GPT-4o!) |
149 | 149 | // @author KudoAI |
150 | 150 | // @namespace https://kudoai.com |
151 | | -// @version 2025.12.16 |
| 151 | +// @version 2026.1.3 |
152 | 152 | // @license MIT |
153 | 153 | // @icon https://assets.ddgpt.com/images/icons/app/icon48.png?v=533ce0f |
154 | 154 | // @icon64 https://assets.ddgpt.com/images/icons/app/icon64.png?v=533ce0f |
|
251 | 251 |
|
252 | 252 | // Documentation: https://docs.ddgpt.com |
253 | 253 |
|
| 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 | + |
254 | 477 | (async () => { |
255 | 478 | 'use strict' |
256 | 479 |
|
|
1416 | 1639 |
|
1417 | 1640 | // Download code |
1418 | 1641 | 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) |
1430 | 1648 | } |
1431 | 1649 | downloadBtn.onmouseenter = downloadBtn.onmouseleave = tooltip.toggle |
1432 | 1650 |
|
|
2544 | 2762 | xhr({ |
2545 | 2763 | method: 'GET', url: shareURL, |
2546 | 2764 | 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] |
2550 | 2766 | .replace(/\s*[—|/]+\s*/g, ' ') // convert symbols to space for hyphen-casing |
2551 | 2767 | .replace(/\.{2,}/g, '') // strip ellipsis |
2552 | 2768 | .toLowerCase().trim().replace(/\s+/g, '-') // hyphen-case |
2553 | 2769 | + '.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) |
2556 | 2773 | }, |
2557 | 2774 | onerror: err => log.error('Failed to download chat:', err) |
2558 | 2775 | }) |
|
0 commit comments