From 06f8625987cb1b827f02776a735985631da12221 Mon Sep 17 00:00:00 2001 From: Lauren Date: Wed, 24 Sep 2025 10:30:48 +0000 Subject: [PATCH 1/2] initial playground --- app/components/Playground/PlaygroundChat.tsx | 216 +++++++++++++++++++ app/components/Playground/Prompts.tsx | 166 ++++++++++++++ app/components/Playground/Settings.tsx | 215 ++++++++++++++++++ app/components/Playground/htmlControls.tsx | 135 ++++++++++++ app/hooks/useChat.ts | 23 ++ app/root.css | 4 + app/routes/playground.tsx | 152 +++++++++++++ 7 files changed, 911 insertions(+) create mode 100644 app/components/Playground/PlaygroundChat.tsx create mode 100644 app/components/Playground/Prompts.tsx create mode 100644 app/components/Playground/Settings.tsx create mode 100644 app/components/Playground/htmlControls.tsx create mode 100644 app/routes/playground.tsx diff --git a/app/components/Playground/PlaygroundChat.tsx b/app/components/Playground/PlaygroundChat.tsx new file mode 100644 index 00000000..683a108d --- /dev/null +++ b/app/components/Playground/PlaygroundChat.tsx @@ -0,0 +1,216 @@ +import {useState} from 'react' +import { + queryLLM, + Entry, + AssistantEntry, + ChatSettings, + Followup, + HistoryEntry, + EntryRole, +} from '~/hooks/useChat' +import Input from '~/components/Input' +import SendIcon from '~/components/icons-generated/PlaneSend' +import ChatEntry from '~/components/Chatbot/ChatEntry' + +const scroll30 = () => { + if (document.documentElement.scrollHeight - window.scrollY < window.innerHeight * 1.1) { + window.scrollTo({top: document.body.scrollHeight, behavior: 'smooth'}) + } +} + +const makeHistory = (query: string, entries: Entry[]): HistoryEntry[] => { + const getRole = (entry: Entry): EntryRole => { + if (entry.deleted) return 'deleted' + if (entry.role === 'stampy') return 'assistant' + return entry.role + } + + const history = entries + .filter((entry) => entry.role !== 'error') + .map((entry) => ({ + role: getRole(entry), + content: entry.content.trim(), + })) + return [...history, {role: 'user', content: query}] +} + +type PlaygroundChatParams = { + sessionId: string + settings: ChatSettings + onQuery?: (q: string) => void + onNewEntry?: (history: Entry[]) => void +} + +export const PlaygroundChat = ({ + sessionId, + settings, + onQuery, + onNewEntry, +}: PlaygroundChatParams) => { + const [entries, setEntries] = useState([]) + const [query, setQuery] = useState('') + const [current, setCurrent] = useState() + const [followups, setFollowups] = useState([]) + const [controller, setController] = useState(() => new AbortController()) + const [isSearching, setIsSearching] = useState(false) + + const updateCurrent = (current: AssistantEntry) => { + if (current?.phase === 'streaming') { + setCurrent(current) + scroll30() + } else { + setCurrent(current) + } + } + + const addResult = ( + query: string, + {result, followups}: {result: Entry; followups?: Followup[]} + ) => { + const userEntry = {role: 'user', content: query} as Entry + setEntries((prev) => { + const entries = [...prev, userEntry, result] + onNewEntry?.(entries) + return entries + }) + setFollowups(followups || []) + setQuery('') + scroll30() + } + + const abortable = + (f: any) => + (...args: any) => { + controller.abort() + const newController = new AbortController() + setController(newController) + return f(newController, ...args) + } + + const search = async (controller: AbortController, query: string) => { + setFollowups([]) + setIsSearching(true) + + const history = makeHistory(query, entries) + + const result = await queryLLM(history, updateCurrent, sessionId, controller, settings) + + if (result.result.content !== 'aborted') { + addResult(query, result) + } + setCurrent(undefined) + setIsSearching(false) + } + + const deleteEntry = (i: number) => { + const entry = entries[i] + if (entry === undefined) return + + if (i === entries.length - 1 && ['assistant', 'stampy', 'error'].includes(entry.role)) { + const prev = entries[i - 1] + if (prev !== undefined) setQuery(prev.content) + setEntries(entries.slice(0, i - 1)) + setFollowups([]) + } else { + entry.deleted = true + setEntries([...entries]) + } + } + + return ( +
+
    + {entries.map( + (entry, i) => + !entry.deleted && ( +
  • + + deleteEntry(i)} + > + ✕ + +
  • + ) + )} +
+ + {followups.length > 0 && ( +
+ {followups.map((followup, i) => ( + + ))} +
+ )} + +
+ { + setQuery(e.target.value) + onQuery?.(e.target.value) + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey && query.trim()) { + e.preventDefault() + abortable(search)(query) + } else if (e.key === 'Escape') { + controller.abort() + setIsSearching(false) + } + }} + /> + query.trim() && abortable(search)(query)} + /> +
+ + {current && ( +
+ {current.phase === 'started' &&

Loading: Sending query...

} + {current.phase === 'semantic' &&

Loading: Performing semantic search...

} + {current.phase === 'context' &&

Loading: Preparing context...

} + {current.phase === 'llm' &&

Loading: Thinking...

} + {current.phase === 'streaming' && } + {current.phase === 'followups' && ( + <> + +

Checking for followups...

+ + )} +
+ )} + + {!current && entries.length > 0 && ( + + )} +
+ ) +} diff --git a/app/components/Playground/Prompts.tsx b/app/components/Playground/Prompts.tsx new file mode 100644 index 00000000..9935039b --- /dev/null +++ b/app/components/Playground/Prompts.tsx @@ -0,0 +1,166 @@ +import {ChangeEvent, useState} from 'react' +import {ChatSettings, Entry} from '~/hooks/useChat' + +type ChatSettingsUpdate = [path: string[], value: any] + +type ChatPromptParams = { + settings: ChatSettings + query: string + history: Entry[] + changeSettings: (...vals: ChatSettingsUpdate[]) => void +} + +type DetailsProps = { + children: React.ReactNode + defaultOpen?: boolean +} & React.DetailsHTMLAttributes + +function Details({children, defaultOpen = true, ...props}: DetailsProps) { + const [isOpen, setIsOpen] = useState(defaultOpen) + + return ( +
setIsOpen((e.target as HTMLDetailsElement).open)} + > + {children} +
+ ) +} + +export const PlaygroundPrompts = ({settings, query, history, changeSettings}: ChatPromptParams) => { + const updatePrompt = + (...path: string[]) => + (event: ChangeEvent) => + changeSettings([['prompts', ...path], (event.target as HTMLInputElement).value]) + + const inlineAllTemplates = async () => { + try { + const response = await fetch('https://chat.stampy.ai:8443/inline-prompts', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({settings}), + }) + if (response.ok) { + const inlinedPrompts = await response.json() + + const updates: [string[], any][] = [] + + const addUpdates = (obj: any, path: string[] = ['prompts']) => { + Object.entries(obj).forEach(([key, value]) => { + const currentPath = [...path, key] + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + addUpdates(value, currentPath) + } else { + updates.push([currentPath, value]) + } + }) + } + + addUpdates(inlinedPrompts) + changeSettings(...updates) + } + } catch (error) { + console.error('Failed to inline templates:', error) + } + } + + return ( +
+ +
+ History summary prompt +