From bb82c5695827d0c96f9259c2dc3fa917214fa3d1 Mon Sep 17 00:00:00 2001 From: Brian Hackett Date: Mon, 10 Feb 2025 20:18:54 -0800 Subject: [PATCH] Improve UI for viewing and changing problems (#15) --- app/components/chat/Chat.client.tsx | 8 + app/components/chat/LoadProblemButton.tsx | 38 ++-- app/components/settings/SettingsWindow.tsx | 2 +- .../settings/providers/APIKeysTab.tsx | 35 ++++ app/components/sidebar/Menu.client.tsx | 3 + app/components/sidebar/SaveProblem.tsx | 77 ++------ app/components/sidebar/SaveSolution.tsx | 133 +++++++++++++ app/lib/replay/Problems.ts | 147 ++++++++++++++ app/lib/replay/SimulationPrompt.ts | 14 +- app/routes/load-problem.$id.tsx | 8 + app/routes/problem.$id.tsx | 186 +++++++++++++++++- app/routes/problems.tsx | 33 +--- 12 files changed, 570 insertions(+), 114 deletions(-) create mode 100644 app/components/sidebar/SaveSolution.tsx create mode 100644 app/lib/replay/Problems.ts create mode 100644 app/routes/load-problem.$id.tsx diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index 4861094f..075f3d53 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -74,6 +74,12 @@ setInterval(async () => { } }, 1000); +let gLastUserPrompt: string | undefined = "app goes blank getting directions"; + +export function getLastUserPrompt(): string | undefined { + return gLastUserPrompt; +} + export function Chat() { renderLogger.trace('Chat'); @@ -345,6 +351,8 @@ export const ChatImpl = memo( return; } + gLastUserPrompt = _input; + const anthropicApiKey = Cookies.get(anthropicApiKeyCookieName); if (!anthropicApiKey) { const numFreeUses = +(Cookies.get(anthropicNumFreeUsesCookieName) || 0); diff --git a/app/components/chat/LoadProblemButton.tsx b/app/components/chat/LoadProblemButton.tsx index f4022d3a..10b358a4 100644 --- a/app/components/chat/LoadProblemButton.tsx +++ b/app/components/chat/LoadProblemButton.tsx @@ -4,7 +4,8 @@ import { toast } from 'react-toastify'; import { createChatFromFolder, type FileArtifact } from '~/utils/folderImport'; import { logStore } from '~/lib/stores/logs'; // Assuming logStore is imported from this location import { assert, sendCommandDedicatedClient } from '~/lib/replay/ReplayProtocolClient'; -import type { BoltProblem } from '~/components/sidebar/SaveProblem'; +import type { BoltProblem } from '~/lib/replay/Problems'; +import { getProblem } from '~/lib/replay/Problems'; import JSZip from 'jszip'; interface LoadProblemButtonProps { @@ -12,32 +13,31 @@ interface LoadProblemButtonProps { importChat?: (description: string, messages: Message[]) => Promise; } -export async function loadProblem(problemId: string, importChat: (description: string, messages: Message[]) => Promise) { - let problem: BoltProblem | null = null; - try { - const rv = await sendCommandDedicatedClient({ - method: "Recording.globalExperimentalCommand", - params: { - name: "fetchBoltProblem", - params: { problemId }, - }, - }); - console.log("FetchProblemRval", rv); - problem = (rv as { rval: BoltProblem }).rval; - } catch (error) { - console.error("Error fetching problem", error); - toast.error("Failed to fetch problem"); +export function setLastLoadedProblem(problem: BoltProblem) { + localStorage.setItem('loadedProblem', JSON.stringify(problem)); +} + +export function getLastLoadedProblem(): BoltProblem | undefined { + const problemJSON = localStorage.getItem('loadedProblem'); + if (!problemJSON) { + return undefined; } + return JSON.parse(problemJSON); +} + +export async function loadProblem(problemId: string, importChat: (description: string, messages: Message[]) => Promise) { + const problem = await getProblem(problemId); if (!problem) { return; } - const problemContents = problem.prompt.content; - const problemTitle = problem.title; + setLastLoadedProblem(problem); + + const { repositoryContents, title: problemTitle } = problem; const zip = new JSZip(); - await zip.loadAsync(problemContents, { base64: true }); + await zip.loadAsync(repositoryContents, { base64: true }); const fileArtifacts: FileArtifact[] = []; for (const [key, object] of Object.entries(zip.files)) { diff --git a/app/components/settings/SettingsWindow.tsx b/app/components/settings/SettingsWindow.tsx index d9838d50..e9ca9178 100644 --- a/app/components/settings/SettingsWindow.tsx +++ b/app/components/settings/SettingsWindow.tsx @@ -26,7 +26,7 @@ export const SettingsWindow = ({ open, onClose }: SettingsProps) => { const tabs: { id: TabType; label: string; icon: string; component?: ReactElement }[] = [ { id: 'data', label: 'Data', icon: 'i-ph:database', component: }, - { id: 'apiKeys', label: 'API Keys', icon: 'i-ph:key', component: }, + { id: 'apiKeys', label: 'User Info', icon: 'i-ph:key', component: }, { id: 'connection', label: 'Connection', icon: 'i-ph:link', component: }, { id: 'features', label: 'Features', icon: 'i-ph:star', component: }, ...(debug diff --git a/app/components/settings/providers/APIKeysTab.tsx b/app/components/settings/providers/APIKeysTab.tsx index 88f651d8..a7bbe115 100644 --- a/app/components/settings/providers/APIKeysTab.tsx +++ b/app/components/settings/providers/APIKeysTab.tsx @@ -2,9 +2,12 @@ import { useState } from 'react'; import { toast } from 'react-toastify'; import Cookies from 'js-cookie'; import { anthropicNumFreeUsesCookieName, anthropicApiKeyCookieName, MaxFreeUses } from '~/utils/freeUses'; +import { setNutAdminKey, setProblemsUsername, getNutAdminKey, getProblemsUsername } from '~/lib/replay/Problems'; export default function ConnectionsTab() { const [apiKey, setApiKey] = useState(Cookies.get(anthropicApiKeyCookieName) || ''); + const [username, setUsername] = useState(getProblemsUsername() || ''); + const [adminKey, setAdminKey] = useState(getNutAdminKey() || ''); const numFreeUses = +(Cookies.get(anthropicNumFreeUsesCookieName) || 0); const handleSaveAPIKey = async (key: string) => { @@ -17,6 +20,16 @@ export default function ConnectionsTab() { setApiKey(key); }; + const handleSaveUsername = async (username: string) => { + setProblemsUsername(username); + setUsername(username); + }; + + const handleSaveAdminKey = async (key: string) => { + setNutAdminKey(key); + setAdminKey(key); + }; + return (

Anthropic API Key

@@ -37,6 +50,28 @@ export default function ConnectionsTab() {
)} +

Problems User Name

+
+
+ handleSaveUsername(e.target.value)} + className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor disabled:opacity-50" + /> +
+
+

Nut Admin Key

+
+
+ handleSaveAdminKey(e.target.value)} + className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor disabled:opacity-50" + /> +
+
); } diff --git a/app/components/sidebar/Menu.client.tsx b/app/components/sidebar/Menu.client.tsx index 73cbd0b9..6ee497f7 100644 --- a/app/components/sidebar/Menu.client.tsx +++ b/app/components/sidebar/Menu.client.tsx @@ -13,6 +13,8 @@ import { binDates } from './date-binning'; import { useSearchFilter } from '~/lib/hooks/useSearchFilter'; import ReactModal from 'react-modal'; import { SaveProblem } from './SaveProblem'; +import { SaveSolution } from './SaveSolution'; +import { hasNutAdminKey } from '~/lib/replay/Problems'; const menuVariants = { closed: { @@ -138,6 +140,7 @@ export const Menu = () => { Problems + {hasNutAdminKey() && } (null); @@ -40,8 +21,7 @@ export function SaveProblem() { setFormData({ title: '', description: '', - name: '', - email: '', + name: '' }); setProblemId(null); }; @@ -61,36 +41,31 @@ export function SaveProblem() { return; } + const username = getProblemsUsername(); + + if (!username) { + toast.error('Please fill in username field'); + return; + } + toast.info("Submitting problem..."); console.log("SubmitProblem", formData); await workbenchStore.saveAllFiles(); const { contentBase64 } = await workbenchStore.generateZipBase64(); - const prompt: ProjectPrompt = { content: contentBase64 }; - const problem: BoltProblem = { - version: 1, + const problem: BoltProblemInput = { + version: 2, title: formData.title, description: formData.description, - name: formData.name, - email: formData.email, - prompt, + username, + repositoryContents: contentBase64, }; - try { - const rv = await sendCommandDedicatedClient({ - method: "Recording.globalExperimentalCommand", - params: { - name: "submitBoltProblem", - params: { problem }, - }, - }); - console.log("SubmitProblemRval", rv); - setProblemId((rv as any).rval.problemId); - } catch (error) { - console.error("Error submitting problem", error); - toast.error("Failed to submit problem"); + const problemId = await submitProblem(problem); + if (problemId) { + setProblemId(problemId); } } @@ -140,22 +115,6 @@ export function SaveProblem() { value={formData.description} onChange={handleInputChange} /> - -
Name (optional):
- - -
Email (optional):
-
diff --git a/app/components/sidebar/SaveSolution.tsx b/app/components/sidebar/SaveSolution.tsx new file mode 100644 index 00000000..eb7d95c9 --- /dev/null +++ b/app/components/sidebar/SaveSolution.tsx @@ -0,0 +1,133 @@ +import { toast } from "react-toastify"; +import ReactModal from 'react-modal'; +import { useState } from "react"; +import { workbenchStore } from "~/lib/stores/workbench"; +import { BoltProblemStatus, getProblemsUsername, updateProblem } from "~/lib/replay/Problems"; +import type { BoltProblemInput } from "~/lib/replay/Problems"; +import { getLastLoadedProblem } from "../chat/LoadProblemButton"; +import { getLastUserSimulationData } from "~/lib/replay/SimulationPrompt"; +import { getLastUserPrompt } from "../chat/Chat.client"; + +ReactModal.setAppElement('#root'); + +export function SaveSolution() { + const [isModalOpen, setIsModalOpen] = useState(false); + const [formData, setFormData] = useState({ + evaluator: '' + }); + const [savedSolution, setSavedSolution] = useState(false); + + const handleSaveSolution = () => { + setIsModalOpen(true); + setFormData({ + evaluator: '', + }); + setSavedSolution(false); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value + })); + }; + + const handleSubmitSolution = async () => { + // Add validation here + if (!formData.evaluator) { + toast.error('Please fill in evaluator field'); + return; + } + + const savedProblem = getLastLoadedProblem(); + if (!savedProblem) { + toast.error('No problem loaded'); + return; + } + + const simulationData = getLastUserSimulationData(); + if (!simulationData) { + toast.error('No simulation data found'); + return; + } + + const userPrompt = getLastUserPrompt(); + if (!userPrompt) { + toast.error('No user prompt found'); + return; + } + + toast.info("Submitting solution..."); + + console.log("SubmitSolution", formData); + + const problem: BoltProblemInput = { + version: 2, + title: savedProblem.title, + description: savedProblem.description, + username: savedProblem.username, + repositoryContents: savedProblem.repositoryContents, + status: BoltProblemStatus.Solved, + solution: { + simulationData, + userPrompt, + evaluator: formData.evaluator, + }, + }; + + await updateProblem(savedProblem.problemId, problem); + + setSavedSolution(true); + } + + return ( + <> + + Save Solution + + setIsModalOpen(false)} + className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg p-6 max-w-2xl w-full z-50" + overlayClassName="fixed inset-0 bg-black bg-opacity-50 z-40" + > + {savedSolution && ( + <> +
Solution Saved
+
+
+ +
+
+ + )} + {!savedSolution && ( + <> +
Save solution for loaded problem from last prompt and recording.
+
Evaluator describes a condition the explanation must satisfy.
+
+
+
Evaluator:
+ +
+
+ + +
+
+ + )} +
+ + ); +} diff --git a/app/lib/replay/Problems.ts b/app/lib/replay/Problems.ts new file mode 100644 index 00000000..4ecaa2ff --- /dev/null +++ b/app/lib/replay/Problems.ts @@ -0,0 +1,147 @@ +// Accessors for the API to access saved problems. + +import { toast } from "react-toastify"; +import { sendCommandDedicatedClient } from "./ReplayProtocolClient"; +import Cookies from 'js-cookie'; + +export interface BoltProblemComment { + username?: string; + content: string; + timestamp: number; +} + +export interface BoltProblemSolution { + simulationData: any; + userPrompt: string; + evaluator: string; +} + +export enum BoltProblemStatus { + // Problem has been submitted but not yet reviewed. + Pending = "Pending", + + // Problem has been reviewed and has not been solved yet. + Unsolved = "Unsolved", + + // There are one or more known prompts describing the recording which solve the problem. + HasPrompt = "HasPrompt", + + // Nut automatically produces a suitable explanation for solving the problem. + Solved = "Solved", +} + +// Information about each problem stored in the index file. +export interface BoltProblemDescription { + version: number; + problemId: string; + timestamp: number; + title: string; + description: string; + status?: BoltProblemStatus; + keywords?: string[]; +} + +export interface BoltProblem extends BoltProblemDescription { + username?: string; + repositoryContents: string; + comments?: BoltProblemComment[]; + solution?: BoltProblemSolution; +} + +export type BoltProblemInput = Omit; + +export async function listAllProblems(): Promise { + try { + const rv = await sendCommandDedicatedClient({ + method: "Recording.globalExperimentalCommand", + params: { + name: "listBoltProblems", + }, + }); + console.log("ListProblemsRval", rv); + return (rv as any).rval.problems.reverse(); + } catch (error) { + console.error("Error fetching problems", error); + toast.error("Failed to fetch problems"); + return []; + } +} + +export async function getProblem(problemId: string): Promise { + try { + const rv = await sendCommandDedicatedClient({ + method: "Recording.globalExperimentalCommand", + params: { + name: "fetchBoltProblem", + params: { problemId }, + }, + }); + console.log("FetchProblemRval", rv); + return (rv as { rval: { problem: BoltProblem } }).rval.problem; + } catch (error) { + console.error("Error fetching problem", error); + toast.error("Failed to fetch problem"); + } + return null; +} + +export async function submitProblem(problem: BoltProblemInput): Promise { + try { + const rv = await sendCommandDedicatedClient({ + method: "Recording.globalExperimentalCommand", + params: { + name: "submitBoltProblem", + params: { problem }, + }, + }); + console.log("SubmitProblemRval", rv); + return (rv as any).rval.problemId; + } catch (error) { + console.error("Error submitting problem", error); + toast.error("Failed to submit problem"); + return null; + } +} + +export async function updateProblem(problemId: string, problem: BoltProblemInput) { + try { + const adminKey = Cookies.get(nutAdminKeyCookieName); + if (!adminKey) { + toast.error("Admin key not specified"); + } + await sendCommandDedicatedClient({ + method: "Recording.globalExperimentalCommand", + params: { + name: "updateBoltProblem", + params: { problemId, problem, adminKey }, + }, + }); + } catch (error) { + console.error("Error updating problem", error); + toast.error("Failed to update problem"); + } +} + +const nutAdminKeyCookieName = 'nutAdminKey'; + +export function getNutAdminKey(): string | undefined { + return Cookies.get(nutAdminKeyCookieName); +} + +export function hasNutAdminKey(): boolean { + return !!getNutAdminKey(); +} + +export function setNutAdminKey(key: string) { + Cookies.set(nutAdminKeyCookieName, key); +} + +const nutProblemsUsernameCookieName = 'nutProblemsUsername'; + +export function getProblemsUsername(): string | undefined { + return Cookies.get(nutProblemsUsernameCookieName); +} + +export function setProblemsUsername(username: string) { + Cookies.set(nutProblemsUsernameCookieName, username); +} diff --git a/app/lib/replay/SimulationPrompt.ts b/app/lib/replay/SimulationPrompt.ts index 1505982a..793651f5 100644 --- a/app/lib/replay/SimulationPrompt.ts +++ b/app/lib/replay/SimulationPrompt.ts @@ -7,7 +7,7 @@ import { SimulationDataVersion } from './SimulationData'; import { assert, ProtocolClient } from './ReplayProtocolClient'; import type { MouseData } from './Recording'; -function createRepositoryContentsPacket(contents: string) { +function createRepositoryContentsPacket(contents: string): SimulationPacket { return { kind: "repositoryContents", contents, @@ -99,7 +99,7 @@ class ChatManager { }); } - finishSimulationData() { + finishSimulationData(): SimulationData { assert(this.client, "Chat has been destroyed"); assert(!this.simulationFinished, "Simulation has been finished"); assert(this.repositoryContents, "Expected repository contents"); @@ -179,11 +179,21 @@ export async function simulationAddData(data: SimulationData) { gChatManager.addPageData(data); } +let gLastUserSimulationData: SimulationData | undefined; + +export function getLastUserSimulationData(): SimulationData | undefined { + return gLastUserSimulationData; +} + export async function getSimulationRecording(): Promise { assert(gChatManager, "Expected to have an active chat"); const simulationData = gChatManager.finishSimulationData(); + // The repository contents are part of the problem and excluded from the simulation data + // reported for solutions. + gLastUserSimulationData = simulationData.filter(packet => packet.kind != "repositoryContents"); + console.log("SimulationData", new Date().toISOString(), JSON.stringify(simulationData)); assert(gChatManager.recordingIdPromise, "Expected recording promise"); diff --git a/app/routes/load-problem.$id.tsx b/app/routes/load-problem.$id.tsx new file mode 100644 index 00000000..e0ccf3d8 --- /dev/null +++ b/app/routes/load-problem.$id.tsx @@ -0,0 +1,8 @@ +import { json, type LoaderFunctionArgs } from '@remix-run/cloudflare'; +import { default as IndexRoute } from './_index'; + +export async function loader(args: LoaderFunctionArgs) { + return json({ problemId: args.params.id }); +} + +export default IndexRoute; diff --git a/app/routes/problem.$id.tsx b/app/routes/problem.$id.tsx index e0ccf3d8..9b878d98 100644 --- a/app/routes/problem.$id.tsx +++ b/app/routes/problem.$id.tsx @@ -1,8 +1,184 @@ -import { json, type LoaderFunctionArgs } from '@remix-run/cloudflare'; -import { default as IndexRoute } from './_index'; +import { ClientOnly } from 'remix-utils/client-only'; +import { Header } from '~/components/header/Header'; +import { Menu } from '~/components/sidebar/Menu.client'; +import BackgroundRays from '~/components/ui/BackgroundRays'; +import { TooltipProvider } from '@radix-ui/react-tooltip'; +import { ToastContainerWrapper } from './problems'; +import { toast } from 'react-toastify'; +import { useEffect } from 'react'; +import { useState } from 'react'; +import { useParams } from '@remix-run/react'; +import { getProblem, updateProblem as backendUpdateProblem, getProblemsUsername, BoltProblemStatus, hasNutAdminKey } from '~/lib/replay/Problems'; +import type { BoltProblem, BoltProblemComment, BoltProblemInput } from '~/lib/replay/Problems'; -export async function loader(args: LoaderFunctionArgs) { - return json({ problemId: args.params.id }); +function Status({ status }: { status: BoltProblemStatus }) { + const statusColors: Record = { + [BoltProblemStatus.Pending]: 'bg-yellow-400', + [BoltProblemStatus.Unsolved]: 'bg-orange-500', + [BoltProblemStatus.HasPrompt]: 'bg-blue-200', + [BoltProblemStatus.Solved]: 'bg-blue-500' + }; + + return ( +
+ Status: +
+ + + {status.charAt(0).toUpperCase() + status.slice(1)} + +
+
+ ); } -export default IndexRoute; +function Keywords({ keywords }: { keywords: string[] }) { + return ( +
+
+ {keywords.map((keyword, index) => ( + + {keyword} + + ))} +
+
+ ); +} + +function Comments({ comments }: { comments: BoltProblemComment[] }) { + return ( +
+ {comments.map((comment, index) => ( +
+
+ {comment.username ?? ""} + + {new Date(comment.timestamp).toLocaleString()} + +
+
{comment.content}
+
+ ))} +
+ ); +} + +function ProblemViewer({ problem }: { problem: BoltProblem }) { + const { problemId, title, description, status = BoltProblemStatus.Pending, keywords = [], comments = [] } = problem; + + return ( +
+

{title}

+

{description}

+ + Load Problem + + + + +
+ ) +} + +type DoUpdateCallback = (problem: BoltProblem) => BoltProblem; +type UpdateProblemCallback = (doUpdate: DoUpdateCallback) => void; + +function CommentForm({ updateProblem }: { updateProblem: UpdateProblemCallback }) { + const [comment, setComment] = useState({ + author: '', + text: '' + }) + + const handleAddComment = (content: string) => { + const newComment: BoltProblemComment = { + timestamp: Date.now(), + username: getProblemsUsername(), + content, + } + updateProblem(problem => { + const comments = [...(problem.comments || []), newComment]; + return { + ...problem, + comments, + }; + }); + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (comment.text.trim() && comment.author.trim()) { + handleAddComment(comment.text) + setComment({ author: '', text: '' }) + } + } + + return ( +
+