[eric] composer: ghost-text prompt prediction in the user's voice (aux-LLM from recent topics), Tab to accept

This commit is contained in:
ciregenz
2026-07-21 14:51:12 -07:00
parent 3416aab991
commit 8c22ec8c80
7 changed files with 225 additions and 5 deletions
+8
View File
@@ -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
@@ -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<signal>\n"
+ signal
+ f"\n</signal>\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 []
@@ -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<ChatInputHandle, Props>(({ 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<ChatInputHandle, Props>(({ 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<HTMLDivElement>(null);
// Live ref so the keydown closure reads the current suggestion without re-creating handlers.
const ghostSuggestionRef = useRef<string>('');
ghostSuggestionRef.current = ghostSuggestion || '';
const containerRef = useRef<HTMLDivElement>(null);
const generalFileInputRef = useRef<HTMLInputElement>(null);
const dispatch = useAppDispatch();
@@ -298,6 +303,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ 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<ChatInputHandle, Props>(({ onSend, disabled, mode,
queueLength={queueLength}
modeConf={modeConf}
placeholderOverride={placeholderOverride}
ghostSuggestion={ghostSuggestion}
runContext={runContext}
onClearRunContext={onClearRunContext}
handleInput={handleInput}
@@ -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<string>;
}
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<TriggerState>(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;
@@ -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<Props> = (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}
@@ -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<Props> = ({
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 (
<Box sx={{ px: 1.5, pt: hasAttachments ? 0.5 : 1.25, pb: 0.25, position: 'relative' }}>
@@ -72,9 +76,31 @@ export const EditorSurface: React.FC<Props> = ({
fontFamily: 'inherit',
pointerEvents: 'none',
userSelect: 'none',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
{placeholderText}
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{placeholderText}
</span>
{showGhost && (
<span
style={{
flexShrink: 0,
fontSize: '0.7rem',
fontWeight: 600,
letterSpacing: '0.02em',
color: c.text.tertiary,
border: `1px solid ${c.border.medium}`,
borderRadius: 5,
padding: '1px 6px',
opacity: 0.8,
}}
>
Tab
</span>
)}
</div>
)}
</Box>
@@ -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<HTMLDivElement, Props>(
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<string[]>([]);
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<string, string> = {};
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<HTMLDivElement, Props>(
onThinkingLevelChange={handleThinkingLevelChange}
prefillPrompt={prefillPrompt}
placeholderOverride="What should I do sir..."
ghostSuggestion={ghostSuggestion}
/>
</DarkTokensScope>
</div>