diff --git a/README.md b/README.md index 0c1d28c..e24b3cb 100644 --- a/README.md +++ b/README.md @@ -11,5 +11,7 @@ Our experimental design involves two phases: one for artists and another for the We plan to continue this research in a second part through a longitudinal field study. +Developer documentation for the creator dataset is in [`src/pages/artist/README.md`](src/pages/artist/README.md). + 1. Kwan, L. Y. -Y., Leung, A. K. -y., & Liou, S. (2018). Culture, creativity, and innovation. Journal of Cross-Cultural Psychology, 49(2), 165–170. https://doi.org/10.1177/0022022117753306s 2. Elisondo, R. (2016). Creativity is Always a Social Process. Creativity. Theories – Research - Applications, 3(2), 2016. 194-210. https://doi.org/10.1515/ctra-2016-0013 diff --git a/server/api/routes/firebaseAPI.ts b/server/api/routes/firebaseAPI.ts index 5fbc4aa..ef672d7 100644 --- a/server/api/routes/firebaseAPI.ts +++ b/server/api/routes/firebaseAPI.ts @@ -8,12 +8,224 @@ const ARTIST_SURVEY_COLLECTION = "artistSurvey"; const POEM_COLLECTION = "poem"; const INCOMPLETE_SESSION_COLLECTION = "artistIncompleteSession"; const ASSIGNMENT_COLLECTION = "artistAssignment"; +const AUDIENCE_COLLECTION = "audience"; +const AUDIENCE_SURVEY_COLLECTION = "audienceSurvey"; +const AUDIENCE_INCOMPLETE_SESSION_COLLECTION = "audienceIncompleteSession"; +const AUDIENCE_PASSAGE_POOL_VERSION = "creator-passages-2026-08-05-v1"; +const AUDIENCE_PASSAGE_ID_LIST = [ + "1", + "2", + "3", + "4", + "5", + "nyt-1", + "nyt-2", + "nyt-3", + "nyt-4", +] as const; +const AUDIENCE_PASSAGE_IDS = new Set(AUDIENCE_PASSAGE_ID_LIST); + +interface AudienceCandidate { + id: string; + condition: "LLM" | "NO_AI"; + passageId: string; + passage: { + id: string; + text: string; + title: string; + author: string; + publication?: string; + }; + selectedWordIndexes: number[]; + statement: string; +} + +const shuffle = (items: T[]): T[] => { + const copy = [...items]; + for (let index = copy.length - 1; index > 0; index -= 1) { + const otherIndex = Math.floor(Math.random() * (index + 1)); + [copy[index], copy[otherIndex]] = [copy[otherIndex], copy[index]]; + } + return copy; +}; + +const asRecord = (value: unknown): Record | undefined => + typeof value === "object" && value !== null + ? (value as Record) + : undefined; + +const getStatement = (surveyData: Record | undefined) => { + const nestedSurveyResponse = asRecord(surveyData?.surveyResponse); + const postAnswers = + asRecord(surveyData?.postSurveyAnswers) ?? + asRecord(surveyData?.postAnswers) ?? + asRecord(nestedSurveyResponse?.postAnswers); + const statement = + postAnswers?.final_intended_meaning ?? postAnswers?.q14 ?? null; + return typeof statement === "string" && statement.trim() + ? statement.trim() + : null; +}; + +const WORD_PATTERN = /[\p{L}\p{N}']+/gu; +const FIRST_PERSON_PATTERN = /\b(i|me|my|mine|we|us|our|ours)\b/i; +const POSITIVE_WORDS = new Set([ + "hope", + "joy", + "love", + "happy", + "peace", + "beauty", + "relief", + "wonder", +]); +const NEGATIVE_WORDS = new Set([ + "fear", + "sad", + "grief", + "anger", + "loss", + "pain", + "anxiety", + "despair", +]); +const GENERIC_STATEMENT_WORDS = new Set([ + "about", + "captures", + "creator", + "expresses", + "explores", + "feeling", + "feelings", + "poem", + "reflects", + "sense", + "something", + "theme", +]); + +const tokenize = (text: string) => + (text.toLowerCase().match(WORD_PATTERN) ?? []).filter( + (token) => token.length > 2, + ); + +const statementFeatures = (statement: string, poemText: string) => { + const statementTokens = tokenize(statement); + const poemTokens = new Set(tokenize(poemText)); + const overlap = statementTokens.filter((token) => poemTokens.has(token)).length; + const positive = statementTokens.filter((token) => POSITIVE_WORDS.has(token)).length; + const negative = statementTokens.filter((token) => NEGATIVE_WORDS.has(token)).length; + const specificTokenShare = statementTokens.length + ? statementTokens.filter((token) => !GENERIC_STATEMENT_WORDS.has(token)) + .length / statementTokens.length + : 0; + return { + wordCount: statementTokens.length, + overlap, + personal: FIRST_PERSON_PATTERN.test(statement), + valence: Math.sign(positive - negative), + specificTokenShare, + }; +}; + +const decoyMatchScore = ( + trueStatement: string, + decoyStatement: string, + poemText: string, +) => { + const target = statementFeatures(trueStatement, poemText); + const decoy = statementFeatures(decoyStatement, poemText); + return ( + Math.abs(target.wordCount - decoy.wordCount) + + Math.abs(target.overlap - decoy.overlap) * 3 + + (target.personal === decoy.personal ? 0 : 5) + + (target.valence === decoy.valence ? 0 : 4) + + Math.abs(target.specificTokenShare - decoy.specificTokenShare) * 5 + ); +}; + +const loadAudienceCandidates = async (): Promise => { + const artistSnapshot = await db + .collection(ARTIST_COLLECTION) + .where("condition", "in", ["LLM", "NO_AI"]) + .get(); + + const candidates = await Promise.all( + artistSnapshot.docs.map(async (artistDoc) => { + const artistData = artistDoc.data(); + const condition = artistData.condition as "LLM" | "NO_AI"; + const passagePoolVersion = artistData.assignment?.passagePoolVersion; + const poemRef = artistData.poem; + const surveyRef = artistData.surveyResponse; + if ( + passagePoolVersion !== AUDIENCE_PASSAGE_POOL_VERSION || + !poemRef || + !surveyRef + ) { + return null; + } + + const [poemDoc, surveyDoc] = await Promise.all([ + poemRef.get(), + surveyRef.get(), + ]); + if (!poemDoc.exists || !surveyDoc.exists) return null; + + const poemData = poemDoc.data(); + const passageId = String( + poemData?.taskPassageId ?? poemData?.passageId ?? "", + ); + const passage = poemData?.passage; + const statement = getStatement(surveyDoc.data()); + const selectedWordIndexes = + poemData?.selectedWordIndexes ?? poemData?.text; + + if ( + !AUDIENCE_PASSAGE_IDS.has(passageId) || + !passage?.text || + !passage?.title || + !passage?.author || + !statement || + !Array.isArray(selectedWordIndexes) + ) { + return null; + } + + return { + id: poemDoc.id, + condition, + passageId, + passage, + selectedWordIndexes: selectedWordIndexes.filter(Number.isInteger), + statement, + } satisfies AudienceCandidate; + }), + ); + + return candidates.filter( + (candidate): candidate is AudienceCandidate => candidate !== null, + ); +}; router.post("/artist-assignment", async (req, res) => { try { - const { sessionId, passageId, prolificPid } = req.body; - if (!sessionId || !passageId) { - return res.status(400).json({ error: "Missing sessionId or passageId" }); + const { + sessionId, + passageId, + tutorialPassageId, + passagePoolVersion, + prolificPid, + } = req.body; + if ( + !sessionId || + !passageId || + !tutorialPassageId || + !passagePoolVersion + ) { + return res.status(400).json({ + error: + "Missing sessionId, passageId, tutorialPassageId, or passagePoolVersion", + }); } const assignmentRef = db.collection(ASSIGNMENT_COLLECTION).doc(sessionId); @@ -21,8 +233,40 @@ router.post("/artist-assignment", async (req, res) => { const existingAssignment = await transaction.get(assignmentRef); if (existingAssignment.exists) { const existing = existingAssignment.data()!; + const taskPassageId = String( + existing.taskPassageId ?? existing.passageId, + ); + const resolvedTutorialPassageId = String( + existing.tutorialPassageId ?? + (tutorialPassageId === taskPassageId + ? passageId + : tutorialPassageId), + ); + const resolvedPassagePoolVersion = String( + existing.passagePoolVersion ?? "legacy-creator-passages", + ); + + if ( + !existing.taskPassageId || + !existing.tutorialPassageId || + !existing.passagePoolVersion + ) { + transaction.set( + assignmentRef, + { + taskPassageId, + tutorialPassageId: resolvedTutorialPassageId, + passagePoolVersion: resolvedPassagePoolVersion, + }, + { merge: true }, + ); + } + return { - passageId: existing.passageId as string, + passageId: taskPassageId, + taskPassageId, + tutorialPassageId: resolvedTutorialPassageId, + passagePoolVersion: resolvedPassagePoolVersion, condition: existing.condition as "LLM" | "NO_AI", strategy: existing.strategy as string, }; @@ -36,12 +280,22 @@ router.post("/artist-assignment", async (req, res) => { sessionId, prolificPid: prolificPid || null, passageId: String(passageId), + taskPassageId: String(passageId), + tutorialPassageId: String(tutorialPassageId), + passagePoolVersion: String(passagePoolVersion), condition, strategy, assignedAt: FieldValue.serverTimestamp(), }); - return { passageId: String(passageId), condition, strategy }; + return { + passageId: String(passageId), + taskPassageId: String(passageId), + tutorialPassageId: String(tutorialPassageId), + passagePoolVersion: String(passagePoolVersion), + condition, + strategy, + }; }); res.json(assignment); @@ -77,7 +331,11 @@ router.post("/autosave", async (req, res) => { ? statusMap[data.data.timeStamps.length] || "started" : "started"; - const ref = db.collection(INCOMPLETE_SESSION_COLLECTION).doc(sessionId); + const incompleteCollection = + data.role === "audience" + ? AUDIENCE_INCOMPLETE_SESSION_COLLECTION + : INCOMPLETE_SESSION_COLLECTION; + const ref = db.collection(incompleteCollection).doc(sessionId); const payload = { sessionId, role: data.role, @@ -153,6 +411,167 @@ router.post("/commit-session", async (req, res) => { } }); +router.post("/audience-assignment", async (_req, res) => { + try { + const candidates = await loadAudienceCandidates(); + const candidatesByPassage = new Map(); + candidates.forEach((candidate) => { + const passageCandidates = candidatesByPassage.get(candidate.passageId) ?? []; + passageCandidates.push(candidate); + candidatesByPassage.set(candidate.passageId, passageCandidates); + }); + + const eligiblePassages = shuffle( + [...candidatesByPassage.entries()].filter(([, passageCandidates]) => { + const llmCount = passageCandidates.filter( + (candidate) => candidate.condition === "LLM", + ).length; + const noAiCount = passageCandidates.filter( + (candidate) => candidate.condition === "NO_AI", + ).length; + return llmCount >= 2 && noAiCount >= 2 && passageCandidates.length >= 7; + }), + ); + + if (eligiblePassages.length === 0) { + return res.status(409).json({ + code: "INSUFFICIENT_AUDIENCE_POOL", + error: + "No current source passage has four balanced focal poems and three same-source decoys", + }); + } + + const [passageId, passageCandidates] = eligiblePassages[0]; + const tutorialPassageId = shuffle( + AUDIENCE_PASSAGE_ID_LIST.filter( + (candidatePassageId) => candidatePassageId !== passageId, + ), + )[0]; + const focalCandidates = shuffle([ + ...shuffle( + passageCandidates.filter((candidate) => candidate.condition === "LLM"), + ).slice(0, 2), + ...shuffle( + passageCandidates.filter((candidate) => candidate.condition === "NO_AI"), + ).slice(0, 2), + ]); + const focalIds = new Set(focalCandidates.map((candidate) => candidate.id)); + const decoyCandidates = passageCandidates.filter( + (candidate) => !focalIds.has(candidate.id), + ); + + const statementTrials = focalCandidates.map((focal) => { + const poemText = focal.selectedWordIndexes + .map((index) => focal.passage.text.split(" ")[index]) + .filter(Boolean) + .join(" "); + const decoys = [...decoyCandidates] + .sort( + (left, right) => + decoyMatchScore(focal.statement, left.statement, poemText) - + decoyMatchScore(focal.statement, right.statement, poemText), + ) + .slice(0, 3); + + return { + poemId: focal.id, + options: shuffle([ + { id: focal.id, statement: focal.statement }, + ...decoys.map((decoy) => ({ + id: decoy.id, + statement: decoy.statement, + })), + ]), + }; + }); + + const assignmentId = db.collection(AUDIENCE_COLLECTION).doc().id; + res.json({ + id: assignmentId, + passageId, + tutorialPassageId, + taskPassageId: passageId, + passagePoolVersion: AUDIENCE_PASSAGE_POOL_VERSION, + poems: focalCandidates.map((candidate) => ({ + id: candidate.id, + passageId: candidate.passageId, + passage: candidate.passage, + selectedWordIndexes: candidate.selectedWordIndexes, + })), + statementTrials, + }); + } catch (error) { + console.error(error); + res.status(500).json({ error: "Failed to create audience assignment" }); + } +}); + +router.post("/commit-audience-session", async (req, res) => { + try { + const { audienceData, sessionId, prolific } = req.body; + if (!audienceData || !sessionId) { + return res + .status(400) + .json({ error: "Missing audienceData or sessionId" }); + } + + const assignment = audienceData.assignment; + if ( + !assignment?.id || + !Array.isArray(assignment.poems) || + assignment.poems.length !== 4 || + assignment.passagePoolVersion !== AUDIENCE_PASSAGE_POOL_VERSION || + assignment.passageId !== assignment.taskPassageId || + assignment.tutorialPassageId === assignment.taskPassageId || + !AUDIENCE_PASSAGE_IDS.has(assignment.tutorialPassageId) || + !AUDIENCE_PASSAGE_IDS.has(assignment.taskPassageId) + ) { + return res.status(400).json({ error: "Invalid audience assignment" }); + } + + const batch = db.batch(); + const audienceRef = db.collection(AUDIENCE_COLLECTION).doc(assignment.id); + const surveyRef = db.collection(AUDIENCE_SURVEY_COLLECTION).doc(); + const incompleteRef = db + .collection(AUDIENCE_INCOMPLETE_SESSION_COLLECTION) + .doc(sessionId); + const assignmentSummary = { + id: assignment.id, + passageId: assignment.passageId, + tutorialPassageId: assignment.tutorialPassageId, + taskPassageId: assignment.taskPassageId, + passagePoolVersion: assignment.passagePoolVersion, + poemIds: assignment.poems.map((poem: { id: string }) => poem.id), + statementTrials: assignment.statementTrials.map( + (trial: { poemId: string; options: Array<{ id: string }> }) => ({ + poemId: trial.poemId, + optionIds: trial.options.map((option) => option.id), + }), + ), + }; + const audienceRecord: Record = { + assignment: assignmentSummary, + surveyResponse: surveyRef, + timestamps: audienceData.timeStamps ?? [], + completedAt: FieldValue.serverTimestamp(), + }; + if (prolific) audienceRecord.prolific = prolific; + + batch.set(audienceRef, audienceRecord); + batch.set(surveyRef, { + audienceId: audienceRef.id, + ...audienceData.surveyResponse, + }); + batch.delete(incompleteRef); + await batch.commit(); + + res.json({ success: true, audienceId: audienceRef.id }); + } catch (error) { + console.error(error); + res.status(500).json({ error: "Audience batch commit failed" }); + } +}); + router.get("/participant-condition", async (req, res) => { try { const { prolificPid } = req.query; diff --git a/src/App.tsx b/src/App.tsx index 6b7793c..1fe7a64 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,15 +17,14 @@ import usePreventRefresh from "./components/shared/preventRefresh"; import usePreventBack from "./components/shared/preventBackBttn"; import { nanoid } from "nanoid"; -// import AudienceInstructions from "./pages/audience/instructions/Instructions"; -// ================= AUDIENCE PAGES ================= -// import ChooseYourCharacter from "./pages/ChooseYourCharacter"; -// import AudiencePreSurvey from "./pages/audience/PreSurvey"; -// import AudienceTransitionStep1 from "./pages/audience/step1/TransitionStep1"; -// import AudienceStep1 from "./pages/audience/step1/Step1"; -// import AudienceStep2 from "./pages/audience/step2/Step2"; -// import AudienceTransitionStep2 from "./pages/audience/step2/TransitionStep2"; -// import AudiencePostSurvey from "./pages/audience/PostSurvey"; +import AudienceCaptcha from "./pages/audience/AudienceCaptcha"; +import AudienceInstructions from "./pages/audience/instructions/Instructions"; +import AudiencePoems from "./pages/audience/step2/Step2"; +import StatementMatch from "./pages/audience/StatementMatch"; +import Creativity from "./pages/audience/Creativity"; +import AIDetection from "./pages/audience/AIDetection"; +import AudiencePostSurvey from "./pages/audience/PostSurvey"; +import AudienceThankYou from "./pages/audience/ThankYou"; import LLMInstruction from "./pages/artist/instructions/llmInstructions"; import ArtistTutorial from "./pages/artist/tutorial/Tutorial"; import { useState, createContext, useEffect, useRef } from "react"; @@ -108,7 +107,7 @@ function App() { }, []); const enqueueAutosave = (data: UserData | null) => { - if (!data || !sessionId) return; + if (!data || !sessionId || isTestMode) return; if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); saveTimerRef.current = window.setTimeout(async () => { @@ -140,7 +139,7 @@ function App() { const addRoleSpecificData = ( updates: Partial | Partial, ) => { - setUserData((prev: any) => { + setUserData((prev) => { if (!prev || !prev.data) { throw new Error( "Tried to update data when userData is null or incomplete.", @@ -155,14 +154,14 @@ function App() { }, }; enqueueAutosave(next as UserData); - return next; + return next as UserData; }); }; const addPreSurvey = ( updates: Partial | Partial, ) => { - setUserData((prev: any) => { + setUserData((prev) => { if (!prev || !prev.data) { throw new Error("Tried to update pre-survey when userData is null."); } @@ -185,14 +184,14 @@ function App() { }, }; enqueueAutosave(next as UserData); - return next; + return next as UserData; }); }; const addPostSurvey = ( updates: Partial | Partial, ) => { - setUserData((prev: any) => { + setUserData((prev) => { if (!prev || !prev.data) { throw new Error("Tried to update post-survey when userData is null."); } @@ -215,7 +214,7 @@ function App() { }, }; enqueueAutosave(next as UserData); - return next; + return next as UserData; }); }; @@ -263,7 +262,9 @@ function App() { - } /> + } /> + } /> + } /> } /> } /> {userData && ( @@ -296,43 +297,35 @@ function App() { element={} /> } /> + } + /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> )} } /> - {/* - AUDIENCE ROUTES - } - /> - } /> - } - /> - } - /> - - - } - /> - - } - /> - - } - /> */} - - {/* } /> */} diff --git a/src/components/audience/AudiencePoem.tsx b/src/components/audience/AudiencePoem.tsx new file mode 100644 index 0000000..d1bdf9f --- /dev/null +++ b/src/components/audience/AudiencePoem.tsx @@ -0,0 +1,39 @@ +import type { AudiencePoem as AudiencePoemData } from "../../types"; + +interface Props { + poem: AudiencePoemData; + label?: string; +} + +const AudiencePoem = ({ poem, label }: Props) => { + const selectedIndexes = new Set(poem.selectedWordIndexes); + const words = poem.passage.text.split(" "); + + return ( +
event.preventDefault()} + > + {label ?
{label}
: null} +
+ {words.map((word, index) => { + const selected = selectedIndexes.has(index); + return ( + + {word + "\u00A0"} + + ); + })} +
+
+ ); +}; + +export default AudiencePoem; diff --git a/src/components/chatbot/Chatbot.tsx b/src/components/chatbot/Chatbot.tsx index dc36fd5..5895ffe 100644 --- a/src/components/chatbot/Chatbot.tsx +++ b/src/components/chatbot/Chatbot.tsx @@ -10,18 +10,21 @@ import { FiSend } from "react-icons/fi"; import { Button, Textarea } from "@chakra-ui/react"; import { nanoid } from "nanoid"; import type { - ChatOpening, + ChatAvailability, + ChatInputActivity, + ChatInputSource as ChatInputSourceType, LlmRequestLog, Message, Stage, } from "../../types"; -import { Role } from "../../types"; +import { ChatInputSource, MessageKind, Role } from "../../types"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { DataContext } from "../../App"; import { createAssistantMessage, IDLE_NUDGE_MESSAGES, + STAGE_OPENING_MESSAGES, } from "../../consts/chatMessages"; interface ChatTabProps { @@ -31,12 +34,14 @@ interface ChatTabProps { selectedWordIndexes?: number[]; passage: string; chatReady?: boolean; - onChatOpened?: (opening: ChatOpening) => void; + initialInputActivity?: ChatInputActivity; + onChatAvailable?: (availability: ChatAvailability) => void; + onInputActivityUpdate?: (activity: ChatInputActivity) => void; onRequestUpdate?: (request: LlmRequestLog) => void; } export const BLACKOUT_ASSISTANT_PROMPT_VERSION = - "blackout-assistant-2026-08-04-v1"; + "blackout-assistant-2026-08-05-v2"; /** * Keep the locator-excerpt convention identical across both stages so users @@ -50,9 +55,10 @@ Blackout poetry: the poet starts with an existing passage and creates a poem by Grounding: - Work only with the passage provided below. Never reference or substitute any other text. -- 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. +- 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 and italicize every surrounding locator word. For example: “*nights are* **clear**” or “*sharp,* **glittering** *sunshine*”. The italicized words are only locators to help the user find the bolded word; 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. +- Use italics only for the surrounding locator words in these excerpts, never for general emphasis. - Never suggest a word that does not appear in the passage. Style and behavior: @@ -161,7 +167,9 @@ export default function ChatTab({ stage, passage, chatReady = true, - onChatOpened, + initialInputActivity, + onChatAvailable, + onInputActivityUpdate, onRequestUpdate, }: ChatTabProps) { const context = useContext(DataContext); @@ -171,7 +179,18 @@ export default function ChatTab({ const chatContainerRef = useRef(null); const timeoutRef = useRef | null>(null); const stageStartMessageCountRef = useRef(messages.length); - const hasLoggedOpeningRef = useRef(false); + const hasLoggedAvailabilityRef = useRef(false); + const inputRef = useRef(""); + const hasDraftRef = useRef(false); + const inputActivityRef = useRef( + initialInputActivity ?? { + stage, + focusCount: 0, + draftStartCount: 0, + abandonedDraftCount: 0, + hasUnsentDraft: false, + }, + ); const [isLLMLoading, setIsLLMLoading] = useState(false); const [input, setInput] = useState(""); @@ -205,10 +224,29 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`, }, [passage, selectedWordIndexes, stage]); useEffect(() => { - if (!chatReady || hasLoggedOpeningRef.current) return; - hasLoggedOpeningRef.current = true; - onChatOpened?.({ stage, timestamp: new Date() }); - }, [chatReady, onChatOpened, stage]); + if (!chatReady || hasLoggedAvailabilityRef.current) return; + hasLoggedAvailabilityRef.current = true; + + const availableAt = new Date(); + setMessages((previousMessages) => { + const alreadyHasOpening = previousMessages.some( + (message) => + message.stage === stage && + message.kind === MessageKind.STAGE_OPENING, + ); + return alreadyHasOpening + ? previousMessages + : [ + ...previousMessages, + createAssistantMessage( + STAGE_OPENING_MESSAGES[stage], + stage, + MessageKind.STAGE_OPENING, + ), + ]; + }); + onChatAvailable?.({ stage, availableAt }); + }, [chatReady, onChatAvailable, setMessages, stage]); useEffect(() => { const element = chatContainerRef.current; @@ -226,7 +264,11 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`, timeoutRef.current = setTimeout(() => { setMessages((previousMessages) => [ ...previousMessages, - createAssistantMessage(IDLE_NUDGE_MESSAGES[stage]), + createAssistantMessage( + IDLE_NUDGE_MESSAGES[stage], + stage, + MessageKind.IDLE_NUDGE, + ), ]); setHasShownIdleNudge(true); }, 40000); @@ -245,8 +287,66 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`, stage, ]); - const sendMessage = async (messageContent?: string) => { - const content = messageContent || input; + const publishInputActivity = (activity: ChatInputActivity) => { + inputActivityRef.current = activity; + onInputActivityUpdate?.(activity); + }; + + const handleInputFocus = () => { + const activity = inputActivityRef.current; + publishInputActivity({ + ...activity, + firstFocusedAt: activity.firstFocusedAt ?? new Date(), + focusCount: activity.focusCount + 1, + }); + }; + + const handleInputChange = (value: string) => { + const hadDraft = hasDraftRef.current; + const hasDraft = Boolean(value.trim()); + inputRef.current = value; + hasDraftRef.current = hasDraft; + setInput(value); + + if (!hadDraft && hasDraft) { + const activity = inputActivityRef.current; + publishInputActivity({ + ...activity, + firstTypedAt: activity.firstTypedAt ?? new Date(), + draftStartCount: activity.draftStartCount + 1, + hasUnsentDraft: true, + }); + } else if (hadDraft && !hasDraft) { + const activity = inputActivityRef.current; + publishInputActivity({ + ...activity, + abandonedDraftCount: activity.abandonedDraftCount + 1, + hasUnsentDraft: false, + }); + } + }; + + const markDraftSubmittedOrReplaced = ( + inputSource: ChatInputSourceType, + ) => { + if (!hasDraftRef.current) return; + + hasDraftRef.current = false; + const activity = inputActivityRef.current; + publishInputActivity({ + ...activity, + abandonedDraftCount: + activity.abandonedDraftCount + + (inputSource === ChatInputSource.SUGGESTION ? 1 : 0), + hasUnsentDraft: false, + }); + }; + + const sendMessage = async ( + messageContent?: string, + inputSource: ChatInputSourceType = ChatInputSource.TYPED, + ) => { + const content = messageContent ?? inputRef.current; if (!content.trim() || isLLMLoading) return; if (timeoutRef.current) { @@ -259,6 +359,9 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`, role: Role.ARTIST, content, timestamp: new Date(), + stage, + kind: MessageKind.USER_MESSAGE, + inputSource, }; const requestId = nanoid(); let requestLog: LlmRequestLog = { @@ -268,6 +371,7 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`, userMessageContent: content, requestedAt: new Date(), status: "STARTED", + inputSource, systemPrompt: systemMessage.content, promptVersion: BLACKOUT_ASSISTANT_PROMPT_VERSION, }; @@ -281,6 +385,8 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`, setMarkdownOutput(""); setSendError(null); setMessages((prev) => [...prev, artistMessage]); + markDraftSubmittedOrReplaced(inputSource); + inputRef.current = ""; setInput(""); setIsLLMLoading(true); @@ -320,6 +426,8 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`, role: Role.LLM, content: fullText, timestamp: new Date(), + stage, + kind: MessageKind.LLM_RESPONSE, }; requestLog = { ...requestLog, @@ -347,9 +455,15 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`, setMessages((previousMessages) => previousMessages.filter((message) => message.id !== artistMessage.id), ); - setInput((currentInput) => - currentInput.trim() ? currentInput : content, - ); + if (!inputRef.current.trim()) { + inputRef.current = content; + hasDraftRef.current = true; + setInput(content); + publishInputActivity({ + ...inputActivityRef.current, + hasUnsentDraft: true, + }); + } } finally { setIsLLMLoading(false); } @@ -363,7 +477,7 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`, }; const handlePromptSelection = (prompt: string) => { - sendMessage(prompt); + sendMessage(prompt, ChatInputSource.SUGGESTION); }; return ( @@ -447,7 +561,8 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`, >