feat: Implement AI-powered comment rewriting and a collapsible comment form for user feedback.

This commit is contained in:
Hördle Bot
2025-12-04 08:54:25 +01:00
parent b204a35628
commit 7db4e26b2c
6 changed files with 933 additions and 768 deletions

View File

@@ -80,3 +80,30 @@ export async function submitRating(songId: number, rating: number, genre?: strin
return { success: false, error: 'Failed to submit rating' }; return { success: false, error: 'Failed to submit rating' };
} }
} }
export async function sendCommentNotification(puzzleId: number, message: string, originalMessage?: string, genre?: string | null) {
try {
const title = `New Curator Comment (Puzzle #${puzzleId})`;
let body = message;
if (originalMessage && originalMessage !== message) {
body = `Original: ${originalMessage}\n\nRewritten: ${message}`;
}
if (genre) {
body = `[${genre}] ${body}`;
}
await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title,
message: body,
priority: 5,
}),
});
} catch (error) {
console.error('Error sending comment notification:', error);
}
}

View File

@@ -10,7 +10,7 @@ export async function POST(request: NextRequest) {
if (rateLimitError) return rateLimitError; if (rateLimitError) return rateLimitError;
try { try {
const { puzzleId, genreId, message, playerIdentifier } = await request.json(); const { puzzleId, genreId, message, playerIdentifier, originalMessage } = await request.json();
// Validate required fields // Validate required fields
if (!puzzleId || !message || !playerIdentifier) { if (!puzzleId || !message || !playerIdentifier) {
@@ -28,9 +28,9 @@ export async function POST(request: NextRequest) {
{ status: 400 } { status: 400 }
); );
} }
if (trimmedMessage.length > 2000) { if (trimmedMessage.length > 300) {
return NextResponse.json( return NextResponse.json(
{ error: 'Message too long. Maximum 2000 characters allowed.' }, { error: 'Message too long. Maximum 300 characters allowed.' },
{ status: 400 } { status: 400 }
); );
} }
@@ -170,6 +170,19 @@ export async function POST(request: NextRequest) {
return comment; return comment;
}); });
// Send Gotify notification (fire and forget)
const { sendCommentNotification } = await import('@/app/actions');
// originalMessage is already available from the initial request.json() call
// Determine genre name for notification
let genreName: string | null = null;
if (finalGenreId) {
const genreObj = await prisma.genre.findUnique({ where: { id: finalGenreId } });
if (genreObj) genreName = genreObj.name as string;
}
sendCommentNotification(Number(puzzleId), trimmedMessage, originalMessage, genreName || null);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
commentId: result.id commentId: result.id

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server';
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
const OPENROUTER_MODEL = 'anthropic/claude-3.5-haiku';
export async function POST(request: NextRequest) {
try {
const { message } = await request.json();
if (!message || typeof message !== 'string') {
return NextResponse.json(
{ error: 'Message is required and must be a string' },
{ status: 400 }
);
}
if (!OPENROUTER_API_KEY) {
console.error('OPENROUTER_API_KEY is not configured');
// Fallback: return original message if API key is missing
return NextResponse.json({ rewrittenMessage: message });
}
const prompt = `Rewrite the following message to express the COMPLETE OPPOSITE meaning and opinion.
If the message is negative or critical, rewrite it to be overwhelmingly positive, praising, and appreciative.
If the message is positive, rewrite it to be critical or negative.
Maintain the original language (German or English).
Return ONLY the rewritten message text, nothing else.
Message: "${message}"`;
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://hoerdle.elpatron.me',
'X-Title': 'Hördle Message Rewriter'
},
body: JSON.stringify({
model: OPENROUTER_MODEL,
messages: [
{
role: 'user',
content: prompt
}
],
temperature: 0.7,
max_tokens: 500
})
});
if (!response.ok) {
console.error('OpenRouter API error:', await response.text());
// Fallback: return original message
return NextResponse.json({ rewrittenMessage: message });
}
const data = await response.json();
let rewrittenMessage = data.choices?.[0]?.message?.content?.trim() || message;
// Add suffix
rewrittenMessage += " (autocorrected by Polite-Bot)";
return NextResponse.json({ rewrittenMessage });
} catch (error) {
console.error('Error rewriting message:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -65,6 +65,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const [commentSending, setCommentSending] = useState(false); const [commentSending, setCommentSending] = useState(false);
const [commentSent, setCommentSent] = useState(false); const [commentSent, setCommentSent] = useState(false);
const [commentError, setCommentError] = useState<string | null>(null); const [commentError, setCommentError] = useState<string | null>(null);
const [commentCollapsed, setCommentCollapsed] = useState(true);
const [rewrittenMessage, setRewrittenMessage] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const updateCountdown = () => { const updateCountdown = () => {
@@ -321,6 +323,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
setCommentSending(true); setCommentSending(true);
setCommentError(null); setCommentError(null);
setRewrittenMessage(null);
try { try {
const playerIdentifier = getOrCreatePlayerId(); const playerIdentifier = getOrCreatePlayerId();
@@ -328,6 +331,26 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
throw new Error('Could not get player identifier'); throw new Error('Could not get player identifier');
} }
// 1. Rewrite message using AI
const rewriteResponse = await fetch('/api/rewrite-message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: commentText.trim() })
});
let finalMessage = commentText.trim();
if (rewriteResponse.ok) {
const rewriteData = await rewriteResponse.json();
if (rewriteData.rewrittenMessage) {
finalMessage = rewriteData.rewrittenMessage;
// If message was changed significantly (simple check), show it
if (finalMessage !== commentText.trim()) {
setRewrittenMessage(finalMessage);
}
}
}
// 2. Send comment
// For specials, genreId should be null. For global, also null. For genres, we pass null and let API determine from puzzle // For specials, genreId should be null. For global, also null. For genres, we pass null and let API determine from puzzle
const genreId = isSpecial ? null : null; // API will determine from puzzle const genreId = isSpecial ? null : null; // API will determine from puzzle
@@ -339,7 +362,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
body: JSON.stringify({ body: JSON.stringify({
puzzleId: dailyPuzzle.id, puzzleId: dailyPuzzle.id,
genreId: genreId, genreId: genreId,
message: commentText.trim(), message: finalMessage,
originalMessage: commentText.trim() !== finalMessage ? commentText.trim() : undefined,
playerIdentifier: playerIdentifier playerIdentifier: playerIdentifier
}) })
}); });
@@ -602,9 +626,24 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
{/* Comment Form */} {/* Comment Form */}
{!commentSent && ( {!commentSent && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem' }}> <div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 'bold', marginBottom: '0.5rem' }}> <div
onClick={() => setCommentCollapsed(!commentCollapsed)}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
marginBottom: commentCollapsed ? 0 : '1rem'
}}
>
<h3 style={{ fontSize: '1rem', fontWeight: 'bold', margin: 0 }}>
{t('sendComment')} {t('sendComment')}
</h3> </h3>
<span>{commentCollapsed ? '▼' : '▲'}</span>
</div>
{!commentCollapsed && (
<>
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.75rem' }}> <p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.75rem' }}>
{t('commentHelp')} {t('commentHelp')}
</p> </p>
@@ -612,7 +651,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
value={commentText} value={commentText}
onChange={(e) => setCommentText(e.target.value)} onChange={(e) => setCommentText(e.target.value)}
placeholder={t('commentPlaceholder')} placeholder={t('commentPlaceholder')}
maxLength={2000} maxLength={300}
style={{ style={{
width: '100%', width: '100%',
minHeight: '100px', minHeight: '100px',
@@ -622,13 +661,14 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
fontSize: '0.9rem', fontSize: '0.9rem',
fontFamily: 'inherit', fontFamily: 'inherit',
resize: 'vertical', resize: 'vertical',
marginBottom: '0.5rem' marginBottom: '0.5rem',
display: 'block' // Ensure block display for proper alignment
}} }}
disabled={commentSending} disabled={commentSending}
/> />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--muted-foreground)' }}> <span style={{ fontSize: '0.75rem', color: 'var(--muted-foreground)' }}>
{commentText.length}/2000 {commentText.length}/300
</span> </span>
{commentError && ( {commentError && (
<span style={{ fontSize: '0.75rem', color: 'var(--danger)' }}> <span style={{ fontSize: '0.75rem', color: 'var(--danger)' }}>
@@ -648,14 +688,22 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
> >
{commentSending ? t('sending') : t('sendComment')} {commentSending ? t('sending') : t('sendComment')}
</button> </button>
</>
)}
</div> </div>
)} )}
{commentSent && ( {commentSent && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(16, 185, 129, 0.1)', borderRadius: '0.5rem', border: '1px solid rgba(16, 185, 129, 0.3)' }}> <div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(16, 185, 129, 0.1)', borderRadius: '0.5rem', border: '1px solid rgba(16, 185, 129, 0.3)' }}>
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center' }}> <p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center', marginBottom: rewrittenMessage ? '0.5rem' : 0 }}>
{t('commentSent')} {t('commentSent')}
</p> </p>
{rewrittenMessage && (
<div style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', textAlign: 'center' }}>
<p style={{ marginBottom: '0.25rem' }}>{t('commentRewritten')}</p>
<p style={{ fontStyle: 'italic' }}>"{rewrittenMessage}"</p>
</div>
)}
</div> </div>
)} )}

View File

@@ -58,9 +58,11 @@
"skipBonus": "Bonus überspringen", "skipBonus": "Bonus überspringen",
"notQuite": "Nicht ganz!", "notQuite": "Nicht ganz!",
"sendComment": "Nachricht an Kurator senden", "sendComment": "Nachricht an Kurator senden",
"commentPlaceholder": "Schreibe eine Nachricht an die Kuratoren dieses Genres...", "sendCommentCollapsed": "Nachricht an Kurator senden",
"commentPlaceholder": "Schreibe eine Nachricht an die Kuratoren dieses Genres... Bitte bleibe freundlich und höflich.",
"commentHelp": "Teile deine Gedanken zum Rätsel mit den Kuratoren. Deine Nachricht wird ihnen angezeigt.", "commentHelp": "Teile deine Gedanken zum Rätsel mit den Kuratoren. Deine Nachricht wird ihnen angezeigt.",
"commentSent": "✓ Nachricht gesendet! Vielen Dank für dein Feedback.", "commentSent": "✓ Nachricht gesendet! Vielen Dank für dein Feedback.",
"commentRewritten": "Deine Nachricht wurde automatisch freundlicher formuliert:",
"commentError": "Fehler beim Senden der Nachricht", "commentError": "Fehler beim Senden der Nachricht",
"commentRateLimited": "Du hast bereits eine Nachricht für dieses Rätsel gesendet.", "commentRateLimited": "Du hast bereits eine Nachricht für dieses Rätsel gesendet.",
"sending": "Wird gesendet...", "sending": "Wird gesendet...",

View File

@@ -58,9 +58,11 @@
"skipBonus": "Skip Bonus", "skipBonus": "Skip Bonus",
"notQuite": "Not quite!", "notQuite": "Not quite!",
"sendComment": "Send message to curator", "sendComment": "Send message to curator",
"commentPlaceholder": "Write a message to the curators of this genre...", "sendCommentCollapsed": "Send message to curator",
"commentHelp": "Share your thoughts about the puzzle with the curators. Your message will be displayed to them.", "commentPlaceholder": "Write a message to the curators of this genre... Please remain friendly and polite.",
"commentHelp": "Share your thoughts on the puzzle with the curators. Your message will be shown to them.",
"commentSent": "✓ Message sent! Thank you for your feedback.", "commentSent": "✓ Message sent! Thank you for your feedback.",
"commentRewritten": "Your message was automatically rephrased to be more friendly:",
"commentError": "Error sending message", "commentError": "Error sending message",
"commentRateLimited": "You have already sent a message for this puzzle.", "commentRateLimited": "You have already sent a message for this puzzle.",
"sending": "Sending...", "sending": "Sending...",