diff --git a/app/components/Input/index.tsx b/app/components/Input/index.tsx index 569431c7..e347abb3 100644 --- a/app/components/Input/index.tsx +++ b/app/components/Input/index.tsx @@ -1,5 +1,6 @@ import './input.css' import AutoHeight from 'react-auto-height' +import {forwardRef} from 'react' type InputProps = { className?: string @@ -12,14 +13,26 @@ type InputProps = { multiline?: boolean } -const Input = ({className = '', multiline, ...props}: InputProps) => { - const classes = `input ${className}` +const Input = forwardRef( + ({className = '', multiline, ...props}, ref) => { + const classes = `input ${className}` - if (multiline) { - return + if (multiline) { + return ( + + ) + } + + return } +) - return -} +Input.displayName = 'Input' export default Input diff --git a/app/components/Playground/PlaygroundChat.tsx b/app/components/Playground/PlaygroundChat.tsx new file mode 100644 index 00000000..8ea0c44f --- /dev/null +++ b/app/components/Playground/PlaygroundChat.tsx @@ -0,0 +1,313 @@ +import {useState, useEffect, useRef} 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 getSpinner = (thinkingCount: number = 0) => { + const spinnerFrames = [ + ' ...', + ' -..', + ' .-.', + ' ..-', + ' ...', + ' ..-', + ' .-.', + ' -..', + ' ...', + ' -..', + ' --.', + ' .--', + ' ..-', + ' ...', + ' -..', + ' .-.', + ' ..-', + ] + return spinnerFrames[thinkingCount % spinnerFrames.length] +} + +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 [onRequestQuickAnswer, setOnRequestQuickAnswer] = useState<(() => void) | undefined>( + undefined + ) + const inputRef = useRef(null) + + useEffect(() => { + if (!isSearching) inputRef.current?.focus() + }, [isSearching]) + + useEffect(() => { + if (!inputRef.current) return + inputRef.current.focus() + const len = inputRef.current.value.length + inputRef.current.setSelectionRange(len, len) + }, []) + + 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) + + let quickAnswerRequested = false + + setOnRequestQuickAnswer(() => () => { + quickAnswerRequested = true + controller.abort() + }) + + let result = await queryLLM(history, updateCurrent, sessionId, controller, settings) + + // If aborted and quick answer was requested, retry without thinking + if (result.result.content === 'aborted' && quickAnswerRequested) { + const newController = new AbortController() + setController(newController) + setOnRequestQuickAnswer(undefined) + + result = await queryLLM(history, updateCurrent, sessionId, newController, { + ...settings, + thinking_budget: 0, + }) + } + + if (result.result.content !== 'aborted') { + addResult(query, result) + } + setCurrent(undefined) + setIsSearching(false) + setOnRequestQuickAnswer(undefined) + } + + 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 === 'Escape') { + e.currentTarget.blur() + } else if (e.key === 'Enter' && !e.shiftKey && query.trim() && !isSearching) { + e.preventDefault() + abortable(search)(query) + } + }} + /> + +
+ + {current && ( +
+ {current.phase === 'started' &&

Loading: Sending query...

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

Loading: Refined your query...

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

Loading: Performing semantic search...

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

Loading: Processing history...

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

Loading: Preparing context...

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

Loading: Preparing context...

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

+ {current.thoughts && current.thoughts.length > 0 ? ( + <> + Loading: Thinking (usually 10s-30s) + {getSpinner(current.thoughts.length)} + + ) : ( + <>Loading... + )} +

+ {current.thoughts && current.thoughts.length > 0 && onRequestQuickAnswer && ( +

+ { + e.preventDefault() + onRequestQuickAnswer() + }} + > + Get a quick answer + +

+ )} +
+ )} + {current.phase === 'thinking' &&

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 +