// ==UserScript== // @name Perplexity helper // @namespace Tiartyos // @match https://www.perplexity.ai/* // @grant none // @version 2.4 // @author Tiartyos, monnef // @description Simple script that adds buttons to Perplexity website for repeating request using Copilot. // @require https://code.jquery.com/jquery-3.6.0.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/jquery-modal/0.9.1/jquery.modal.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/lodash-fp/0.10.4/lodash-fp.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.8/clipboard.min.js // @require https://cdn.jsdelivr.net/npm/color2k@2.0.2/dist/index.unpkg.umd.js // @require https://cdn.jsdelivr.net/npm/showdown@2.1.0/dist/showdown.min.js // @homepageURL https://www.perplexity.ai/ // @license GPL-3.0-or-later // @downloadURL none // ==/UserScript== const jq = $.noConflict(); const $c = (cls, parent) => jq(`.${cls}`, parent); const $i = (id, parent) => jq(`#${id}`, parent); const takeStr = n => str => str.slice(0, n); const dropStr = n => str => str.slice(n); const nl = '\n'; const markdownConverter = new showdown.Converter(); let debugMode = false; const enableDebugMode = () => { debugMode = true; }; const logPrefix = '[Perplexity helper]'; const debugLog = (...args) => { if (debugMode) { console.debug(logPrefix, ...args); } } let debugTags = false; const debugLogTags = (...args) => { if (debugTags) { console.debug(logPrefix, '[tags]', ...args); } } ($ => { $.fn.nthParent = function(n) { let $p = $(this); if (!(n > -0)) { return $() } let p = 1 + n; while (p--) { $p = $p.parent(); } return $p; }; })(jq); const button = (id, icoName, title, extraClass) => ``; const upperButton = (id, icoName, title) => `
${icoName}
` const textButton = (id, text, title) => ` ` const icoColor = '#1F1F1F'; const robotIco = ``; const robotRepeatIco = ` `; const cogIco = ` \t `; const perplexityHelperModalId = 'perplexityHelperModal'; const getPerplexityHelperModal = () => $i(perplexityHelperModalId); const modalHTML = ` `; const genCssName = x => `perplexity-helper--${x}`; const tagsContainerCls = genCssName('tags-container'); const threadTagContainerCls = genCssName('thread-tag-container'); const newTagContainerCls = genCssName('new-tag-container'); const tagCls = genCssName('tag'); const tagDarkTextCls = genCssName('tag-dark-text'); const tagPaletteCls = genCssName('tag-palette'); const tagPaletteItemCls = genCssName('tag-palette-item'); const tagsPreviewCls = genCssName('tags-preview'); const tagsPreviewNewCls = genCssName('tags-preview-new'); const tagsPreviewThreadCls = genCssName('tags-preview-thread'); const helpTextCls = genCssName('help-text'); const queryBoxCls = genCssName('query-box'); const controlsAreaCls = genCssName('controls-area'); const textAreaCls = genCssName('text-area'); const standardButtonCls = genCssName('standard-button'); const roundedMD = genCssName('rounded-md'); const topSettingsButtonId = genCssName('settings-button-top'); const leftSettingsButtonId = genCssName('settings-button-left'); const styles = ` .checkbox_label { color: white; } .textarea_wrapper { display: flex; flex-direction: column; } .textarea_wrapper > textarea { width: 100%; background-color: rgba(0, 0, 0, 0.8); padding: 0 5px; } .textarea_label { margin-right: auto; } .${helpTextCls} { max-width: 580px; background-color: #225; padding: 0.3em 0.7em; border-radius: 0.5em; margin: 1em 0; } .${helpTextCls} { cursor: text; } .${helpTextCls} code { font-size: 80%; background-color: rgba(255, 255, 255, 0.1); border-radius: 0.3em; padding: 0.1em; } .${helpTextCls} pre > code { background: none; } .${helpTextCls} pre { font-size: 80%; overflow: auto; background-color: rgba(255, 255, 255, 0.1); border-radius: 0.3em; padding: 0.1em 1em; } .${helpTextCls} li { list-style: circle; margin-left: 1em; } .${helpTextCls} hr { margin: 1em 0 0.5em 0; border-color: rgba(255, 255, 255, 0.1); } .btn-helper { margin-left: 20px } .modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0, 0, 0, 0.8) } .modal-content { display: flex; margin: 1em auto; width: calc(100vw - 2em); padding: 20px; border: 1px solid #888; background-color: #202025; border-radius: 6px; color: rgb(206, 206, 210); flex-direction: column; position: relative; row-gap: 10px; overflow-y: auto; cursor: default; } .modal-content label { padding-right: 10px; } .modal-content h1 { margin-bottom: 0.5em; border-bottom: 1px solid #888; } .close { color: rgb(206, 206, 210); float: right; font-size: 28px; font-weight: bold; position: absolute; right: 10px; top: 5px; } .close:hover, .close:focus { color: white; text-decoration: none; cursor: pointer; } #copied-modal,#copied-modal-2 { padding: 5px 5px; background:gray; position:absolute; display: none; color: white; font-size: 15px; } label > div.select-none { user-select: text; cursor: initial; } .${tagsContainerCls} { display: flex; gap: 5px; margin: 5px 0; flex-wrap: wrap; } .${tagsContainerCls}.${threadTagContainerCls} { margin-left: 0.5em; margin-right: 0.5em; margin-bottom: 2px; } .${tagCls} { border: 1px solid #3b3b3b; background-color: #282828; /*color: rgba(255, 255, 255, 0.482);*/ /* equivalent of #909090; when on #282828 background */ padding: 0px 8px 0 8px; border-radius: 4px; cursor: pointer; transition: background-color 0.2s, color 0.2s; display: inline-block; color: #D0D0D0; } .${tagCls}.${tagDarkTextCls} { color: #333333; text-shadow: 1px 0 0.5px white, -1px 0 0.5px white, 0 1px 0.5px white, 0 -1px 0.5px white; } .${tagCls} span { position: relative; top: 1.5px; text-shadow: 1px 0 0.5px black, -1px 0 0.5px black, 0 1px 0.5px black, 0 -1px 0.5px black; } .${tagCls}:hover { background-color: #333; color: #fff; } .${tagCls}.${tagDarkTextCls}:hover { /* color: #171717; */ color: #2f2f2f; } .${tagPaletteCls} { display: flex; flex-wrap: wrap; gap: 1px; } .${tagPaletteCls} .${tagPaletteItemCls} { text-shadow: 0 0 4px black; width: 35px; height: 20px; display: inline-block; text-align: center; border-radius: 0.2em; padding: 0 2px; transition: color 0.2s; } .${tagPaletteItemCls}:hover { cursor: pointer; color: white; } .${tagsPreviewCls} { background-color: #191a1a; padding: 0.5em 1em; border-radius: 1em; } .${tagsPreviewNewCls}:before { content: 'Target New: '; } .${tagsPreviewThreadCls}:before { content: 'Target Thread: '; } .${queryBoxCls} { flex-wrap: wrap; } .${controlsAreaCls} { grid-template-columns: repeat(4,minmax(0,1fr)) } .${textAreaCls} { grid-column-end: 5; } .${standardButtonCls} { grid-column-start: 4; } .${roundedMD} { border-radius: 0.375rem!important; } `; const TAG_POSITION = { BEFORE: 'before', AFTER: 'after', CARET: 'caret', }; const TAG_CONTAINER_TYPE = { NEW: 'new', THREAD: 'thread', ALL: 'all', } const tagsHelpText = ` Each line is one tag. Non-field text is what will be inserted into prompt. Field is denoted by \`<\` and \`>\`, field name is before \`:\`, field value after \`:\`. Supported fields: - \`label\`: tag label shown on tag "box" (new items around prompt input area) - \`position\`: where the tag text will be inserted, default is \`before\`; valid values are \`before\`/\`after\` (existing text) or \`caret\` (at cursor position) - \`color\`: tag color; CSS colors supported, you can use colors from a pre-generated palette via \`%\` syntax, e.g. \`\`. See palette bellow. - \`tooltip\`: shown on hover (aka title); (default) tooltip can be disabled when this field is set to empty string - \`\` - \`target\`: where the tag will be inserted, default is \`new\`; valid values are \`new\` (on home page or when clicking on "New Thread" button) / \`thread\` (on thread page) / \`all\` (everywhere) --- Examples: \`\`\` Vintage Story - stable diffusion web ui - , prefer concise modern syntax and style, FFXIV: tell me a joke \`\`\` `.trim(); const defaultTagColor = '#282828'; const changeValueUsingEvent = (selector, value) => { debugLog('changeValueUsingEvent', value, selector); const nativeTextareaValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; nativeTextareaValueSetter.call(selector, value); const inputEvent = new Event('input', {bubbles: true}); selector.dispatchEvent(inputEvent); } const cyanButtonPerplexityColor = '#1fb8cd'; const TAGS_PALETTE_COLORS_NUM = 16; const TAGS_PALETTE = Object.freeze((() => { const step = 360 / TAGS_PALETTE_COLORS_NUM; const [startH, startS, startL, startA] = color2k.parseToHsla(cyanButtonPerplexityColor); return _.flow( _.map(x => startH + x * step, _), _.map(h => color2k.hsla(h, startS, startL, startA), _), _.sortBy(x => color2k.parseToHsla(x)[0], _) )(_.range(0, TAGS_PALETTE_COLORS_NUM)); })()); debugLogTags('TAGS_PALETTE', TAGS_PALETTE); const convertColorInPaletteFormat = value => TAGS_PALETTE[parseInt(dropStr(1)(value), 10)] ?? defaultTagColor const processTagField = name => value => { if (name === 'color' && value.startsWith('%')) return convertColorInPaletteFormat(value); return value; } const tagLineRegex = /<(label|position|color|tooltip|target):([^<>]*)>/g; const parseOneTagLine = (line) => Array.from(line.matchAll(tagLineRegex)).reduce( (acc, match) => { const [fullMatch, field, value] = match; const processedValue = processTagField(field)(value); return { ...acc, [field]: processedValue, text: acc.text.replace(fullMatch, '').replace(/\\n/g, '\n'), }; }, {text: line, color: defaultTagColor, target: TAG_CONTAINER_TYPE.NEW} ); const parseTagsText = text => { const lines = text.split('\n').filter(tag => tag.trim().length > 0); return lines.map(parseOneTagLine); } const getTagsContainer = () => $c(tagsContainerCls); const promptAreaOfNewThreadSelector = 'textarea[placeholder="Ask anything..."]'; const getPromptAreaOfNewThread = () => jq(promptAreaOfNewThreadSelector); const getPromptAreaWrapperOfNewThread = () => getPromptAreaOfNewThread().parent().parent().parent().parent(); const promptAreaOnThreadSelector = 'textarea[placeholder="Ask follow-up"]'; const getPromptAreaOnThread = () => jq(promptAreaOnThreadSelector); const getPromptAreaWrapperOnThread = () => getPromptAreaOnThread().parent().parent().parent().parent(); const anyPromptAreaSelector = `${promptAreaOfNewThreadSelector},${promptAreaOnThreadSelector}`; const posFromTag = tag => Object.values(TAG_POSITION).includes(tag.position) ? tag.position : TAG_POSITION.BEFORE; const applyTagToString = (tag, val, caretPos) => { const {text} = tag; switch (posFromTag(tag)) { case TAG_POSITION.BEFORE: return `${text}${val}`; case TAG_POSITION.AFTER: return `${val}${text}`; case TAG_POSITION.CARET: return `${takeStr(caretPos)(val)}${text}${dropStr(caretPos)(val)}`; default: throw new Error(`Invalid position: ${tag.position}`); } }; const getPromptAreaFromTagsContainer = tagsContainerEl => tagsContainerEl.parent().find(anyPromptAreaSelector); const createTag = containerEl => isPreview => tag => { const labelString = tag.label ?? tag.text; const isTagLight = color2k.getLuminance(tag.color) > 0.35; const colorMod = isTagLight ? color2k.darken : color2k.lighten; const hoverBgColor = color2k.toRgba(colorMod(tag.color, 0.1)); const borderColor = color2k.toRgba(colorMod(tag.color, 0.1)); const clickHandler = evt => { debugLog('clicked', tag, evt); const el = jq(evt.currentTarget); const promptArea = getPromptAreaFromTagsContainer(el.parent()); if (!promptArea.length) { debugLogTags('no prompt area found', promptArea); return; } const promptAreaRaw = promptArea[0]; const newText = applyTagToString(tag, promptArea.val(), promptAreaRaw.selectionStart); changeValueUsingEvent(promptAreaRaw, newText); promptAreaRaw.focus(); }; const tagEl = jq(`
`) .addClass(tagCls) .prop('title', tag.tooltip ?? `${logPrefix} Insert \`${tag.text}\` at position \`${posFromTag(tag)}\``) .attr('data-tag', JSON.stringify(tag)) .click(isPreview ? null : clickHandler) .css({ backgroundColor: tag.color, borderColor, }) .attr('data-color', color2k.toHex(tag.color)) .attr('data-hoverBgColor', color2k.toHex(hoverBgColor)) .on('mouseenter', (event) => { jq(event.currentTarget).css('background-color', hoverBgColor); }) .on('mouseleave', (event) => { jq(event.currentTarget).css('background-color', tag.color); }) ; if (isTagLight) { tagEl.addClass(tagDarkTextCls); } const textEl = jq('') .text(labelString) .css({ // TODO: either move tag text shadows to options or remove styles and this override textShadow: 'none' }) ; tagEl.append(textEl); containerEl.append(tagEl); return tagEl; }; const genDebugFakeTags = () => _.times(TAGS_PALETTE_COLORS_NUM, x => `Fake ${x} ${_.times(x / 3).map(() => 'x').join('')}`) .join('\n'); const getTagContainerType = containerEl => { if (containerEl.hasClass(threadTagContainerCls) || containerEl.hasClass(tagsPreviewThreadCls)) return TAG_CONTAINER_TYPE.THREAD; if (containerEl.hasClass(newTagContainerCls) || containerEl.hasClass(tagsPreviewNewCls)) return TAG_CONTAINER_TYPE.NEW; return null; } const isTagRelevantForContainer = containerType => tag => containerType === tag.target || tag.target === TAG_CONTAINER_TYPE.ALL const refreshTags = () => { const promptWrapper = getPromptAreaWrapperOfNewThread().add(getPromptAreaWrapperOnThread()); if (!promptWrapper.length) { debugLogTags('no prompt area found'); } const allTags = _.flow( x => x + (unsafeWindow.phFakeTags ? `${nl}${genDebugFakeTags()}${nl}` : ''), parseTagsText, )(loadConfig().tagsText ?? defaultConfig.tagsText); debugLogTags('refreshing allTags', allTags); const createContainer = (promptWrapper) => { const el = jq(`
`).addClass(tagsContainerCls); if (promptWrapper.find(promptAreaOnThreadSelector).length) { el.addClass(threadTagContainerCls); } if (promptWrapper.find(promptAreaOfNewThreadSelector).length) { el.addClass(newTagContainerCls); } return el; } promptWrapper.each((_, rEl) => { const el = jq(rEl); if (el.parent().find(`.${tagsContainerCls}`).length) { el.parent().addClass(queryBoxCls); return; } el.before(createContainer(el)); }); const containerEls = getTagsContainer(); containerEls.each((_i, rEl) => { const containerEl = jq(rEl); const isPreview = Boolean(containerEl.attr('data-preview')); const currentTags = containerEl.find(`.${tagCls}`).map((i, el) => JSON.parse(el.dataset.tag)).toArray(); const tagContainerType = getTagContainerType(containerEl); const tagsForThisContainer = allTags.filter(isTagRelevantForContainer(tagContainerType)); debugLogTags('tagContainerType', tagContainerType, 'current tags', currentTags, 'tagsForThisContainer', tagsForThisContainer); if (_.isEqual(currentTags, tagsForThisContainer)) { debugLogTags('no tags changed'); return; } containerEl.empty(); tagsForThisContainer.forEach(createTag(containerEl)(isPreview)); }); } const setupTags = () => { debugLog('setting up tags'); setInterval(refreshTags, 500); } const defaultConfig = Object.freeze({ showCopilot: true, showCopilotNewThread: true, showCopilotRepeatLast: true, showCopilotCopyPlaceholder: true, tagsText: '', debugMode: false, }); // TODO: if still using local storage, at least it should be prefixed with user script name const storageKey = 'checkBoxStates'; const loadConfig = () => { try { // TODO: use storage from GM API const val = JSON.parse(localStorage.getItem(storageKey)); // debugLog('loaded config', val); return val; } catch (e) { console.error('Failed to load config, using default', e); return defaultConfig; } } const loadConfigOrDefault = () => loadConfig() ?? defaultConfig const saveConfig = cfg => { debugLog('saving config', cfg); localStorage.setItem(storageKey, JSON.stringify(cfg)); }; const createCheckbox = (id, labelText, onChange) => { debugLog("createCheckbox", id); const checkbox = jq(``); const label = jq(``); const checkboxWithLabel = jq('
').append(label).append(checkbox); debugLog('checkboxwithlabel', checkboxWithLabel); getSettingsModalContent().append(checkboxWithLabel); checkbox.on('change', onChange); return checkbox; }; const createTextArea = (id, labelText, onChange, helpText) => { debugLog("createTextArea", id); const textarea = jq(``); const label = jq(``); const textareaWithLabel = jq('
').append(label); if (helpText) { const help = jq(`
`).addClass(helpTextCls).html(markdownConverter.makeHtml(helpText)).append(jq('
')); help.append(jq('