Redashの編集画面にイルカが出てきてSQLのアドバイスをくれるuser.js

## TL;DR

こちらの記事に大変感銘を受けて、Redashで似たことをやってみました。

https://blog.sushi.money/entry/2025/04/03/111421

エディタ上でタイプすると、イルカがでてきます。

![実行例](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28360/bd254c8b-8d3c-4a6e-be5f-bc7febd60c52.png)

## コード

普段、Pythonを書いていてJavaScriptにはあまり知見がなく、私が改変したところはわりと雑です。TampermonkeyとVisual Studio Codeを繋いで、生成AIに色々やってもらったりもしました。

Redashはv10とv8を使っているので、両方に対応しています。最新のv25.1.0で動くかは試せてません。使うときは`@match https://example.com/*` の部分をRedashのURLに書き換えてください。

```javascript
// ==UserScript==
// @name         SQL Dolphin for Redash
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  redashでChatGPTが本文を批評してくれるイルカ feat. https://blog.sushi.money/entry/2025/04/03/111421
// @match        https://example.com/*
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @connect      api.openai.com
// ==/UserScript==

(function () {
    'use strict';

    if (window.top !== window.self) return;

    let dolphinContainer = null;
    let lastContent = '';
    let stableTimer = null;

    const FIXED_PROMPT = '次のSQLの内容を端的に批評してください。応答は最大300文字くらいにしてください。';
    const DEFAULT_SUFFIX = '他に書くべき内容なども提案してください。';

    GM_registerMenuCommand('📝 追加プロンプトを編集', async () => {
        const current = await GM_getValue('customPromptSuffix', DEFAULT_SUFFIX);
        const input = prompt('ChatGPTへの追加指示を入力してください：', current);
        if (input !== null) {
            await GM_setValue('customPromptSuffix', input);
            alert('追加プロンプトを更新しました。');
        }
    });

    // URLの変更を監視（SPAのページ遷移対応）
    let lastUrl = location.href;
    new MutationObserver(() => {
        const url = location.href;
        if (url !== lastUrl) {
            lastUrl = url;
            if (url.includes('/queries/')) {
                initDolphin(); // URL変更時に再初期化
            }
        }
    }).observe(document, {subtree: true, childList: true});

    if (location.href.includes('/queries/')) {
        initDolphin(); // 初回読み込み時に初期化
    }

    async function initDolphin() {
        console.log('initDolphin');

        let apiKey = await GM_getValue('apiKey');
        if (!apiKey) {
            apiKey = prompt('OpenAI APIキーを入力してください（次回以降保存されます）');
            if (!apiKey) {
                showDolphinMessage('APIキーが入力されていません。');
                return;
            }
            await GM_setValue('apiKey', apiKey);
        }

        // エディタ監視を設定
        setupEditorWatchers(apiKey);
        showDolphinMessage('queryのページ待ち');
    };

    function setupEditorWatchers(apiKey) {
        // v10
        waitForElm('#ace-editor').then(() => {
            document.querySelector('#ace-editor').addEventListener('keydown', () => {
                const value = ace.edit("ace-editor").getValue();
                triggerStableCheck(() => value, apiKey);
            });
            showDolphinMessage('監視中 v10…');
        });

        // v8
        waitForElm('#brace-editor').then(() => {
            document.querySelector('#brace-editor').addEventListener('keydown', () => {
                const value = ace.edit("brace-editor").getValue();
                triggerStableCheck(() => value, apiKey);
            });
            showDolphinMessage('監視中 v8…');
        });
    }

    // https://stackoverflow.com/questions/5525071/how-to-wait-until-an-element-exists
    function waitForElm(selector) {
        return new Promise(resolve => {
            if (document.querySelector(selector)) {
                return resolve(document.querySelector(selector));
            }

            const observer = new MutationObserver(mutations => {
                if (document.querySelector(selector)) {
                    observer.disconnect();
                    resolve(document.querySelector(selector));
                }
            });

            // If you get "parameter 1 is not of type 'Node'" error, see https://stackoverflow.com/a/77855838/492336
            observer.observe(document.body, {
                childList: true,
                subtree: true
            });
        });
    }

    function triggerStableCheck(getContentFn, apiKey) {
        const current = getContentFn();
        if (current === lastContent){
            return;
        }
        lastContent = current;
        if (stableTimer) clearTimeout(stableTimer);
        stableTimer = setTimeout(() => {
            runCritique(current, apiKey);
        }, 5000);
    }

    async function runCritique(content, apiKey) {
        showDolphinMessage('…');
        const suffix = await GM_getValue('customPromptSuffix', DEFAULT_SUFFIX);
        const prompt = `${FIXED_PROMPT}\n${suffix}\n\n${content}`;

        console.log('Prompt:', prompt);
        GM_xmlhttpRequest({
            method: 'POST',
            url: 'https://api.openai.com/v1/chat/completions',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': `Bearer ${apiKey}`
            },
            data: JSON.stringify({
                model: 'gpt-4o-mini',
                messages: [{ role: 'user', content: prompt }]
            }),
            onload: function (response) {
                const res = JSON.parse(response.responseText);
                const content = res.choices?.[0]?.message?.content ?? 'No response';
                showDolphinMessage(content);
            },
            onerror: function (err) {
                showDolphinMessage('エラー: ' + err.message);
            }
        });
    }

    function showDolphinMessage(msg) {
        if (!dolphinContainer) {
            dolphinContainer = document.createElement('div');
            dolphinContainer.style.position = 'fixed';
            dolphinContainer.style.bottom = '20px';
            dolphinContainer.style.right = '20px';
            dolphinContainer.style.zIndex = 9999;
            dolphinContainer.style.display = 'flex';
            dolphinContainer.style.alignItems = 'flex-end';
            dolphinContainer.className = 'tamper-dolphin-bubble';
            dolphinContainer.style.cursor = 'move';

            const dolphin = document.createElement('div');
            dolphin.textContent = '🐬';
            dolphin.style.fontSize = '2.5em';
            dolphin.style.marginRight = '0.5em';
            dolphin.style.textShadow = '2px 2px 4px rgba(0,0,0,0.3)';

            const bubble = document.createElement('div');
            bubble.className = 'bubble';
            bubble.style.background = '#e0f7fa';
            bubble.style.border = '1px solid #26c6da';
            bubble.style.borderRadius = '10px';
            bubble.style.padding = '0.8em';
            bubble.style.maxWidth = '300px';
            bubble.style.maxHeight = '300px';
            bubble.style.overflow = 'auto';
            bubble.style.boxShadow = '2px 2px 10px rgba(0,0,0,0.2)';
            bubble.style.whiteSpace = 'pre-wrap';
            bubble.style.wordBreak = 'break-word';

            dolphinContainer.appendChild(dolphin);
            dolphinContainer.appendChild(bubble);
            document.body.appendChild(dolphinContainer);

            makeDraggable(dolphinContainer);
        }

        const bubble = dolphinContainer.querySelector('.bubble');
        bubble.textContent = msg;
    }

    function makeDraggable(el) {
        let isDragging = false;
        let startX, startY, initialLeft, initialTop;

        el.addEventListener('mousedown', (e) => {
            isDragging = true;
            startX = e.clientX;
            startY = e.clientY;
            const rect = el.getBoundingClientRect();
            initialLeft = rect.left;
            initialTop = rect.top;
            e.preventDefault();
        });

        document.addEventListener('mousemove', (e) => {
            if (!isDragging) return;
            const dx = e.clientX - startX;
            const dy = e.clientY - startY;
            el.style.left = `${initialLeft + dx}px`;
            el.style.top = `${initialTop + dy}px`;
            el.style.bottom = 'auto';
            el.style.right = 'auto';
        });

        document.addEventListener('mouseup', () => {
            isDragging = false;
        });
    }
})();
```

## 余談

Redash、公式にはLLMのサポートはされなさそうな雰囲気を感じています。

https://github.com/getredash/redash/discussions/6733

OpenAIとかGeminiとかNotebookLMとかにコピペしてクエリ書くのでいいんだけどコピペちょっと面倒だし、MCPもいいけど構築はわりと大変そうだし、Redash以外もあるかもしれないけど金かかることが多いし、みたいな気持ちがあり、Redashでデータ分析をサポートするようなAI、PoCライクに、ちょっと試しに作ってみたいなと思っていたところに、前述の記事を読みました。ああ、私が求めていたのはイルカではないだろうか。

軽く使ってみた感想としては、やっぱりプロンプトを作り込まないと厳しそうだな、という印象です。無理に講評しているようにも感じていて、「なかったらなかったでいいよ」とか「初学者の学習になるように」とか「もっと褒めて」とかプロンプトに書いたほうが良いかもしれません。あとはやはり、他のRedashクエリを参照したりとかメタデータを入れたりとかそもそも接続先DBの種類とか、いろいろ外部知識が必要そうです。とはいえ、今後の可能性や「あのイルカを育てられる」という感覚を得ました。