From 7db4e26b2c93fe4b7785d13563502845bd1ce00d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Thu, 4 Dec 2025 08:54:25 +0100 Subject: [PATCH] feat: Implement AI-powered comment rewriting and a collapsible comment form for user feedback. --- app/actions.ts | 27 ++ app/api/curator-comment/route.ts | 21 +- app/api/rewrite-message/route.ts | 73 ++++ components/Game.tsx | 146 ++++--- messages/de.json | 718 ++++++++++++++++--------------- messages/en.json | 716 +++++++++++++++--------------- 6 files changed, 933 insertions(+), 768 deletions(-) create mode 100644 app/api/rewrite-message/route.ts diff --git a/app/actions.ts b/app/actions.ts index 605b8af..ed24d5e 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -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); + } +} diff --git a/app/api/curator-comment/route.ts b/app/api/curator-comment/route.ts index 9bc9a47..eb11c46 100644 --- a/app/api/curator-comment/route.ts +++ b/app/api/curator-comment/route.ts @@ -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,13 +170,26 @@ 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 }); } catch (error) { console.error('Error creating curator comment:', error); - + // Handle unique constraint violation (shouldn't happen due to our check, but just in case) if (error instanceof Error && error.message.includes('Unique constraint')) { return NextResponse.json( diff --git a/app/api/rewrite-message/route.ts b/app/api/rewrite-message/route.ts new file mode 100644 index 0000000..41e7246 --- /dev/null +++ b/app/api/rewrite-message/route.ts @@ -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 } + ); + } +} diff --git a/components/Game.tsx b/components/Game.tsx index 67298e3..a96c8a8 100644 --- a/components/Game.tsx +++ b/components/Game.tsx @@ -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(null); + const [commentCollapsed, setCommentCollapsed] = useState(true); + const [rewrittenMessage, setRewrittenMessage] = useState(null); useEffect(() => { const updateCountdown = () => { @@ -139,7 +141,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max } else { setHasRated(false); } - + // Check if comment already sent for this puzzle const playerIdentifier = getOrCreatePlayerId(); if (playerIdentifier) { @@ -220,7 +222,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max const handleSkip = () => { // Prevent skipping if already solved or failed if (gameState?.isSolved || gameState?.isFailed) return; - + // If user hasn't played audio yet on first attempt, start it instead of skipping if (gameState.guesses.length === 0 && !hasPlayedAudio) { handleStartAudio(); @@ -251,7 +253,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max const handleGiveUp = () => { // Prevent giving up if already solved or failed if (gameState?.isSolved || gameState?.isFailed) return; - + setLastAction('SKIP'); addGuess("SKIPPED", false); giveUp(); // Ensure game is marked as failed and score reset to 0 @@ -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 }) }); @@ -352,7 +376,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max setCommentSent(true); setCommentText(''); - + // Store in localStorage that comment was sent const commentedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_commented_puzzles`) || '[]'); if (!commentedPuzzles.includes(dailyPuzzle.id)) { @@ -602,60 +626,84 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max {/* Comment Form */} {!commentSent && (
-

- {t('sendComment')} -

-

- {t('commentHelp')} -

-