mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] chat: follow-up chips in the user's own voice, per-chat, silent until two real exchanges
This commit is contained in:
@@ -67,6 +67,20 @@ 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("/sessions/{session_id}/followups")
|
||||
async def predict_followups_route(session_id: str, count: int = 3):
|
||||
"""Chat-specific next-message suggestions in the user's own voice. Empty until the
|
||||
conversation has >= 2 real exchanges; always empty rather than erroring."""
|
||||
session = agent_manager.sessions.get(session_id)
|
||||
if not session:
|
||||
try:
|
||||
session = await agent_manager.resume_session(session_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="session not found")
|
||||
from backend.apps.agents.manager.predict_followups import predict_followups
|
||||
return {"suggestions": await predict_followups(session, count=max(1, min(count, 5)))}
|
||||
|
||||
|
||||
@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
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Aux-LLM follow-up prediction for ONE chat: guess the user's next message in THIS conversation,
|
||||
in their exact voice, from the conversation itself. Sibling of predict_prompts.py (which predicts
|
||||
across chats from topic history); this one only ever reads the given session. Provider-agnostic
|
||||
cheap tier; fail-open to [] so the chat renders nothing instead of an error."""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.apps.agents.core.aux_llm import aux_max_tokens_for
|
||||
from backend.apps.agents.core.models import AgentSession
|
||||
from backend.apps.agents.manager.predict_prompts import parse_suggestion_lines
|
||||
from backend.apps.agents.manager.session.history_compaction import get_branch_messages
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_FOLLOWUPS = 3
|
||||
# No suggestions until the conversation has a real shape: below two full exchanges any guess is
|
||||
# generic filler, and the empty-chat starters already cover turn zero.
|
||||
MIN_EXCHANGES = 2
|
||||
# Enough tail to know where the conversation is, small enough to stay a sub-cent aux call.
|
||||
P_TAIL_MESSAGES = 12
|
||||
P_PER_MESSAGE_CAP = 700
|
||||
|
||||
|
||||
@typechecked
|
||||
def followups_eligible(session: AgentSession) -> bool:
|
||||
"""True once this branch holds >= MIN_EXCHANGES completed user->assistant exchanges."""
|
||||
msgs = get_branch_messages(session)
|
||||
users = sum(1 for m in msgs if m.role == "user" and not getattr(m, "hidden", False))
|
||||
assistants = sum(1 for m in msgs if m.role == "assistant")
|
||||
return min(users, assistants) >= MIN_EXCHANGES
|
||||
|
||||
|
||||
def conversation_tail(session: AgentSession) -> str:
|
||||
lines: List[str] = []
|
||||
for m in get_branch_messages(session)[-P_TAIL_MESSAGES:]:
|
||||
if getattr(m, "hidden", False) or m.role not in ("user", "assistant"):
|
||||
continue
|
||||
text = m.content if isinstance(m.content, str) else str(m.content)
|
||||
if len(text) > P_PER_MESSAGE_CAP:
|
||||
text = text[:P_PER_MESSAGE_CAP] + "..."
|
||||
lines.append(f"{'User' if m.role == 'user' else 'Assistant'}: {text}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@typechecked
|
||||
async def predict_followups(session: AgentSession, count: int = MAX_FOLLOWUPS) -> List[str]:
|
||||
"""Up to `count` plausible next messages for THIS chat, in the user's voice. [] on any miss."""
|
||||
try:
|
||||
if not followups_eligible(session):
|
||||
return []
|
||||
from backend.apps.settings.credentials import get_anthropic_client_for_model
|
||||
from backend.apps.agents.providers.registry import resolve_aux_model
|
||||
from backend.apps.settings.settings import load_settings
|
||||
|
||||
global_settings = load_settings()
|
||||
tail = conversation_tail(session)
|
||||
if not tail:
|
||||
return []
|
||||
aux_model = (await resolve_aux_model(global_settings, preferred_tier="haiku"))[0]
|
||||
client = get_anthropic_client_for_model(global_settings, aux_model)
|
||||
|
||||
system_prompt = (
|
||||
"You predict the next message a user might send in an ONGOING conversation with their "
|
||||
"AI agent. You never answer or explain; you only produce plausible follow-ups the USER "
|
||||
"would type next in THIS conversation.\n\n"
|
||||
"Mimic the user's exact writing style from their messages in the transcript: their "
|
||||
"casing, punctuation, brevity, slang. If they write lowercase two-word asks, so do you.\n\n"
|
||||
f"Return exactly {count} follow-ups, one per line, no numbering, no quotes, no preamble. "
|
||||
"Each under ~80 characters, each a DIFFERENT direction (dig deeper, next step, adjacent "
|
||||
"ask), each specific to this conversation's actual content, never generic."
|
||||
)
|
||||
user_turn = (
|
||||
"Conversation so far:\n<transcript>\n" + tail + "\n</transcript>\n\n"
|
||||
f"Predict {count} messages this user might send next."
|
||||
)
|
||||
|
||||
chunks: List[str] = []
|
||||
async with client.messages.stream(
|
||||
model=aux_model,
|
||||
max_tokens=aux_max_tokens_for(aux_model, base=200),
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_turn}],
|
||||
) as stream:
|
||||
async for text in stream.text_stream:
|
||||
chunks.append(text)
|
||||
return parse_suggestion_lines("".join(chunks), count)
|
||||
except Exception as e:
|
||||
logger.info(f"[predict-followups] fail-open ([]): {e}")
|
||||
return []
|
||||
@@ -46,7 +46,7 @@ def p_recent_topics(limit: int = MAX_TOPICS) -> List[str]:
|
||||
return topics
|
||||
|
||||
|
||||
def p_parse_lines(raw: str, count: int) -> List[str]:
|
||||
def parse_suggestion_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():
|
||||
@@ -115,7 +115,7 @@ async def predict_prompts(count: int = MAX_SUGGESTIONS) -> List[str]:
|
||||
) as stream:
|
||||
async for text in stream.text_stream:
|
||||
chunks.append(text)
|
||||
return p_parse_lines("".join(chunks), count)
|
||||
return parse_suggestion_lines("".join(chunks), count)
|
||||
except Exception as e:
|
||||
logger.info(f"[predict-prompts] fail-open ([]): {e}")
|
||||
return []
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""The turn gate is the product rule (no suggestions until the chat has real shape), so it is
|
||||
pinned independently of the aux call, which is fail-open and never exercised here."""
|
||||
|
||||
from backend.apps.agents.core.models import AgentSession, Message
|
||||
from backend.apps.agents.manager.predict_followups import followups_eligible, conversation_tail
|
||||
|
||||
|
||||
def p_session(*roles: str) -> AgentSession:
|
||||
s = AgentSession(name="t", model="sonnet")
|
||||
s.messages = [Message(role=r, content=f"m{i}", branch_id="main") for i, r in enumerate(roles)]
|
||||
return s
|
||||
|
||||
|
||||
def test_empty_chat_is_not_eligible():
|
||||
assert followups_eligible(p_session()) is False
|
||||
|
||||
|
||||
def test_one_exchange_is_not_eligible():
|
||||
assert followups_eligible(p_session("user", "assistant")) is False
|
||||
|
||||
|
||||
def test_two_exchanges_are_eligible():
|
||||
assert followups_eligible(p_session("user", "assistant", "user", "assistant")) is True
|
||||
|
||||
|
||||
def test_unanswered_user_spam_is_not_eligible():
|
||||
assert followups_eligible(p_session("user", "user", "user", "user")) is False
|
||||
|
||||
|
||||
def test_hidden_user_turns_do_not_count():
|
||||
s = p_session("user", "assistant", "user", "assistant")
|
||||
s.messages[2].hidden = True
|
||||
assert followups_eligible(s) is False
|
||||
|
||||
|
||||
def test_tool_noise_does_not_count_as_exchanges():
|
||||
s = p_session("user", "tool_call", "tool_result", "assistant", "tool_call", "assistant")
|
||||
assert followups_eligible(s) is False
|
||||
|
||||
|
||||
def test_tail_contains_only_visible_user_assistant_text():
|
||||
s = p_session("user", "tool_call", "assistant")
|
||||
tail = conversation_tail(s)
|
||||
assert "User: m0" in tail and "Assistant: m2" in tail and "m1" not in tail
|
||||
|
||||
|
||||
def test_tail_caps_giant_messages():
|
||||
s = p_session("user", "assistant")
|
||||
s.messages[0].content = "x" * 5000
|
||||
tail = conversation_tail(s)
|
||||
assert len(tail) < 2000 and tail.count("...") >= 1
|
||||
@@ -69,6 +69,7 @@ import ForceStopAgentBar from './ForceStopAgentBar';
|
||||
import { RateLimitPill } from './shell/RateLimitPill';
|
||||
import { ContextRecoveredPill } from './shell/ContextRecoveredPill';
|
||||
import ChatInput, { ChatInputHandle } from './ChatInput';
|
||||
import FollowupChips from './FollowupChips';
|
||||
import ContextDrawer from './shell/ContextDrawer';
|
||||
import { ErrorSlime } from '@/app/components/feedback/ErrorSlime';
|
||||
import { ContextPath } from '@/app/components/editor/DirectoryBrowser';
|
||||
@@ -2434,6 +2435,13 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<WorkflowModelNotice c={c} label={workflowModelNotice} />
|
||||
<FreeTrialModelNotice c={c} notice={freeTrialModelNotice} />
|
||||
<FollowupChips
|
||||
sessionId={id}
|
||||
busy={agentBusy}
|
||||
messageCount={session?.messages?.length ?? 0}
|
||||
enabled={!isDraft && !readOnly && !runContext}
|
||||
onPick={(p) => handleSend(p)}
|
||||
/>
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSend={handleSend}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { API_BASE, getAuthToken } from '@/shared/config';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
interface FollowupChipsProps {
|
||||
sessionId: string | undefined;
|
||||
/** True while a turn is running; chips hide and refetch on the next settle. */
|
||||
busy: boolean;
|
||||
/** Message count of the session; a change while idle means a turn landed, time to refresh. */
|
||||
messageCount: number;
|
||||
enabled: boolean;
|
||||
onPick: (prompt: string) => void;
|
||||
}
|
||||
|
||||
// Chat-specific follow-ups in the user's own voice, Claude-style chips above the composer. The
|
||||
// backend stays silent until the chat has >= 2 real exchanges, so rendering [] as nothing IS the
|
||||
// turn gate; clicking sends immediately (the text is already written the way the user types).
|
||||
const FollowupChips: React.FC<FollowupChipsProps> = ({ sessionId, busy, messageCount, enabled, onPick }) => {
|
||||
const c = useClaudeTokens();
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const fetchSeqRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !enabled || busy) {
|
||||
setSuggestions([]);
|
||||
return undefined;
|
||||
}
|
||||
const seq = ++fetchSeqRef.current;
|
||||
// Small settle delay so the fetch reads the turn's final transcript, not a mid-commit state.
|
||||
const timer = setTimeout(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/sessions/${sessionId}/followups?count=3`, { headers });
|
||||
if (!resp.ok || seq !== fetchSeqRef.current) return;
|
||||
const data = await resp.json();
|
||||
if (seq !== fetchSeqRef.current) return;
|
||||
setSuggestions(Array.isArray(data.suggestions)
|
||||
? data.suggestions.filter((s: unknown): s is string => typeof s === 'string' && !!s)
|
||||
: []);
|
||||
} catch { /* fail open: no chips */ }
|
||||
}, 900);
|
||||
return () => clearTimeout(timer);
|
||||
}, [sessionId, enabled, busy, messageCount]);
|
||||
|
||||
if (suggestions.length === 0) return null;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, px: 2, pb: 1 }}>
|
||||
{suggestions.map((s) => (
|
||||
<Box
|
||||
key={s}
|
||||
role="button"
|
||||
onClick={() => { setSuggestions([]); onPick(s); }}
|
||||
sx={{
|
||||
px: 1.25,
|
||||
py: 0.5,
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
bgcolor: c.bg.surface,
|
||||
color: c.text.secondary,
|
||||
fontSize: '0.8125rem',
|
||||
lineHeight: 1.4,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'border-color 0.15s ease, color 0.15s ease, background 0.15s ease',
|
||||
'&:hover': { borderColor: c.border.strong, color: c.text.primary, bgcolor: c.bg.elevated },
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FollowupChips;
|
||||
Reference in New Issue
Block a user