From 8c22ec8c802a6bacd5df10f4d49a274cd9f53b10 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 21 Jul 2026 14:51:12 -0700 Subject: [PATCH] [eric] composer: ghost-text prompt prediction in the user's voice (aux-LLM from recent topics), Tab to accept --- backend/apps/agents/agents.py | 8 ++ .../apps/agents/manager/predict_prompts.py | 116 ++++++++++++++++++ .../src/app/pages/AgentChat/ChatInput.tsx | 9 +- .../ChatInput/hooks/useEditorHandlers.ts | 31 ++++- .../ChatInput/view/ChatInputView.tsx | 2 + .../ChatInput/view/EditorSurface.tsx | 32 ++++- .../app/pages/Dashboard/DashboardToolbar.tsx | 32 +++++ 7 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 backend/apps/agents/manager/predict_prompts.py diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index e1688c4c..ea72414a 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -67,6 +67,14 @@ async def list_sessions(dashboard_id: str = ""): sessions = agent_manager.get_all_sessions(dashboard_id=dashboard_id or None) return {"sessions": [p_session_list_item(s) for s in sessions]} +@agents.router.get("/predict-prompts") +async def predict_prompts_route(count: int = 5): + """Guess a few prompts the user might type next, in their own voice, from what they've already + worked on. Drives the composer's ghost-text suggestion. Fails open to [] (no signal / no + provider / error), so the composer just keeps its static placeholder.""" + from backend.apps.agents.manager.predict_prompts import predict_prompts + return {"suggestions": await predict_prompts(count=max(1, min(count, 8)))} + @agents.router.get("/activity") async def agent_activity(): """How many agent tasks are live right now, plus seconds until the next scheduled diff --git a/backend/apps/agents/manager/predict_prompts.py b/backend/apps/agents/manager/predict_prompts.py new file mode 100644 index 00000000..f41ebad8 --- /dev/null +++ b/backend/apps/agents/manager/predict_prompts.py @@ -0,0 +1,116 @@ +"""Aux-LLM prompt prediction: guess a few prompts the user might type next, in their own voice, +from what they've already worked on (recent chat topics + onboarding starters). Provider-agnostic +(cheap tier of whichever provider is connected); fail-open to [] so the composer just falls back to +its static placeholder when there is no signal, no provider, or the call errors.""" + +import logging +import re +from typing import List + +from typeguard import typechecked + +from backend.apps.agents.core.aux_llm import aux_max_tokens_for +from backend.apps.agents.manager.session.session_store import load_all_session_data +from backend.apps.settings.settings import load_settings + +logger = logging.getLogger(__name__) + +MAX_TOPICS = 24 +MAX_SUGGESTIONS = 5 +# Names the aux title-gen hands out for empty/greeting chats; they carry no topic signal. +P_SKIP_NAMES = {"untitled", "new chat", "greeting", "chat", ""} + + +def p_recent_topics(limit: int = MAX_TOPICS) -> List[str]: + """Recent chat topic titles (the aux-distilled 2-4 word names), newest first, deduped.""" + data = load_all_session_data() + data.sort( + key=lambda pair: pair[1].get("closed_at") or pair[1].get("created_at") or "", + reverse=True, + ) + topics: List[str] = [] + seen = set() + for _sid, d in data: + name = (d.get("name") or "").strip() + low = name.lower() + if low in P_SKIP_NAMES or low in seen: + continue + seen.add(low) + topics.append(name) + if len(topics) >= limit: + break + return topics + + +def p_parse_lines(raw: str, count: int) -> List[str]: + """One suggestion per line; strip bullets/numbering/quotes, drop empties, cap at count.""" + out: List[str] = [] + for line in raw.splitlines(): + s = line.strip() + s = re.sub(r"^\s*(?:[-*•]|\d+[.)])\s*", "", s).strip() + s = s.strip('"“”‘’') + if s and len(s) <= 140: + out.append(s) + if len(out) >= count: + break + return out + + +@typechecked +async def predict_prompts(count: int = MAX_SUGGESTIONS) -> List[str]: + """Predict up to `count` short prompts the user might type next, in their style. [] on any miss.""" + try: + from backend.apps.settings.credentials import get_anthropic_client_for_model + from backend.apps.agents.providers.registry import resolve_aux_model + + global_settings = load_settings() + topics = p_recent_topics() + starters = [ + (s.prompt or "").strip() + for s in (global_settings.personalized_starters or []) + if getattr(s, "prompt", None) + ] + # Nothing to personalize from: let the composer keep its static placeholder. + if not topics and not starters: + return [] + + aux_model = (await resolve_aux_model(global_settings, preferred_tier="haiku"))[0] + client = get_anthropic_client_for_model(global_settings, aux_model) + + name = (global_settings.user_name or "").strip() + signal_lines: List[str] = [] + if topics: + signal_lines.append("Recent things they worked on: " + "; ".join(topics)) + if starters: + signal_lines.append("Tasks they were interested in: " + "; ".join(starters[:6])) + signal = "\n".join(signal_lines) + + system_prompt = ( + "You predict what a user is likely to type next into their AI agent platform, based on " + "what they already work on. You NEVER answer or explain; you only produce plausible next " + "prompts in the USER'S voice (imperative, first person, the way someone types to their " + "own assistant), matching their topics and phrasing.\n\n" + f"Return exactly {count} short prompts, one per line, no numbering, no quotes, no preamble. " + "Each is a single line under ~90 characters, concrete and immediately actionable. Vary " + "them across the topics; do not repeat a task they clearly just finished verbatim." + ) + user_turn = ( + (f"The user's name is {name}.\n" if name else "") + + "Here is what this user works on:\n\n" + + signal + + f"\n\n\nPredict {count} prompts they might type next." + ) + + chunks: List[str] = [] + async with client.messages.stream( + model=aux_model, + max_tokens=aux_max_tokens_for(aux_model, base=300), + system=system_prompt, + messages=[{"role": "user", "content": user_turn}], + ) as stream: + async for text in stream.text_stream: + chunks.append(text) + return p_parse_lines("".join(chunks), count) + except Exception as e: + logger.info(f"[predict-prompts] fail-open ([]): {e}") + return [] diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx index 6027f1cb..b4cffe7b 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -48,14 +48,19 @@ interface Props { prefillPrompt?: string; // Replaces the default "Agent, @ for context..." placeholder (e.g. "Ask about this run..."). placeholderOverride?: string; + // Predicted next prompt (in the user's voice) shown as ghost text in the empty composer; Tab fills it. + ghostSuggestion?: string; // A workflow run shown as a small removable chip inside the composer. runContext?: WorkflowsRunContext; onClearRunContext?: () => void; } -const ChatInput = forwardRef(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange, onActivityLabelChange, prefillPrompt, placeholderOverride, runContext, onClearRunContext }, ref) => { +const ChatInput = forwardRef(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange, onActivityLabelChange, prefillPrompt, placeholderOverride, ghostSuggestion, runContext, onClearRunContext }, ref) => { const c = useClaudeTokens(); const editorRef = useRef(null); + // Live ref so the keydown closure reads the current suggestion without re-creating handlers. + const ghostSuggestionRef = useRef(''); + ghostSuggestionRef.current = ghostSuggestion || ''; const containerRef = useRef(null); const generalFileInputRef = useRef(null); const dispatch = useAppDispatch(); @@ -298,6 +303,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, elementSelection, setHasContent, setAttachedSkills, setForcedTools, onModeChange, addImageFiles, uploadAndAttachFiles, handleSend, onPasteExpand: setPreviewPasteId, + ghostSuggestionRef, }); useDraftLoad(editorRef, ownerId, setPreviewPasteId, removePasteCard, c.font.mono, c.status.error); @@ -353,6 +359,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, queueLength={queueLength} modeConf={modeConf} placeholderOverride={placeholderOverride} + ghostSuggestion={ghostSuggestion} runContext={runContext} onClearRunContext={onClearRunContext} handleInput={handleInput} diff --git a/frontend/src/app/pages/AgentChat/ChatInput/hooks/useEditorHandlers.ts b/frontend/src/app/pages/AgentChat/ChatInput/hooks/useEditorHandlers.ts index a743fece..3200bab6 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/hooks/useEditorHandlers.ts +++ b/frontend/src/app/pages/AgentChat/ChatInput/hooks/useEditorHandlers.ts @@ -62,13 +62,15 @@ interface Params { uploadAndAttachFiles: (files: File[]) => void; handleSend: () => void; onPasteExpand: (pasteId: string) => void; + // Ghost-text prediction shown in the empty composer; Tab fills it as real, editable text. + ghostSuggestionRef?: RefObject; } export function useEditorHandlers(p: Params) { const { editorRef, generalFileInputRef, ownerId, sessionId, autoRunMode, c, skills, elementSelection, setHasContent, setAttachedSkills, setForcedTools, onModeChange, - addImageFiles, uploadAndAttachFiles, handleSend, onPasteExpand, + addImageFiles, uploadAndAttachFiles, handleSend, onPasteExpand, ghostSuggestionRef, } = p; const dispatch = useAppDispatch(); const [picker, setPicker] = useState(EMPTY_TRIGGER); @@ -233,6 +235,33 @@ export function useEditorHandlers(p: Params) { e.preventDefault(); return; } + // Tab accepts the ghost prediction (Copilot-style): only when the editor is empty and a + // suggestion is showing, so Tab keeps its normal meaning the instant the user starts typing. + if (e.key === 'Tab' && !e.shiftKey) { + const ghost = ghostSuggestionRef?.current || ''; + const editor = editorRef.current; + if (ghost && editor && !readEditorText(editor).trim()) { + e.preventDefault(); + if (isTextareaEl(editor)) { + editor.value = ghost; + editor.setSelectionRange(ghost.length, ghost.length); + } else { + editor.textContent = ghost; + const sel = window.getSelection(); + if (sel) { + const range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + } + } + editor.focus(); + updateHasContent(); + scheduleDraftSave(ownerId, () => readEditorHTML(editor)); + return; + } + } if ((e.ctrlKey || e.metaKey) && ['b', 'i', 'u'].includes(e.key.toLowerCase())) { e.preventDefault(); return; diff --git a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx index a90c8f5c..a224040c 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx @@ -61,6 +61,7 @@ interface Props { queueLength: number; modeConf: ModeConf; placeholderOverride?: string; + ghostSuggestion?: string; runContext?: WorkflowsRunContext; onClearRunContext?: () => void; handleInput: () => void; @@ -238,6 +239,7 @@ export const ChatInputView: React.FC = (p) => { isRunning={p.isRunning} queueLength={p.queueLength} placeholderLabel={p.placeholderOverride ?? 'Ask anything, @ for context, / for commands'} + ghostSuggestion={p.ghostSuggestion} onInput={p.handleInput} onClick={p.handleEditorClick} onKeyDown={p.handleKeyDown} diff --git a/frontend/src/app/pages/AgentChat/ChatInput/view/EditorSurface.tsx b/frontend/src/app/pages/AgentChat/ChatInput/view/EditorSurface.tsx index 03977d82..801444ea 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/view/EditorSurface.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/view/EditorSurface.tsx @@ -12,6 +12,7 @@ interface Props { isRunning?: boolean; queueLength: number; placeholderLabel: string; + ghostSuggestion?: string; onInput: () => void; onClick: () => void; onKeyDown: (e: React.KeyboardEvent) => void; @@ -20,15 +21,18 @@ interface Props { export const EditorSurface: React.FC = ({ c, editorRef, disabled, hasContent, hasAttachments, autoRunMode, isRunning, queueLength, - placeholderLabel, onInput, onClick, onKeyDown, onPaste, + placeholderLabel, ghostSuggestion, onInput, onClick, onKeyDown, onPaste, }) => { + // A live prediction outranks the static placeholder while the box is empty and idle; the Tab pill + // tells the user how to take it. Falls back to the placeholder the moment there's no suggestion. + const showGhost = !!ghostSuggestion && !disabled && !autoRunMode && !isRunning; const placeholderText = disabled ? 'Agent is working...' : autoRunMode ? 'Describe what data to generate…' : isRunning ? (queueLength > 0 ? `${queueLength} queued, type another or wait…` : 'Agent is working, messages will queue…') - : placeholderLabel; + : showGhost ? ghostSuggestion! : placeholderLabel; return ( @@ -72,9 +76,31 @@ export const EditorSurface: React.FC = ({ fontFamily: 'inherit', pointerEvents: 'none', userSelect: 'none', + display: 'flex', + alignItems: 'center', + gap: 8, }} > - {placeholderText} + + {placeholderText} + + {showGhost && ( + + Tab + + )} )} diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index 92c4558a..179a0848 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -20,6 +20,7 @@ import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { searchHistory, clearHistorySearch } from '@/shared/state/agentsSlice'; import { updateSettingsPatch, AppSettings } from '@/shared/state/settingsSlice'; import { store } from '@/shared/state/store'; +import { API_BASE, getAuthToken } from '@/shared/config'; import type { Output } from '@/shared/state/outputsSlice'; interface Props { @@ -94,6 +95,36 @@ const DashboardToolbar = React.forwardRef( settingsApplied.current = true; } }, [settingsLoaded, defaultMode, defaultModel, defaultThinkingLevel]); + // Ghost-text predictions: what the user might type next, in their own voice. Fetched once per app + // load (cached), then one is shown at a time and cycled while the composer sits idle+empty. Empty + // list (no signal / no provider / error) just leaves the static "What should I do sir..." placeholder. + const [ghostList, setGhostList] = useState([]); + const [ghostIdx, setGhostIdx] = useState(0); + const ghostFetchedRef = useRef(false); + useEffect(() => { + if (!inputOpen || ghostFetchedRef.current) return; + ghostFetchedRef.current = true; + (async () => { + try { + const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + const headers: Record = {}; + if (tok) headers['Authorization'] = `Bearer ${tok}`; + const resp = await fetch(`${API_BASE}/agents/predict-prompts?count=5`, { headers }); + if (!resp.ok) return; + const data = await resp.json(); + if (Array.isArray(data.suggestions)) setGhostList(data.suggestions.filter((s: unknown) => typeof s === 'string' && s)); + } catch { /* fail open: keep the static placeholder */ } + })(); + }, [inputOpen]); + // Rotate the visible suggestion every few seconds while the composer is open, so the user sees a + // few different ideas instead of one. Cheap; the list is already fetched and cached. + useEffect(() => { + if (!inputOpen || ghostList.length <= 1) return undefined; + const t = setInterval(() => setGhostIdx((i) => (i + 1) % ghostList.length), 4500); + return () => clearInterval(t); + }, [inputOpen, ghostList.length]); + const ghostSuggestion = ghostList.length ? ghostList[ghostIdx % ghostList.length] : undefined; + // Reset defaults on each new compose session so in-session picks don't leak into the next new-chat draft. const prevInputOpen = useRef(false); useEffect(() => { @@ -417,6 +448,7 @@ const DashboardToolbar = React.forwardRef( onThinkingLevelChange={handleThinkingLevelChange} prefillPrompt={prefillPrompt} placeholderOverride="What should I do sir..." + ghostSuggestion={ghostSuggestion} />