mirror of
https://github.com/proxysoul/Empryo.git
synced 2026-11-07 06:25:57 -10:00
fix(chat): snappier streaming + no bare assistant header
- yield via microtask budget, not setTimeout(0) — no event-loop starvation - hoist + gate Hearth bridge streaming behind active binding - show "Thinking…" placeholder when reasoning hidden and no visible body Co-Authored-By: SoulForge <[email protected]>
This commit is contained in:
3 files changed
+54
-9
No files matched your search
@@ -1214,6 +1214,17 @@ const AssistantMessage = memo(function AssistantMessage({
|
||||
const hasTools = msg.toolCalls && msg.toolCalls.length > 0;
|
||||
const isEmpty = !hasSegments && !hasContent && !hasTools;
|
||||
|
||||
// A message whose only content is reasoning renders an empty body when
|
||||
// reasoning is hidden — leaving a bare header. Detect that case so we can
|
||||
// show a "Thinking…" placeholder instead of nothing.
|
||||
const onlyHiddenReasoning =
|
||||
!isEmpty &&
|
||||
!hasContent &&
|
||||
!hasTools &&
|
||||
!showReasoning &&
|
||||
!!msg.segments?.length &&
|
||||
msg.segments.every((s) => s.type === "reasoning");
|
||||
|
||||
// Stable seed from message id for deterministic silly-message selection
|
||||
const finalResponseSeed = useMemo(() => {
|
||||
let h = 0;
|
||||
@@ -1345,6 +1356,10 @@ const AssistantMessage = memo(function AssistantMessage({
|
||||
<text fg={t.textMuted} attributes={TextAttributes.ITALIC}>
|
||||
Empty response — model returned no content.
|
||||
</text>
|
||||
) : onlyHiddenReasoning ? (
|
||||
<text fg={t.textMuted} attributes={TextAttributes.ITALIC}>
|
||||
Thinking… (reasoning hidden)
|
||||
</text>
|
||||
) : autoLayout ? (
|
||||
<>
|
||||
{autoLayout.opening ? (
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { memo, useMemo, useRef } from "react";
|
||||
import { useTheme } from "../../core/theme/store.js";
|
||||
import { Markdown } from "./Markdown.js";
|
||||
import { ReasoningBlock } from "./ReasoningBlock.js";
|
||||
import { type LiveToolCall, ToolCallDisplay } from "./ToolCallDisplay.js";
|
||||
@@ -49,6 +51,7 @@ export const StreamSegmentList = memo(function StreamSegmentList({
|
||||
}) {
|
||||
// Build a stable id->call lookup. Allocating a new Map each render costs O(N)
|
||||
// but avoids the O(N²) of repeated linear lookups inside the render loop.
|
||||
const t = useTheme();
|
||||
const toolCallMap = useMemo(() => {
|
||||
const m = new Map<string, LiveToolCall>();
|
||||
for (const tc of toolCalls) m.set(tc.id, tc);
|
||||
@@ -120,9 +123,24 @@ export const StreamSegmentList = memo(function StreamSegmentList({
|
||||
return -1;
|
||||
}, [merged, streaming]);
|
||||
|
||||
// When reasoning is hidden, a turn that has only emitted reasoning so far has
|
||||
// no visible body — leaving a bare header while the model thinks. Show a
|
||||
// placeholder until the first visible (text/tools) content streams in.
|
||||
const hasVisibleContent = merged.some(
|
||||
(seg) =>
|
||||
(seg.type === "reasoning" && showReasoning) ||
|
||||
(seg.type === "text" && seg.content.trim().length > 0) ||
|
||||
seg.type === "tools",
|
||||
);
|
||||
|
||||
let lastVisibleType: string | null = null;
|
||||
return (
|
||||
<>
|
||||
{streaming && !hasVisibleContent ? (
|
||||
<text fg={t.textMuted} attributes={TextAttributes.ITALIC}>
|
||||
Thinking…
|
||||
</text>
|
||||
) : null}
|
||||
{merged.map((seg, i) => {
|
||||
if (seg.type === "reasoning" && !showReasoning) return null;
|
||||
|
||||
|
||||
+21
-9
@@ -2708,8 +2708,19 @@ export function useChat({
|
||||
abortController.abort();
|
||||
}, 10_000);
|
||||
|
||||
let streamEventCount = 0;
|
||||
let yieldBeforeNext = false;
|
||||
let lastYieldTs = Date.now();
|
||||
// Yield budget: hand the event loop a turn at most every ~8ms so the
|
||||
// renderer can paint streamed text. A microtask yield (Promise.resolve)
|
||||
// can't be starved by a busy event loop the way setTimeout(0) can.
|
||||
const YIELD_BUDGET_MS = 8;
|
||||
// Resolve Hearth bridge once. When no surface is paired we skip the
|
||||
// per-delta bridge streaming entirely, avoiding a microtask per delta
|
||||
// on the streaming hot path for the common local-TUI case.
|
||||
const hearthBridgeMod = await import("../hearth/bridge.js");
|
||||
const hearthLive = hearthBridgeMod.hearthBridge
|
||||
.listBindings()
|
||||
.some((b) => b.tabId === tabId);
|
||||
// Capture the iterator so the watchdog can call .return() on the SAME
|
||||
// instance the loop holds. Calling [Symbol.asyncIterator]() again would
|
||||
// try getReader() on an already-locked ReadableStream and throw.
|
||||
@@ -2720,9 +2731,10 @@ export function useChat({
|
||||
)[Symbol.asyncIterator]();
|
||||
for await (const part of { [Symbol.asyncIterator]: () => streamIterator }) {
|
||||
markActivity();
|
||||
if (yieldBeforeNext || ++streamEventCount % 5 === 0) {
|
||||
if (yieldBeforeNext || Date.now() - lastYieldTs >= YIELD_BUDGET_MS) {
|
||||
yieldBeforeNext = false;
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
lastYieldTs = Date.now();
|
||||
await Promise.resolve();
|
||||
}
|
||||
switch (part.type) {
|
||||
case "start-step": {
|
||||
@@ -2749,9 +2761,7 @@ export function useChat({
|
||||
case "reasoning-delta": {
|
||||
gotFirstContent = true;
|
||||
appendReasoningContent(part.text);
|
||||
void import("../hearth/bridge.js").then(({ reasoningStreamEmitter }) => {
|
||||
reasoningStreamEmitter.append(tabId, part.text);
|
||||
});
|
||||
if (hearthLive) hearthBridgeMod.reasoningStreamEmitter.append(tabId, part.text);
|
||||
break;
|
||||
}
|
||||
case "reasoning-end":
|
||||
@@ -2761,9 +2771,11 @@ export function useChat({
|
||||
gotFirstContent = true;
|
||||
appendText(part.text);
|
||||
// Stream visible text to Hearth surfaces in real time (coalesced).
|
||||
void import("../hearth/bridge.js").then(({ bridgeStreamEmitter }) => {
|
||||
bridgeStreamEmitter.stream(tabId, { type: "text", content: part.text });
|
||||
});
|
||||
if (hearthLive)
|
||||
hearthBridgeMod.bridgeStreamEmitter.stream(tabId, {
|
||||
type: "text",
|
||||
content: part.text,
|
||||
});
|
||||
queueMicrotaskFlush();
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user