TL;DR
こちらの記事に大変感銘を受けて、Redashで似たことをやってみました。
エディタ上でタイプすると、イルカがでてきます。
コード
普段、Pythonを書いていてJavaScriptにはあまり知見がなく、私が改変したところはわりと雑です。TampermonkeyとVisual Studio Codeを繋いで、生成AIに色々やってもらったりもしました。
Redashはv10とv8を使っているので、両方に対応しています。最新のv25.1.0で動くかは試せてません。使うときは@match https://example.com/*
の部分をRedashのURLに書き換えてください。
// ==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のサポートはされなさそうな雰囲気を感じています。
OpenAIとかGeminiとかNotebookLMとかにコピペしてクエリ書くのでいいんだけどコピペちょっと面倒だし、MCPもいいけど構築はわりと大変そうだし、Redash以外もあるかもしれないけど金かかることが多いし、みたいな気持ちがあり、Redashでデータ分析をサポートするようなAI、PoCライクに、ちょっと試しに作ってみたいなと思っていたところに、前述の記事を読みました。ああ、私が求めていたのはイルカではないだろうか。
軽く使ってみた感想としては、やっぱりプロンプトを作り込まないと厳しそうだな、という印象です。無理に講評しているようにも感じていて、「なかったらなかったでいいよ」とか「初学者の学習になるように」とか「もっと褒めて」とかプロンプトに書いたほうが良いかもしれません。あとはやはり、他のRedashクエリを参照したりとかメタデータを入れたりとかそもそも接続先DBの種類とか、いろいろ外部知識が必要そうです。とはいえ、今後の可能性や「あのイルカを育てられる」という感覚を得ました。