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' };
}
}
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;
try {
const { puzzleId, genreId, message, playerIdentifier } = await request.json();
const { puzzleId, genreId, message, playerIdentifier, originalMessage } = await request.json();
// Validate required fields
if (!puzzleId || !message || !playerIdentifier) {
@@ -28,9 +28,9 @@ export async function POST(request: NextRequest) {
{ status: 400 }
);
}
if (trimmedMessage.length > 2000) {
if (trimmedMessage.length > 300) {
return NextResponse.json(
{ error: 'Message too long. Maximum 2000 characters allowed.' },
{ error: 'Message too long. Maximum 300 characters allowed.' },
{ status: 400 }
);
}
@@ -170,6 +170,19 @@ export async function POST(request: NextRequest) {
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({
success: true,
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 [commentSent, setCommentSent] = useState(false);
const [commentError, setCommentError] = useState<string | null>(null);
const [commentCollapsed, setCommentCollapsed] = useState(true);
const [rewrittenMessage, setRewrittenMessage] = useState<string | null>(null);
useEffect(() => {
const updateCountdown = () => {
@@ -321,6 +323,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
setCommentSending(true);
setCommentError(null);
setRewrittenMessage(null);
try {
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');
}
// 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
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({
puzzleId: dailyPuzzle.id,
genreId: genreId,
message: commentText.trim(),
message: finalMessage,
originalMessage: commentText.trim() !== finalMessage ? commentText.trim() : undefined,
playerIdentifier: playerIdentifier
})
});
@@ -602,9 +626,24 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
{/* Comment Form */}
{!commentSent && (
<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')}
</h3>
<span>{commentCollapsed ? '▼' : '▲'}</span>
</div>
{!commentCollapsed && (
<>
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.75rem' }}>
{t('commentHelp')}
</p>
@@ -612,7 +651,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder={t('commentPlaceholder')}
maxLength={2000}
maxLength={300}
style={{
width: '100%',
minHeight: '100px',
@@ -622,13 +661,14 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
fontSize: '0.9rem',
fontFamily: 'inherit',
resize: 'vertical',
marginBottom: '0.5rem'
marginBottom: '0.5rem',
display: 'block' // Ensure block display for proper alignment
}}
disabled={commentSending}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--muted-foreground)' }}>
{commentText.length}/2000
{commentText.length}/300
</span>
{commentError && (
<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')}
</button>
</>
)}
</div>
)}
{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)' }}>
<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')}
</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>
)}

View File

@@ -58,9 +58,11 @@
"skipBonus": "Bonus überspringen",
"notQuite": "Nicht ganz!",
"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.",
"commentSent": "✓ Nachricht gesendet! Vielen Dank für dein Feedback.",
"commentRewritten": "Deine Nachricht wurde automatisch freundlicher formuliert:",
"commentError": "Fehler beim Senden der Nachricht",
"commentRateLimited": "Du hast bereits eine Nachricht für dieses Rätsel gesendet.",
"sending": "Wird gesendet...",

View File

@@ -58,9 +58,11 @@
"skipBonus": "Skip Bonus",
"notQuite": "Not quite!",
"sendComment": "Send message to curator",
"commentPlaceholder": "Write a message to the curators of this genre...",
"commentHelp": "Share your thoughts about the puzzle with the curators. Your message will be displayed to them.",
"sendCommentCollapsed": "Send message to curator",
"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.",
"commentRewritten": "Your message was automatically rephrased to be more friendly:",
"commentError": "Error sending message",
"commentRateLimited": "You have already sent a message for this puzzle.",
"sending": "Sending...",