mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 12:47:42 +02:00
[eric] composer: ghost-text prompt prediction in the user's voice (aux-LLM from recent topics), Tab to accept
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user