mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 09:47:44 +02:00
[eric] merge: streaming cadence + live markdown + auth/devtools/browser fixes from dev
This commit is contained in:
@@ -186,6 +186,9 @@ _AUTH_EXEMPT_EXACT = {
|
||||
# public api.openswarm.com/api/oauth/google/refresh doesn't already
|
||||
# do for any internet caller, so no new attack surface.
|
||||
"/api/tools/google-oauth-token",
|
||||
# Dev-only token handoff for the split-port frontend (no Electron preload
|
||||
# to read the token from). The route itself 404s in packaged builds.
|
||||
"/api/dev/token",
|
||||
}
|
||||
|
||||
_AUTH_EXEMPT_PREFIX = (
|
||||
|
||||
@@ -386,6 +386,17 @@ async def websocket_dashboard(websocket: WebSocket):
|
||||
ws_manager.disconnect_global(websocket)
|
||||
|
||||
|
||||
@app.get("/api/dev/token")
|
||||
async def dev_token():
|
||||
"""Hand the per-install token to the dev frontend, which has no Electron
|
||||
preload to read it from. Disabled in packaged builds (the preload exists
|
||||
there); localhost binding is the only thing gating it in dev."""
|
||||
if os.environ.get("OPENSWARM_PACKAGED") == "1":
|
||||
return JSONResponse({"error": "not available"}, status_code=404)
|
||||
from backend.auth import get_auth_token
|
||||
return JSONResponse({"token": get_auth_token()})
|
||||
|
||||
|
||||
@app.post("/api/browser/command")
|
||||
async def browser_command(request: Request):
|
||||
"""HTTP endpoint called by the browser MCP server subprocess.
|
||||
|
||||
@@ -268,3 +268,26 @@ def test_signout_succeeds_even_when_cloud_unreachable(client, reset_settings):
|
||||
s2 = load_settings()
|
||||
assert s2.user_id is None
|
||||
assert s2.openswarm_bearer_token is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The dev-token handoff must be dev-only so it can't widen prod surface (#49).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_dev_token_is_dev_only():
|
||||
"""/api/dev/token hands the install token to the split-port dev frontend
|
||||
without auth, but 404s in packaged builds where the preload supplies it."""
|
||||
import os
|
||||
import backend.auth as auth_mod
|
||||
noauth = TestClient(app) # deliberately no bearer header
|
||||
|
||||
os.environ.pop("OPENSWARM_PACKAGED", None)
|
||||
r = noauth.get("/api/dev/token")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["token"] == auth_mod._TOKEN
|
||||
|
||||
os.environ["OPENSWARM_PACKAGED"] = "1"
|
||||
try:
|
||||
assert noauth.get("/api/dev/token").status_code == 404
|
||||
finally:
|
||||
os.environ.pop("OPENSWARM_PACKAGED", None)
|
||||
|
||||
@@ -1249,6 +1249,19 @@ function createWindow() {
|
||||
console.log(`[renderer:${tag}] ${message}${src ? ` (${src}:${line})` : ''}`);
|
||||
});
|
||||
|
||||
// DevTools shortcut. Windows/Linux hide the menu bar, so the default View >
|
||||
// Toggle Developer Tools route is unreachable there (Mac keeps its menu);
|
||||
// wire F12 and Ctrl/Cmd+Shift+I directly so support can grab logs anywhere.
|
||||
mainWindow.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.type !== 'keyDown') return;
|
||||
const key = (input.key || '').toLowerCase();
|
||||
const isInspect = (input.control || input.meta) && input.shift && key === 'i';
|
||||
if (key === 'f12' || isInspect) {
|
||||
mainWindow.webContents.toggleDevTools();
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
isCreatingMainWindow = false;
|
||||
console.log('[diag][main] createWindow end, ua=', mainWindow.webContents.getUserAgent());
|
||||
}
|
||||
|
||||
@@ -1307,19 +1307,28 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{/* overflow-anchor: none on the two elements that grow every frame
|
||||
(live stream + thinking dots) keeps Chromium's scroll anchoring
|
||||
from fighting our jam-to-bottom for the scroll position. The
|
||||
committed messages above keep the default anchor, so resizing a
|
||||
tool row while the user has scrolled up still holds their view. */}
|
||||
{id && (
|
||||
<StreamingBubble
|
||||
sessionId={id}
|
||||
activeBranchId={session.active_branch_id || 'main'}
|
||||
turnLabel={session.turn_label?.label}
|
||||
onStreamGrew={stickToBottomIfNeeded}
|
||||
/>
|
||||
<Box sx={{ overflowAnchor: 'none' }}>
|
||||
<StreamingBubble
|
||||
sessionId={id}
|
||||
activeBranchId={session.active_branch_id || 'main'}
|
||||
turnLabel={session.turn_label?.label}
|
||||
onStreamGrew={stickToBottomIfNeeded}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{(awaitingResponse || (session.status === 'running' && !streamingMessageId)) && (
|
||||
<ThinkingBubble
|
||||
label={session.turn_label?.label}
|
||||
seedKey={`${session.id}:${session.messages?.length ?? 0}`}
|
||||
/>
|
||||
<Box sx={{ overflowAnchor: 'none' }}>
|
||||
<ThinkingBubble
|
||||
label={session.turn_label?.label}
|
||||
seedKey={`${session.id}:${session.messages?.length ?? 0}`}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{showResumeBubble && session.status === 'stopped' && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75 }}>
|
||||
|
||||
@@ -972,6 +972,17 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
overflow: 'hidden',
|
||||
opacity: isPending ? 0.7 : 1,
|
||||
transition: 'opacity 0.2s, border-color 0.2s',
|
||||
// User bubbles ease in instead of popping. Assistant bubbles are left
|
||||
// alone on purpose: they reveal by typing, and animating them would
|
||||
// flash at the streaming -> committed handoff. Transform+opacity only,
|
||||
// so it rides the compositor and never shifts layout or the scroll.
|
||||
...(isUser && !editing ? {
|
||||
animation: 'msgBubbleEnter 160ms ease-out',
|
||||
'@keyframes msgBubbleEnter': {
|
||||
from: { opacity: 0, transform: 'translateY(4px)' },
|
||||
to: { opacity: 1, transform: 'translateY(0)' },
|
||||
},
|
||||
} : {}),
|
||||
}}
|
||||
>
|
||||
{isUser ? (
|
||||
@@ -1165,22 +1176,12 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{isStreaming ? (
|
||||
<Box
|
||||
component="div"
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
{rawText}
|
||||
</Box>
|
||||
) : (
|
||||
renderedMarkdown
|
||||
)}
|
||||
{/* Render markdown live (not just at the end) so code is mono,
|
||||
bold is bold, lists/headings format from the first character.
|
||||
Killing the old plain-text -> markdown swap removes the big
|
||||
layout snap at stream end, which was the "glitch" people felt.
|
||||
Re-parse is memoized on the (smoothed) text and cheap at chat sizes. */}
|
||||
{renderedMarkdown}
|
||||
{isStreaming && <StreamingCursor />}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,26 +6,28 @@ import { useEffect, useRef, useState } from 'react';
|
||||
* claude.ai does, so generated text reads like it's being typed rather than
|
||||
* dumped in clumps.
|
||||
*
|
||||
* Zero dependencies. Zero added TTFT: the first characters reveal on the very next
|
||||
* animation frame after the first delta (same frame budget as painting it directly).
|
||||
* The reveal rate is ADAPTIVE — it accelerates as the backlog grows, so display
|
||||
* never falls meaningfully behind the model and never reads as laggy. The rAF loop
|
||||
* runs ONLY while there's a backlog to drain and parks itself at zero cost once
|
||||
* caught up, so it adds no idle-frame churn.
|
||||
* Why the old "reveal backlog/4, floor 3 chars/frame" version felt like
|
||||
* "pump pump pump": that floor (~180 chars/sec) is FASTER than a model
|
||||
* generates (~90 chars/sec), so the display kept sprinting to catch up, then
|
||||
* FROZE waiting for the next token. Freeze-sprint-freeze at token frequency is
|
||||
* the choppiness.
|
||||
*
|
||||
* This version is a buffered constant-velocity controller:
|
||||
* - It deliberately stays ~TARGET_LAG seconds BEHIND the latest text, so there
|
||||
* is always a buffer to reveal and it never runs dry between tokens.
|
||||
* - Reveal is TIME-based (chars = rate * elapsed), so it's frame-rate
|
||||
* independent and survives a dropped frame without a visible jump.
|
||||
* - The reveal RATE is EMA-smoothed, so a burst ramps the speed up gently and
|
||||
* a lull ramps it down gently; the rate never steps, so the flow never pulses.
|
||||
* The rAF loop runs only while there's a backlog and parks at zero cost once
|
||||
* caught up. Zero added TTFT: the first characters still reveal in-render on the
|
||||
* very first frame content exists.
|
||||
*/
|
||||
|
||||
/** Pure pacing step (exported for testing): chars to reveal this frame. */
|
||||
export function smoothStep(shown: number, full: number): number {
|
||||
if (shown >= full) return full;
|
||||
const backlog = full - shown;
|
||||
// Floor of 3 chars/frame (~180 chars/sec at 60fps) for a calm typing feel,
|
||||
// and drain ~1/4 of any backlog on top of that so bursts catch up fast. The
|
||||
// /4 keeps mid-stream lag small (a few words at most), so when the live bubble
|
||||
// hands off to the final message at stream end there's no visible jump. Never
|
||||
// overshoots `full`.
|
||||
const step = Math.max(3, Math.ceil(backlog / 4));
|
||||
return Math.min(full, shown + step);
|
||||
}
|
||||
const TARGET_LAG_S = 0.35; // stay this far behind = the buffer that prevents stalls
|
||||
const RATE_SMOOTH_S = 0.25; // how fast the reveal speed eases toward its target
|
||||
const MAX_CPS = 1000; // cap so a huge paste/burst still reveals smoothly, not instantly
|
||||
const MAX_DT_S = 0.05; // clamp elapsed after a frame drop / tab switch so we don't leap
|
||||
|
||||
export function useSmoothText(target: string, enabled: boolean): string {
|
||||
const [shownLen, setShownLen] = useState(enabled ? 0 : target.length);
|
||||
@@ -33,40 +35,69 @@ export function useSmoothText(target: string, enabled: boolean): string {
|
||||
const targetRef = useRef(target);
|
||||
targetRef.current = target;
|
||||
|
||||
// Controller state lives in refs so the rAF loop reads the latest without the
|
||||
// effect re-subscribing every character.
|
||||
const posRef = useRef<number>(enabled ? 0 : target.length); // float reveal position
|
||||
const cpsRef = useRef<number>(0); // current reveal speed
|
||||
const lastRef = useRef<number>(0); // last frame timestamp
|
||||
const shownRef = useRef<number>(shownLen);
|
||||
shownRef.current = shownLen;
|
||||
|
||||
// ONE persistent loop, keyed only on `enabled`. It must NOT restart per token:
|
||||
// an effect that depends on target.length tears the rAF down and rebuilds it on
|
||||
// every delta, and that churn is what stalls the reveal. So the loop runs every
|
||||
// frame for the life of the stream, reads the latest text from a ref, and just
|
||||
// advances by 0 when it happens to be caught up (cheap, no stall, no parking).
|
||||
useEffect(() => {
|
||||
// Disabled (historical message, or smoothing turned off): show all, stop loop.
|
||||
if (!enabled) {
|
||||
if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; }
|
||||
posRef.current = targetRef.current.length;
|
||||
setShownLen(targetRef.current.length);
|
||||
return;
|
||||
}
|
||||
const tick = () => {
|
||||
rafRef.current = null;
|
||||
setShownLen((cur) => {
|
||||
const next = smoothStep(cur, targetRef.current.length);
|
||||
if (next < targetRef.current.length) rafRef.current = requestAnimationFrame(tick);
|
||||
return next;
|
||||
});
|
||||
|
||||
const tick = (now: number) => {
|
||||
const full = targetRef.current.length;
|
||||
const dtRaw = lastRef.current ? (now - lastRef.current) / 1000 : 0.016;
|
||||
lastRef.current = now;
|
||||
const dt = dtRaw > MAX_DT_S ? MAX_DT_S : dtRaw;
|
||||
|
||||
const backlog = Math.max(0, full - posRef.current);
|
||||
const desired = backlog / TARGET_LAG_S; // speed that holds the lag steady (0 when caught up)
|
||||
const k = Math.min(1, dt / RATE_SMOOTH_S);
|
||||
let cps = cpsRef.current + (desired - cpsRef.current) * k; // EMA-smooth the speed itself, both up and down
|
||||
if (cps > MAX_CPS) cps = MAX_CPS;
|
||||
if (cps < 0) cps = 0;
|
||||
cpsRef.current = cps;
|
||||
|
||||
if (backlog > 0) {
|
||||
posRef.current = Math.min(full, posRef.current + cps * dt);
|
||||
const nextLen = Math.floor(posRef.current);
|
||||
if (nextLen !== shownRef.current) setShownLen(nextLen);
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick); // keep running for the whole stream
|
||||
};
|
||||
// Start a drain only if we're behind and no loop is already running.
|
||||
if (rafRef.current == null && shownLen < target.length) {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
lastRef.current = 0;
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; }
|
||||
};
|
||||
}, [enabled, target.length, shownLen]);
|
||||
}, [enabled]);
|
||||
|
||||
// Target shrank (new turn / reset / branch switch): re-sync so we don't slice
|
||||
// past the end of a shorter string.
|
||||
// past the end of a shorter string and so a fresh turn starts from zero.
|
||||
useEffect(() => {
|
||||
if (shownLen > target.length) setShownLen(enabled ? 0 : target.length);
|
||||
}, [target.length, shownLen, enabled]);
|
||||
if (posRef.current > target.length) {
|
||||
posRef.current = enabled ? 0 : target.length;
|
||||
cpsRef.current = 0;
|
||||
lastRef.current = 0;
|
||||
setShownLen(enabled ? 0 : target.length);
|
||||
}
|
||||
}, [target.length, enabled]);
|
||||
|
||||
// ZERO added TTFT: on the very first frame content exists (shownLen still 0),
|
||||
// reveal the floor immediately in-render instead of waiting a frame for the rAF
|
||||
// tick. Pure derivation, no extra render — so first visible text lands on the
|
||||
// exact same frame it would have without smoothing. State catches up next frame.
|
||||
// ZERO added TTFT: on the very first frame content exists, reveal a few chars
|
||||
// in-render instead of waiting a frame for the first rAF tick.
|
||||
if (!enabled) return target;
|
||||
const effectiveShown = (shownLen === 0 && target.length > 0)
|
||||
? Math.min(3, target.length)
|
||||
|
||||
@@ -55,7 +55,24 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
|
||||
const tc = useTermColors();
|
||||
|
||||
return (
|
||||
<Box {...selectAttrs} sx={{ maxWidth: mcpCompact ? '100%' : '85%', my: mcpCompact ? 0 : 0.5 }}>
|
||||
<Box
|
||||
{...selectAttrs}
|
||||
sx={{
|
||||
maxWidth: mcpCompact ? '100%' : '85%',
|
||||
my: mcpCompact ? 0 : 0.5,
|
||||
// Ease standalone tool calls in instead of popping. Skipped while
|
||||
// streaming (the live pill has a committed twin, so animating both
|
||||
// would flash) and when mcpCompact (rows inside a group already fade
|
||||
// via toolRowFadeIn). Transform+opacity only, so layout/scroll are untouched.
|
||||
...(!isStreaming && !mcpCompact ? {
|
||||
animation: 'toolBubbleEnter 160ms ease-out',
|
||||
'@keyframes toolBubbleEnter': {
|
||||
from: { opacity: 0, transform: 'translateY(4px)' },
|
||||
to: { opacity: 1, transform: 'translateY(0)' },
|
||||
},
|
||||
} : {}),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
'--glow-rgb': accentRgb,
|
||||
|
||||
@@ -65,7 +65,11 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
|
||||
const isInvokeAgent = isInvokeAgentTool(toolName);
|
||||
const isCreateAgent = isCreateAgentTool(toolName);
|
||||
const browserAgentAutoExpand = isBrowserAgent && isPending && !isStreaming;
|
||||
const showBody = expanded || isStreaming || browserAgentAutoExpand;
|
||||
// While the call is still streaming we keep the body CLOSED: the args land in
|
||||
// bursty clumps and force-painting them mid-stream is the jitter the user feels.
|
||||
// The header pill (tool name + glow) is the calm "what's running" signal; the
|
||||
// full args/output live behind the chevron once the call lands and is expanded.
|
||||
const showBody = expanded || browserAgentAutoExpand;
|
||||
|
||||
const resultContent = result?.content;
|
||||
const hasStructuredResult =
|
||||
|
||||
@@ -99,6 +99,14 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
|
||||
my: 0.5,
|
||||
// contain: stops new tool rows from reflowing the whole transcript.
|
||||
contain: 'layout style',
|
||||
// Ease in instead of popping when a tool group appears mid-turn.
|
||||
// Transform+opacity only, so it rides the compositor and never nudges
|
||||
// layout or the scroll position. No streaming twin, so no handoff flash.
|
||||
animation: 'toolGroupEnter 160ms ease-out',
|
||||
'@keyframes toolGroupEnter': {
|
||||
from: { opacity: 0, transform: 'translateY(4px)' },
|
||||
to: { opacity: 1, transform: 'translateY(0)' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
|
||||
@@ -574,11 +574,25 @@ async function handleEvaluate(wv: BrowserWebview, params: Record<string, any>):
|
||||
}
|
||||
}
|
||||
|
||||
// The registry is renderer-local and a card briefly unregisters on remount /
|
||||
// tab-switch; a command landing in that gap shouldn't hard-fail. Wait a bounded
|
||||
// window for (re)registration before giving up, so the error stays a real
|
||||
// "card is gone" signal rather than a transient race.
|
||||
async function awaitWebview(browserId: string, tabId?: string): Promise<BrowserWebview | undefined> {
|
||||
const deadline = Date.now() + 2000;
|
||||
let wv = getWebview(browserId, tabId);
|
||||
while (!wv && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
wv = getWebview(browserId, tabId);
|
||||
}
|
||||
return wv;
|
||||
}
|
||||
|
||||
async function handleBrowserCommand(data: Record<string, any>) {
|
||||
const { request_id, action, browser_id, tab_id, params = {} } = data;
|
||||
if (!request_id) return;
|
||||
|
||||
const wv = getWebview(browser_id, tab_id || undefined);
|
||||
const wv = await awaitWebview(browser_id, tab_id || undefined);
|
||||
if (!wv) {
|
||||
dashboardWs.send('browser:result', {
|
||||
request_id,
|
||||
|
||||
@@ -23,6 +23,18 @@ export async function refreshAuthToken(): Promise<string> {
|
||||
} catch {
|
||||
_authTokenCache = '';
|
||||
}
|
||||
return _authTokenCache;
|
||||
}
|
||||
// Dev (split-port, no Electron preload): the backend hands us the token over
|
||||
// localhost. The route 404s in packaged builds, so this only fires under run.sh.
|
||||
try {
|
||||
const r = await fetch(`http://${host}:${port}/api/dev/token`);
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
_authTokenCache = typeof data?.token === 'string' ? data.token : '';
|
||||
}
|
||||
} catch {
|
||||
_authTokenCache = '';
|
||||
}
|
||||
return _authTokenCache;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user