feat: LLM auto session titles + live reasoning whisper
- POST /sessions/{id}/title/auto — summarize the first exchange into a short
title in the user's language; never clobbers a manual rename
- Frontend fires it after the first completed exchange and refreshes the sidebar
- reasoning_delta now carries a bounded 600-char rolling tail on the existing
throttled emit; ActivityLine whispers the live thought (serif italic, 3-line
clamp) while the attempt is active
This commit is contained in:
1 parent
8f40de5a5c
commit
3ed96c32ba
7 files changed
+114
-5
No files matched your search
@@ -702,6 +702,7 @@ class AgentLoop:
|
|||||||
# Streaming output + collect thinking text
|
# Streaming output + collect thinking text
|
||||||
thinking_chunks: List[str] = []
|
thinking_chunks: List[str] = []
|
||||||
reasoning_chars = 0
|
reasoning_chars = 0
|
||||||
|
reasoning_tail = ""
|
||||||
last_reasoning_emit: float | None = None
|
last_reasoning_emit: float | None = None
|
||||||
|
|
||||||
def _on_text_chunk(delta: str) -> None:
|
def _on_text_chunk(delta: str) -> None:
|
||||||
@@ -714,8 +715,12 @@ class AgentLoop:
|
|||||||
# and evicts tool_call/text_delta events. The first chunk
|
# and evicts tool_call/text_delta events. The first chunk
|
||||||
# of each iteration always emits immediately so the UI
|
# of each iteration always emits immediately so the UI
|
||||||
# flips to "Reasoning…" without delay.
|
# flips to "Reasoning…" without delay.
|
||||||
nonlocal reasoning_chars, last_reasoning_emit
|
nonlocal reasoning_chars, reasoning_tail, last_reasoning_emit
|
||||||
reasoning_chars += len(delta)
|
reasoning_chars += len(delta)
|
||||||
|
# Rolling tail rides the already-throttled emit so the UI
|
||||||
|
# can whisper the current thought; a bounded window keeps
|
||||||
|
# replay-buffer pressure flat regardless of trace length.
|
||||||
|
reasoning_tail = (reasoning_tail + delta)[-600:]
|
||||||
now = _time.monotonic()
|
now = _time.monotonic()
|
||||||
if (
|
if (
|
||||||
last_reasoning_emit is not None
|
last_reasoning_emit is not None
|
||||||
@@ -725,7 +730,7 @@ class AgentLoop:
|
|||||||
last_reasoning_emit = now
|
last_reasoning_emit = now
|
||||||
self._emit(
|
self._emit(
|
||||||
"reasoning_delta",
|
"reasoning_delta",
|
||||||
{"iter": current_iter, "chars": reasoning_chars},
|
{"iter": current_iter, "chars": reasoning_chars, "tail": reasoning_tail},
|
||||||
)
|
)
|
||||||
|
|
||||||
# On last iteration, drop tool definitions to force text output
|
# On last iteration, drop tool definitions to force text output
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Mounted by ``agent/api_server.py`` via ``register_sessions_routes(app)``.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
@@ -615,6 +616,69 @@ def register_sessions_routes(app: FastAPI) -> None:
|
|||||||
svc.store.update_session(session)
|
svc.store.update_session(session)
|
||||||
return {"status": "updated", "session_id": session_id}
|
return {"status": "updated", "session_id": session_id}
|
||||||
|
|
||||||
|
@app.post("/sessions/{session_id}/title/auto", dependencies=[Depends(require_auth)])
|
||||||
|
async def auto_title_session(session_id: str):
|
||||||
|
"""Summarize the first exchange into a short LLM-generated title.
|
||||||
|
|
||||||
|
Never clobbers a manual rename: only rewrites when the current title
|
||||||
|
is empty or still the auto-set first-prompt prefix from create time.
|
||||||
|
"""
|
||||||
|
_host_validate_path_param(session_id, "session_id")
|
||||||
|
svc = _host_get_session_service()
|
||||||
|
if not svc:
|
||||||
|
raise HTTPException(status_code=501, detail="Session runtime not enabled")
|
||||||
|
session = svc.store.get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||||
|
|
||||||
|
messages = svc.get_messages(session_id, limit=6)
|
||||||
|
first_user = next(
|
||||||
|
(m for m in messages if m.role == "user" and (m.content or "").strip()), None
|
||||||
|
)
|
||||||
|
if first_user is None:
|
||||||
|
raise HTTPException(status_code=409, detail="Session has no user message to summarize")
|
||||||
|
|
||||||
|
current = (session.title or "").strip()
|
||||||
|
auto_prefix = first_user.content.strip()[:50].strip()
|
||||||
|
if current and current != auto_prefix:
|
||||||
|
return {"status": "kept", "session_id": session_id, "title": current}
|
||||||
|
|
||||||
|
first_assistant = next(
|
||||||
|
(m for m in messages if m.role == "assistant" and (m.content or "").strip()), None
|
||||||
|
)
|
||||||
|
excerpt = f"User: {first_user.content.strip()[:600]}"
|
||||||
|
if first_assistant:
|
||||||
|
excerpt += f"\nAssistant: {first_assistant.content.strip()[:600]}"
|
||||||
|
prompt = (
|
||||||
|
"Write a session title for the conversation below: at most 8 words "
|
||||||
|
"(or 16 CJK characters), in the same language as the user's message, "
|
||||||
|
"no quotes, no trailing punctuation. Reply with the title only.\n\n"
|
||||||
|
+ excerpt
|
||||||
|
)
|
||||||
|
|
||||||
|
def _generate() -> str:
|
||||||
|
from src.providers.chat import ChatLLM
|
||||||
|
|
||||||
|
response = ChatLLM().chat([{"role": "user", "content": prompt}], timeout=30)
|
||||||
|
return (getattr(response, "content", "") or "").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = await asyncio.to_thread(_generate)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"title generation failed: {exc}")
|
||||||
|
|
||||||
|
title = raw.splitlines()[0].strip().strip("\"'“”「」『』").strip()
|
||||||
|
chars = list(title)
|
||||||
|
if len(chars) > 40:
|
||||||
|
title = "".join(chars[:40])
|
||||||
|
if not title:
|
||||||
|
raise HTTPException(status_code=502, detail="empty title from model")
|
||||||
|
|
||||||
|
session.title = title
|
||||||
|
session.updated_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
svc.store.update_session(session)
|
||||||
|
return {"status": "updated", "session_id": session_id, "title": title}
|
||||||
|
|
||||||
@app.post("/sessions/{session_id}/messages", dependencies=[Depends(require_auth)])
|
@app.post("/sessions/{session_id}/messages", dependencies=[Depends(require_auth)])
|
||||||
async def send_message(session_id: str, payload: SendMessageRequest, http_request: Request):
|
async def send_message(session_id: str, payload: SendMessageRequest, http_request: Request):
|
||||||
"""Send a user message and start the agent loop (natural language strategy)."""
|
"""Send a user message and start the agent loop (natural language strategy)."""
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import type { AgentActivity } from "@/stores/agent";
|
|||||||
|
|
||||||
interface ActivityLineProps {
|
interface ActivityLineProps {
|
||||||
activity: AgentActivity;
|
activity: AgentActivity;
|
||||||
|
/** Live rolling reasoning excerpt — only supplied for the in-flight attempt. */
|
||||||
|
reasoningTail?: string;
|
||||||
onContinue?: () => void;
|
onContinue?: () => void;
|
||||||
onReattach?: () => void;
|
onReattach?: () => void;
|
||||||
}
|
}
|
||||||
@@ -59,6 +61,7 @@ function StateIcon({ state }: { state: AgentActivity["state"] }): JSX.Element {
|
|||||||
/** The single live and historical status surface for one agent attempt. */
|
/** The single live and historical status surface for one agent attempt. */
|
||||||
export const ActivityLine = memo(function ActivityLine({
|
export const ActivityLine = memo(function ActivityLine({
|
||||||
activity,
|
activity,
|
||||||
|
reasoningTail,
|
||||||
onContinue,
|
onContinue,
|
||||||
onReattach,
|
onReattach,
|
||||||
}: ActivityLineProps): JSX.Element {
|
}: ActivityLineProps): JSX.Element {
|
||||||
@@ -159,6 +162,15 @@ export const ActivityLine = memo(function ActivityLine({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div id={detailsId} hidden={!expanded}>
|
<div id={detailsId} hidden={!expanded}>
|
||||||
|
{/* Live reasoning whisper — DeepTutor-style sub-trace of the current
|
||||||
|
thought; disappears once the attempt leaves its active states. */}
|
||||||
|
{isActive && reasoningTail && (
|
||||||
|
<div className="border-t border-border/30 px-3 py-2" aria-hidden="true">
|
||||||
|
<p className="line-clamp-3 border-s-2 border-border/40 ps-2 font-serif text-xs italic leading-relaxed text-muted-foreground/80">
|
||||||
|
{reasoningTail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{activity.steps.length > 0 && (
|
{activity.steps.length > 0 && (
|
||||||
<div className="border-t border-border/30 px-3 py-2">
|
<div className="border-t border-border/30 px-3 py-2">
|
||||||
<ToolProgressIndicator toolCalls={activity.steps} />
|
<ToolProgressIndicator toolCalls={activity.steps} />
|
||||||
|
|||||||
@@ -64,6 +64,14 @@ export function Layout() {
|
|||||||
const isAgentPage = pathname.startsWith("/agent");
|
const isAgentPage = pathname.startsWith("/agent");
|
||||||
useEffect(() => { loadSessions(); }, [isAgentPage, activeSessionId]);
|
useEffect(() => { loadSessions(); }, [isAgentPage, activeSessionId]);
|
||||||
|
|
||||||
|
// Re-list after out-of-band title changes (e.g. LLM auto-titling on the
|
||||||
|
// first completed exchange).
|
||||||
|
useEffect(() => {
|
||||||
|
const refresh = () => loadSessions();
|
||||||
|
window.addEventListener("vibe:sessions-refresh", refresh);
|
||||||
|
return () => window.removeEventListener("vibe:sessions-refresh", refresh);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||||
const [renameTarget, setRenameTarget] = useState<string | null>(null);
|
const [renameTarget, setRenameTarget] = useState<string | null>(null);
|
||||||
const [renameValue, setRenameValue] = useState("");
|
const [renameValue, setRenameValue] = useState("");
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ export const api = {
|
|||||||
createSession: (title?: string) => request<SessionItem>("/sessions", { method: "POST", body: JSON.stringify({ title: title || "" }) }),
|
createSession: (title?: string) => request<SessionItem>("/sessions", { method: "POST", body: JSON.stringify({ title: title || "" }) }),
|
||||||
deleteSession: (sid: string) => request<{ status: string }>(`/sessions/${sid}`, { method: "DELETE" }),
|
deleteSession: (sid: string) => request<{ status: string }>(`/sessions/${sid}`, { method: "DELETE" }),
|
||||||
renameSession: (sid: string, title: string) => request<{ status: string }>(`/sessions/${sid}`, { method: "PATCH", body: JSON.stringify({ title }) }),
|
renameSession: (sid: string, title: string) => request<{ status: string }>(`/sessions/${sid}`, { method: "PATCH", body: JSON.stringify({ title }) }),
|
||||||
|
// Codex-style LLM summary title from the first exchange; backend refuses to
|
||||||
|
// overwrite a manual rename, so this is safe to fire-and-forget.
|
||||||
|
autoTitleSession: (sid: string) => request<{ status: string; title: string }>(`/sessions/${sid}/title/auto`, { method: "POST" }),
|
||||||
sendMessage: (sid: string, content: string) => request<{ message_id: string; attempt_id: string }>(`/sessions/${sid}/messages`, { method: "POST", body: JSON.stringify({ content }) }),
|
sendMessage: (sid: string, content: string) => request<{ message_id: string; attempt_id: string }>(`/sessions/${sid}/messages`, { method: "POST", body: JSON.stringify({ content }) }),
|
||||||
cancelSession: (sid: string) => request<{ status: string }>(`/sessions/${sid}/cancel`, { method: "POST" }),
|
cancelSession: (sid: string) => request<{ status: string }>(`/sessions/${sid}/cancel`, { method: "POST" }),
|
||||||
getSessionMessages: (sid: string) => request<MessageItem[]>(`/sessions/${sid}/messages`),
|
getSessionMessages: (sid: string) => request<MessageItem[]>(`/sessions/${sid}/messages`),
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ export function Agent() {
|
|||||||
const status = useAgentStore(s => s.status);
|
const status = useAgentStore(s => s.status);
|
||||||
const sessionId = useAgentStore(s => s.sessionId);
|
const sessionId = useAgentStore(s => s.sessionId);
|
||||||
const activity = useAgentStore(s => s.activity);
|
const activity = useAgentStore(s => s.activity);
|
||||||
|
const reasoningTail = useAgentStore(s => s.reasoningTail);
|
||||||
const swarmRuns = useAgentStore(s => s.swarmRuns);
|
const swarmRuns = useAgentStore(s => s.swarmRuns);
|
||||||
const sessionLoading = useAgentStore(s => s.sessionLoading);
|
const sessionLoading = useAgentStore(s => s.sessionLoading);
|
||||||
const previousStatusRef = useRef(status);
|
const previousStatusRef = useRef(status);
|
||||||
@@ -648,6 +649,8 @@ export function Agent() {
|
|||||||
touch();
|
touch();
|
||||||
replayAttemptSeenRef.current = true;
|
replayAttemptSeenRef.current = true;
|
||||||
if (!identifyActivity(d, "thinking")) return;
|
if (!identifyActivity(d, "thinking")) return;
|
||||||
|
// The backend sends a bounded rolling tail — replace, never append.
|
||||||
|
act().setReasoningTail(String(d.tail ?? ""));
|
||||||
queueStreamUpdate("", true);
|
queueStreamUpdate("", true);
|
||||||
if (act().status !== "streaming") act().setStatus("streaming");
|
if (act().status !== "streaming") act().setStatus("streaming");
|
||||||
},
|
},
|
||||||
@@ -832,6 +835,14 @@ export function Agent() {
|
|||||||
s.addMessage({ id: "", type: "answer", content: finalAnswer, timestamp: Date.now() });
|
s.addMessage({ id: "", type: "answer", content: finalAnswer, timestamp: Date.now() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// First completed exchange → ask the backend for a Codex-style
|
||||||
|
// summary title, then nudge the sidebar to re-list sessions.
|
||||||
|
if (act().messages.filter((message) => message.type === "user").length === 1) {
|
||||||
|
api.autoTitleSession(sid)
|
||||||
|
.then(() => window.dispatchEvent(new Event("vibe:sessions-refresh")))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// Detect Shadow Account id if render_shadow_report fired successfully this turn
|
// Detect Shadow Account id if render_shadow_report fired successfully this turn
|
||||||
const shadowCall = completedTools.find(
|
const shadowCall = completedTools.find(
|
||||||
(tc) => tc.tool === "render_shadow_report" && (tc.status || "ok") === "ok",
|
(tc) => tc.tool === "render_shadow_report" && (tc.status || "ok") === "ok",
|
||||||
@@ -1616,7 +1627,7 @@ export function Agent() {
|
|||||||
<div className="flex gap-3 msg-enter">
|
<div className="flex gap-3 msg-enter">
|
||||||
<AgentAvatar />
|
<AgentAvatar />
|
||||||
<div className="flex-1 min-w-0 space-y-2">
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
{activity && <ActivityLine activity={activity} />}
|
{activity && <ActivityLine activity={activity} reasoningTail={reasoningTail} />}
|
||||||
{streamingText && (
|
{streamingText && (
|
||||||
<div aria-live="polite" aria-atomic="false">
|
<div aria-live="polite" aria-atomic="false">
|
||||||
<MarkdownContent content={streamingText} streaming showCursor />
|
<MarkdownContent content={streamingText} streaming showCursor />
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ interface AgentState {
|
|||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
status: "idle" | "streaming" | "error";
|
status: "idle" | "streaming" | "error";
|
||||||
streamingText: string;
|
streamingText: string;
|
||||||
|
/** Rolling tail of the model's live reasoning trace (bounded server-side). */
|
||||||
|
reasoningTail: string;
|
||||||
|
|
||||||
/** The session currently streaming on the backend. Survives switchSession
|
/** The session currently streaming on the backend. Survives switchSession
|
||||||
* so the sidebar spinner persists when the user navigates away. */
|
* so the sidebar spinner persists when the user navigates away. */
|
||||||
@@ -98,6 +100,7 @@ interface AgentState {
|
|||||||
cacheSession: (sid: string, msgs: StoredAgentMessage[]) => void;
|
cacheSession: (sid: string, msgs: StoredAgentMessage[]) => void;
|
||||||
getCachedSession: (sid: string) => StoredAgentMessage[] | undefined;
|
getCachedSession: (sid: string) => StoredAgentMessage[] | undefined;
|
||||||
|
|
||||||
|
setReasoningTail: (tail: string) => void;
|
||||||
clearStreaming: () => void;
|
clearStreaming: () => void;
|
||||||
clearStreamingSession: (sid: string) => void;
|
clearStreamingSession: (sid: string) => void;
|
||||||
|
|
||||||
@@ -118,6 +121,7 @@ export const useAgentStore = create<AgentState>((set) => ({
|
|||||||
sessionId: null,
|
sessionId: null,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
streamingText: "",
|
streamingText: "",
|
||||||
|
reasoningTail: "",
|
||||||
streamingSessionId: null,
|
streamingSessionId: null,
|
||||||
toolCalls: [],
|
toolCalls: [],
|
||||||
activity: null,
|
activity: null,
|
||||||
@@ -292,7 +296,8 @@ export const useAgentStore = create<AgentState>((set) => ({
|
|||||||
}),
|
}),
|
||||||
getCachedSession: (sid) => _sessionCache.get(sid),
|
getCachedSession: (sid) => _sessionCache.get(sid),
|
||||||
|
|
||||||
clearStreaming: () => set({ streamingText: "" }),
|
setReasoningTail: (reasoningTail) => set({ reasoningTail }),
|
||||||
|
clearStreaming: () => set({ streamingText: "", reasoningTail: "" }),
|
||||||
clearStreamingSession: (sid) =>
|
clearStreamingSession: (sid) =>
|
||||||
set((s) => s.streamingSessionId === sid ? { streamingSessionId: null } : {}),
|
set((s) => s.streamingSessionId === sid ? { streamingSessionId: null } : {}),
|
||||||
|
|
||||||
@@ -306,6 +311,7 @@ export const useAgentStore = create<AgentState>((set) => ({
|
|||||||
messages: msgs || [],
|
messages: msgs || [],
|
||||||
status: "idle",
|
status: "idle",
|
||||||
streamingText: "",
|
streamingText: "",
|
||||||
|
reasoningTail: "",
|
||||||
toolCalls: [],
|
toolCalls: [],
|
||||||
activity: null,
|
activity: null,
|
||||||
swarmRuns: msgs ? (_swarmSessionCache.get(sid) ?? {}) : {},
|
swarmRuns: msgs ? (_swarmSessionCache.get(sid) ?? {}) : {},
|
||||||
@@ -321,7 +327,7 @@ export const useAgentStore = create<AgentState>((set) => ({
|
|||||||
reset: () => {
|
reset: () => {
|
||||||
_id = 0;
|
_id = 0;
|
||||||
set({
|
set({
|
||||||
messages: [], status: "idle", streamingText: "",
|
messages: [], status: "idle", streamingText: "", reasoningTail: "",
|
||||||
sessionId: null, toolCalls: [], activity: null, swarmRuns: {}, sessionLoading: false,
|
sessionId: null, toolCalls: [], activity: null, swarmRuns: {}, sessionLoading: false,
|
||||||
streamingSessionId: null,
|
streamingSessionId: null,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user