feat(webui): polish agent output and app discovery

This commit is contained in:
Xubin Ren
2026-07-22 22:42:31 +08:00
parent b189a37648
commit aa8387fb4d
87 changed files with 6225 additions and 2379 deletions
+69 -23
View File
@@ -42,6 +42,7 @@ type UIMessageTurnFields = Pick<UIMessage, "turnId" | "turnPhase" | "turnSeq">;
const FILE_EDIT_TOOL_NAMES = new Set(["write_file", "edit_file", "apply_patch"]);
const STREAM_END_IDLE_DELAY_MS = 1000;
const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000;
function turnFieldsFromEvent(
ev: { turn_id?: string; turn_phase?: UITurnPhase; turn_seq?: number },
@@ -478,9 +479,12 @@ export interface SendAttachment {
export interface SendOptions {
cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[];
quotedContext?: string;
workspaceScope?: WorkspaceScopePayload | null;
sideChannel?: boolean;
finalizeActiveTurn?: boolean;
/** Append guidance to the running turn without detaching its active answer segment. */
continueActiveTurn?: boolean;
}
function eventExtendsModelActivity(ev: InboundEvent): boolean {
@@ -548,6 +552,7 @@ export function useNanobotStream(
const activitySegmentCounterRef = useRef(0);
const pendingStreamEventsRef = useRef<PendingStreamEvent[]>([]);
const streamFrameRef = useRef<number | null>(null);
const streamTimerRef = useRef<number | null>(null);
const suppressStreamUntilTurnEndRef = useRef(false);
const sideChannelTurnIdsRef = useRef<Set<string>>(new Set());
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
@@ -570,6 +575,10 @@ export function useNanobotStream(
window.cancelAnimationFrame(streamFrameRef.current);
streamFrameRef.current = null;
}
if (streamTimerRef.current !== null) {
window.clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
pendingStreamEventsRef.current = [];
}, []);
@@ -734,6 +743,10 @@ export function useNanobotStream(
window.cancelAnimationFrame(streamFrameRef.current);
streamFrameRef.current = null;
}
if (streamTimerRef.current !== null) {
window.clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
const events = pendingStreamEventsRef.current;
const finalAnswerText = options?.finalAnswerText;
const turn = options?.turn ?? {};
@@ -748,37 +761,47 @@ export function useNanobotStream(
const targetIndex =
resolveActiveAssistantIndex(next, turn)
?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current, turn);
if (targetIndex !== null) {
const target = next[targetIndex];
next = replaceMessageAt(next, targetIndex, {
...target,
if (targetIndex !== null) {
const target = next[targetIndex];
next = replaceMessageAt(next, targetIndex, {
...target,
content: finalAnswerText,
isStreaming: true,
...turn,
});
} else {
const id = crypto.randomUUID();
closedAssistantStreamIdsRef.current.add(id);
next = [
...next,
{
id,
role: "assistant",
content: finalAnswerText,
isStreaming: true,
...turn,
});
} else {
const id = crypto.randomUUID();
closedAssistantStreamIdsRef.current.add(id);
next = [
...next,
{
id,
role: "assistant",
content: finalAnswerText,
isStreaming: true,
...turn,
createdAt: Date.now(),
},
];
}
createdAt: Date.now(),
},
];
}
}
if (options?.closeAnswerSegment) closeActiveAssistantStream();
return next;
});
}, [applyPendingStreamEvents, closeActiveAssistantStream, resolveActiveAssistantIndex]);
const schedulePendingStreamFlush = useCallback(() => {
if (streamFrameRef.current !== null) return;
if (streamFrameRef.current !== null || streamTimerRef.current !== null) return;
if (document.visibilityState === "hidden") {
streamTimerRef.current = window.setTimeout(() => {
streamTimerRef.current = null;
const events = pendingStreamEventsRef.current;
if (events.length === 0) return;
pendingStreamEventsRef.current = [];
setMessages((prev) => applyPendingStreamEvents(prev, events));
}, BACKGROUND_STREAM_FLUSH_INTERVAL_MS);
return;
}
streamFrameRef.current = window.requestAnimationFrame(() => {
streamFrameRef.current = null;
const events = pendingStreamEventsRef.current;
@@ -788,6 +811,16 @@ export function useNanobotStream(
});
}, [applyPendingStreamEvents]);
useEffect(() => {
const flushOnReturn = () => {
if (document.visibilityState !== "visible") return;
if (pendingStreamEventsRef.current.length === 0) return;
flushPendingStreamEvents();
};
document.addEventListener("visibilitychange", flushOnReturn);
return () => document.removeEventListener("visibilitychange", flushOnReturn);
}, [flushPendingStreamEvents]);
// Reset local state when switching chats. Do not reset on every
// ``initialMessages`` update: a brand-new chat can receive an empty/404
// history response after the optimistic first message has already rendered.
@@ -863,6 +896,12 @@ export function useNanobotStream(
turn,
});
if (suppressStreamUntilTurnEndRef.current) return;
if (ev.resuming) {
cancelStreamEndTimer();
setIsStreaming(true);
setMessages((prev) => finalizeStreamedTurn(prev, turn));
return;
}
scheduleStreamEndTimer(turn);
return;
}
@@ -1144,6 +1183,7 @@ export function useNanobotStream(
const sideChannel = options?.sideChannel === true;
const finalizeActiveTurn = options?.finalizeActiveTurn === true;
const continueActiveTurn = options?.continueActiveTurn === true;
flushPendingStreamEvents();
if (finalizeActiveTurn) {
cancelStreamEndTimer();
@@ -1153,16 +1193,21 @@ export function useNanobotStream(
if (sideChannel) sideChannelTurnIdsRef.current.add(turnId);
const previews = hasAttachments ? images!.map((i) => i.preview) : undefined;
setMessages((prev) => {
if (!sideChannel || finalizeActiveTurn) {
if ((!sideChannel && !continueActiveTurn) || finalizeActiveTurn) {
buffer.current = null;
activeAssistantRef.current = null;
closedAssistantStreamIdsRef.current.clear();
clearActivitySegment();
suppressStreamUntilTurnEndRef.current = false;
} else if (continueActiveTurn) {
// Guidance belongs to the active backend turn. Preserve the answer
// cursor so its resuming stream_end can finalize the text already
// shown before the new user row, while starting fresh activity after it.
clearActivitySegment();
}
const base = finalizeActiveTurn ? finalizeStreamedTurn(prev) : prev;
return [
...(sideChannel ? base : pruneReasoningOnlyPlaceholders(base)),
...(sideChannel || continueActiveTurn ? base : pruneReasoningOnlyPlaceholders(base)),
{
id: crypto.randomUUID(),
role: "user",
@@ -1182,6 +1227,7 @@ export function useNanobotStream(
const wireOptions = { ...options, turnId };
delete wireOptions.sideChannel;
delete wireOptions.finalizeActiveTurn;
delete wireOptions.continueActiveTurn;
client.sendMessage(chatId, content, wireMedia, wireOptions);
},
[cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents],