Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 54 additions & 16 deletions src/components/chatbot/Chatbot.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { useEffect, useState, useRef, useMemo, useContext } from "react";
import {
useEffect,
useState,
useRef,
useMemo,
useContext,
type KeyboardEvent,
} from "react";
import { FiSend } from "react-icons/fi";
import { Button, Textarea } from "@chakra-ui/react";
import { nanoid } from "nanoid";
Expand All @@ -21,22 +28,31 @@ interface ChatTabProps {
chatReady?: boolean;
}

/**
* Keep the locator-excerpt convention identical across both stages so users
* learn one predictable way to distinguish a suggested word from its context.
* Stage addenda change the assistant's goal, not its output format.
*/
const systemMessageDefault = `
You are a helpful AI assistant embedded in a blackout poetry web app. You are helping the user create a blackout poem from a fixed passage.

Blackout poetry: the poet starts with an existing passage and creates a poem by selecting some of its words. Rules in this app: every word of the poem must come from the passage, and words must be used in the order they appear in the passage.

Grounding:
- Work only with the passage provided below. Never reference or substitute any other text.
- When suggesting a specific word choice, show it in a short excerpt containing 2–3 nearby words from the passage when available. Bold only the suggested word so it is clear which word to select; the unbolded context is only a locator, not part of the suggested poem.
- Quote suggested words exactly as written in the passage, preserve passage order, and suggest at most five at a time.
- Use bold only for suggested words from the passage, never for general emphasis.
- When you point to a specific passage word, show it in a short excerpt containing two or three nearby passage words in total when available. Bold only the word you are pointing to. The unbolded words are only a locator to help the user find it; they are not part of the suggestion.
- Quote passage words exactly as written, keep multiple suggested words in passage order, and point to at most five words in a single response.
- Use bold only for passage words you are pointing to, never for general emphasis.
- Never suggest a word that does not appear in the passage.

Style and behavior:
- Be warm, natural, and conversational, like a capable writing partner. Avoid ungrounded or sycophantic flattery.
- The user is working under time pressure. Keep responses under 80 words unless the user asks for more. Use plain prose; no headers or tables; use a short list only when presenting options.
- If the user's direction is unclear, ask one brief clarifying question. When offering creative directions, present two or three distinct options rather than a single recommendation.
- Write in complete, everyday sentences. Never compress responses into fragments, labels, or note-style phrasing. If a response is running long, cut options rather than grammar.
- Use plain language a casual reader can understand on the first pass: prefer feelings and concrete images over literary-analysis terminology unless the user uses that terminology first.
- The user is working under time pressure. Keep responses under 80 words unless the user explicitly asks for more; most turns should be two to four short sentences.
- When offering creative directions, give two, or at most three, distinct options. Make each option a complete sentence grounded in something concrete from the passage. A short bulleted list is fine, but do not use headers or tables.
- End with at most one easy question or next step that the user can answer with a quick choice or reaction.
- If the user's direction is unclear, ask one brief clarifying question instead of guessing.
- Do not write a complete poem unless the user explicitly asks you to. If they explicitly ask, do it.
- You are text-only. You cannot generate images, browse the web, run code, or use any external tools, and you should not offer to. Do not include images or hyperlinks in your responses.
- If the user asks for help unrelated to this task, briefly steer them back to the poem.
Expand All @@ -45,9 +61,9 @@ Style and behavior:

const stageMessages: Record<Stage, string> = {
SPARK:
"The user is currently reading the passage and brainstorming—exploring themes, moods, and directions, and taking notes. Help them figure out what they might want to express. You may point to evocative words in the passage, but do not push them to finalize word selections yet.",
"The user is reading the passage and brainstorming—figuring out what they might want to express and taking notes for later. Talk about the passage's images, moments, and feelings. You may point out striking words as material worth noting, but nothing is final yet. Do not pressure the user to commit to a direction or start building lines.",
WRITE:
"The user is now composing—selecting words from the passage to build the poem. Their current selections appear below and update as they work. Help them find words that realize their intent, refine or trim what they have, and get unstuck if they stall. When suggesting sequences of words, respect the passage-order rule.",
"The user is now composing—selecting words from the passage to build the poem. Their current selections appear below and update as they work. Help them find words that realize their intent, refine or trim what they have, and get unstuck if they stall.",
};

const promptSuggestions: Record<Stage, string[]> = {
Expand Down Expand Up @@ -133,8 +149,8 @@ export default function ChatTab({
const [input, setInput] = useState("");
// const [lastResponseId] = useState<string | null>(null);
const [markdownOutput, setMarkdownOutput] = useState("");
const [hasUsedFirstPrompt, setHasUsedFirstPrompt] = useState(false);
const [hasShownIdleNudge, setHasShownIdleNudge] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);

const hasUserMessageInStage = messages
.slice(stageStartMessageCountRef.current)
Expand Down Expand Up @@ -197,7 +213,7 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`,

const sendMessage = async (messageContent?: string) => {
const content = messageContent || input;
if (!content.trim()) return;
if (!content.trim() || isLLMLoading) return;

if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
Expand All @@ -211,9 +227,13 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`,
timestamp: new Date(),
};

const strippedMessages = messages.map(({ id, timestamp, ...rest }) => rest);
const strippedMessages = messages.map(({ role, content }) => ({
role,
content,
}));

setMarkdownOutput("");
setSendError(null);
setMessages((prev) => [...prev, artistMessage]);
setInput("");
setIsLLMLoading(true);
Expand Down Expand Up @@ -244,21 +264,30 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`,
setMarkdownOutput(""); // Clear this so the streaming UI disappears
} catch (error) {
console.error("LLM response failed", error);
setMarkdownOutput("");
setSendError(
"The assistant had a connection problem. Send your message again.",
);
// Remove the optimistic message before restoring it for a clean retry.
setMessages((previousMessages) =>
previousMessages.filter((message) => message.id !== artistMessage.id),
);
setInput((currentInput) =>
currentInput.trim() ? currentInput : content,
);
} finally {
setIsLLMLoading(false);
}
};

const handleKeyDown = (e: any) => {
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};

const handlePromptSelection = (prompt: string) => {
setInput(prompt);
setHasUsedFirstPrompt(true);
sendMessage(prompt);
};

Expand All @@ -285,7 +314,6 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`,

{chatReady &&
!hasUserMessageInStage &&
!hasUsedFirstPrompt &&
!isLLMLoading && (
<div className="mt-4 space-y-2">
<p className="text-sm text-gray-600 mb-3">Try asking me:</p>
Expand Down Expand Up @@ -330,6 +358,11 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`,
{/* Input area */}
{
<div className="p-3">
{sendError && (
<p className="mb-2 text-sm text-red-700" role="alert">
{sendError}
</p>
)}
<form
onSubmit={(e) => {
e.preventDefault();
Expand All @@ -344,7 +377,12 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`,
placeholder="Type a message..."
className="text-main bg-white flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-grey"
/>
<Button className="btn-small" onClick={() => sendMessage()}>
<Button
type="submit"
className="btn-small"
disabled={isLLMLoading}
aria-label="Send message"
>
<FiSend />
</Button>
</form>
Expand Down