Add a button to copy a hand's situation as an AI prompt

Reviews and replays get a "copy for AI" button that builds a text
describing the hand (stacks, positions, actions, pots, board, revealed
cards, result and the site's own judgments) plus questions, ready to
paste into an AI chat. Hidden opponent cards are left out so the
evaluation is based only on what Hero could see.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
agomizi1cmandClaude Opus 5.5 committed 2026-10-20 23:51:41 +09:00
1 parent 3ddb0996e0
commit d5ef2d310f
10 files changed
+294 -3

No files matched your search

+45 -1
View File
@@ -139,6 +139,50 @@
return data;
}
// 文章をクリップボードにコピーする。load は文章を返す Promise を作る関数。
// サーバから取ってくる間にボタンを押した扱いが切れる(iOS Safari など)ので、まず ClipboardItem に
// Promise のまま渡し、だめなら writeText、それもだめなら文章を選択した状態で表示して手でコピーしてもらう
async function copyText(load) {
const pending = load();
try {
if (window.ClipboardItem && navigator.clipboard && navigator.clipboard.write) {
const blob = pending.then((t) => new Blob([t], { type: 'text/plain' }));
await navigator.clipboard.write([new ClipboardItem({ 'text/plain': blob })]);
return true;
}
} catch { /* 次の方法へ */ }
const text = await pending; // 文章を取れなかったときはここで呼び出し元へエラーを返す
try {
await navigator.clipboard.writeText(text);
return true;
} catch { /* 手でコピーしてもらう */ }
showCopyDialog(text);
return false;
}
function showCopyDialog(text) {
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop confirm-backdrop';
backdrop.innerHTML =
'<div class="modal copy-modal" role="dialog" aria-modal="true">' +
'<h2 class="confirm-title">コピーしてください</h2>' +
'<p class="hint">自動でコピーできませんでした。下の文章を全選択してコピーしてください。</p>' +
'<textarea class="copy-text" readonly></textarea>' +
'<button type="button" class="btn btn-dark copy-close">閉じる</button>' +
'</div>';
const area = backdrop.querySelector('textarea');
area.value = text;
const close = () => backdrop.remove();
backdrop.querySelector('.copy-close').addEventListener('click', close);
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) close();
});
document.body.appendChild(backdrop);
area.focus();
area.select();
area.scrollTop = 0; // select() で末尾までスクロールするので先頭に戻す
}
// サーバは WebSocket のみ受け付ける。接続を拒否されたら鍵が無効になっていないか確認する
function connectSocket() {
const socket = io({ transports: ['websocket'] });
@@ -167,5 +211,5 @@
FINISHING: 'FINISHING',
};
window.App = { Store, esc, chips, toast, confirm: confirmDialog, api, connectSocket, safeNext, PHASE_LABELS };
window.App = { Store, esc, chips, toast, confirm: confirmDialog, api, copyText, connectSocket, safeNext, PHASE_LABELS };
})();
+4
View File
@@ -534,6 +534,9 @@ body.table-page {
.confirm-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 20px; }
.confirm-buttons .btn { padding: 12px 8px; font-size: 16px; white-space: normal; }
.confirm-buttons .btn:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
/* 自動でコピーできなかったときに文章を見せるダイアログ(App.copyText) */
.copy-modal { max-width: 520px; display: flex; flex-direction: column; gap: 10px; max-height: calc(100dvh - 32px); }
.copy-text { width: 100%; min-height: 240px; flex: 1; resize: vertical; padding: 10px; font: 12px/1.5 ui-monospace, monospace; color: inherit; background: rgba(0, 0, 0, 0.3); border: 1px solid var(--line); border-radius: 8px; }
@keyframes confirm-fade { from { opacity: 0; } }
@keyframes confirm-pop { from { opacity: 0; transform: scale(0.94); } }
@media (prefers-reduced-motion: reduce) {
@@ -812,6 +815,7 @@ label.switch { margin: 0; display: inline-block; }
.review-modal { max-width: 440px; max-height: calc(100dvh - 32px); overflow-y: auto; overscroll-behavior: contain; }
.review-buttons { display: grid; grid-template-columns: 1fr 2fr; gap: 8px; margin-top: 16px; }
.review-buttons:has(> [hidden]) { grid-template-columns: 1fr; }
.review-copy { width: 100%; margin-top: 8px; }
.review-link { display: block; margin-top: 12px; font-size: 13px; text-align: center; }
.rv-head { display: flex; align-items: center; gap: 12px; margin-top: 10px; }
.rv-cards { display: flex; gap: 3px; }
+12
View File
@@ -475,6 +475,17 @@
}
}
// 直前のハンドの状況を、AI に貼り付けて質問できる文章にしてコピーする
async function copyHandPrompt(r) {
if (!r || !r.sessionId || !r.last) return;
const url = `/api/reviews/${encodeURIComponent(r.sessionId)}/hands/${r.last.number}/prompt`;
try {
if (await App.copyText(() => App.api('GET', url).then((d) => d.text))) toast('コピーしました。AI のチャットに貼り付けて質問できます');
} catch (err) {
toast(err.message);
}
}
const openReview = () => { $('review-modal').hidden = false; };
$('review-open').addEventListener('click', openReview);
$('review-show').addEventListener('click', openReview);
@@ -486,6 +497,7 @@
$('review-modal').hidden = true;
emit('table:next');
};
$('review-copy').addEventListener('click', () => copyHandPrompt(state && state.review));
$('review-next').addEventListener('click', nextHand);
$('review-modal-next').addEventListener('click', nextHand);
+1
View File
@@ -119,6 +119,7 @@
<button type="button" class="btn btn-dark" id="review-close">閉じる</button>
<button type="button" class="btn btn-orange" id="review-modal-next">次のハンドへ</button>
</div>
<button type="button" class="btn btn-dark review-copy" id="review-copy">🤖 AIに質問する用にコピー</button>
<a class="link-gold review-link" id="review-link" href="/review" target="_blank" rel="noopener">対局全体のレビュー(点数表・成績・リプレイ)→</a>
</div>
</div>