feat(webui): refine output timeline and model controls (#4108)
* feat(webui): refine output timeline and composer queue * feat(webui): add provider model picker * fix(webui): polish model settings and heartbeat checks * chore: keep heartbeat changes out of webui pr * refactor(webui): isolate settings routes * fix(providers): align minimax anthropic test * fix(providers): keep minimax anthropic base sdk-compatible * fix(providers): normalize anthropic base urls
This commit is contained in:
@@ -2,15 +2,13 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
import {
|
||||
AgentActivityCluster,
|
||||
isAgentActivityMember,
|
||||
} from "@/components/thread/AgentActivityCluster";
|
||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||
import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
|
||||
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
||||
|
||||
interface ThreadMessagesProps {
|
||||
messages: UIMessage[];
|
||||
/** When true, agent turn still in flight — keeps activity cluster expanded. */
|
||||
/** When true, agent turn still in flight — keeps activity timeline expanded. */
|
||||
isStreaming?: boolean;
|
||||
hiddenMessageCount?: number;
|
||||
onLoadEarlier?: () => void;
|
||||
@@ -18,9 +16,7 @@ interface ThreadMessagesProps {
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
}
|
||||
|
||||
export type DisplayUnit =
|
||||
| { type: "cluster"; messages: UIMessage[] }
|
||||
| { type: "single"; message: UIMessage };
|
||||
export type DisplayUnit = TurnUnit;
|
||||
|
||||
/** True when this unit index is the last assistant text slice before the next user message (or end of thread). */
|
||||
export function isFinalAssistantSliceBeforeNextUser(
|
||||
@@ -28,170 +24,17 @@ export function isFinalAssistantSliceBeforeNextUser(
|
||||
index: number,
|
||||
): boolean {
|
||||
const u = units[index];
|
||||
if (u.type !== "single" || u.message.role !== "assistant") return true;
|
||||
if (u.type !== "message" || u.message.role !== "assistant") return true;
|
||||
for (let j = index + 1; j < units.length; j++) {
|
||||
const v = units[j];
|
||||
if (v.type === "single" && v.message.role === "user") break;
|
||||
if (v.type === "message" && v.message.role === "user") break;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
||||
const out: DisplayUnit[] = [];
|
||||
let i = 0;
|
||||
while (i < messages.length) {
|
||||
const m = messages[i];
|
||||
if (isAgentActivityMember(m)) {
|
||||
const cluster: UIMessage[] = [];
|
||||
let segmentId: string | undefined = m.activitySegmentId;
|
||||
let clusterHasFileEdits = hasFileEdits(m);
|
||||
while (
|
||||
i < messages.length
|
||||
&& isAgentActivityMember(messages[i])
|
||||
&& canJoinActivityCluster(segmentId, clusterHasFileEdits, messages[i])
|
||||
) {
|
||||
const current = messages[i];
|
||||
if (!segmentId && current.activitySegmentId) {
|
||||
segmentId = current.activitySegmentId;
|
||||
}
|
||||
clusterHasFileEdits = clusterHasFileEdits || hasFileEdits(current);
|
||||
cluster.push(current);
|
||||
i += 1;
|
||||
}
|
||||
pushActivityCluster(out, cluster);
|
||||
continue;
|
||||
}
|
||||
const previous = out[out.length - 1];
|
||||
if (
|
||||
previous?.type === "cluster"
|
||||
&& assistantHasInlineReasoning(m)
|
||||
&& canFoldInlineReasoning(previous.messages, m)
|
||||
) {
|
||||
previous.messages.push(reasoningOnlyMessageFromAnswer(m));
|
||||
out.push({ type: "single", message: stripInlineReasoning(m) });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (assistantHasInlineReasoning(m)) {
|
||||
out.push({ type: "cluster", messages: [reasoningOnlyMessageFromAnswer(m)] });
|
||||
out.push({ type: "single", message: stripInlineReasoning(m) });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
out.push({ type: "single", message: m });
|
||||
i += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pushActivityCluster(out: DisplayUnit[], cluster: UIMessage[]) {
|
||||
const previous = out[out.length - 1];
|
||||
if (
|
||||
previous?.type !== "single"
|
||||
|| !shouldPlaceLateActivityBeforeAssistant(out, previous.message)
|
||||
) {
|
||||
out.push({ type: "cluster", messages: cluster });
|
||||
return;
|
||||
}
|
||||
|
||||
const beforeAssistant = out[out.length - 2];
|
||||
if (beforeAssistant?.type === "cluster" && canMergeActivityClusters(beforeAssistant.messages, cluster)) {
|
||||
beforeAssistant.messages.push(...cluster);
|
||||
return;
|
||||
}
|
||||
|
||||
out.splice(out.length - 1, 0, { type: "cluster", messages: cluster });
|
||||
}
|
||||
|
||||
function shouldPlaceLateActivityBeforeAssistant(out: DisplayUnit[], message: UIMessage): boolean {
|
||||
if (message.role !== "assistant" || message.kind === "trace") return false;
|
||||
if (message.isStreaming) return true;
|
||||
if (hasTurnLatency(message)) return true;
|
||||
|
||||
const beforeAssistant = out[out.length - 2];
|
||||
return beforeAssistant?.type === "cluster";
|
||||
}
|
||||
|
||||
function hasTurnLatency(message: UIMessage): boolean {
|
||||
return (
|
||||
typeof message.latencyMs === "number"
|
||||
&& Number.isFinite(message.latencyMs)
|
||||
&& message.latencyMs >= 0
|
||||
);
|
||||
}
|
||||
|
||||
function clusterSegmentId(messages: UIMessage[]): string | undefined {
|
||||
return messages.find((message) => message.activitySegmentId)?.activitySegmentId;
|
||||
}
|
||||
|
||||
function hasFileEdits(message: UIMessage): boolean {
|
||||
return !!message.fileEdits?.length;
|
||||
}
|
||||
|
||||
function clusterHasFileEdits(messages: UIMessage[]): boolean {
|
||||
return messages.some(hasFileEdits);
|
||||
}
|
||||
|
||||
function canJoinActivityCluster(
|
||||
clusterSegmentId: string | undefined,
|
||||
clusterIncludesFileEdits: boolean,
|
||||
message: UIMessage,
|
||||
): boolean {
|
||||
const messageHasFileEdits = hasFileEdits(message);
|
||||
if (!clusterIncludesFileEdits && !messageHasFileEdits) return true;
|
||||
if (!clusterSegmentId || !message.activitySegmentId) return true;
|
||||
return clusterSegmentId === message.activitySegmentId;
|
||||
}
|
||||
|
||||
function canFoldInlineReasoning(cluster: UIMessage[], message: UIMessage): boolean {
|
||||
if (!clusterHasFileEdits(cluster) && !hasFileEdits(message)) return true;
|
||||
const segmentId = clusterSegmentId(cluster);
|
||||
if (!segmentId || !message.activitySegmentId) return true;
|
||||
return segmentId === message.activitySegmentId;
|
||||
}
|
||||
|
||||
function canMergeActivityClusters(target: UIMessage[], incoming: UIMessage[]): boolean {
|
||||
let segmentId = clusterSegmentId(target);
|
||||
let includesFileEdits = clusterHasFileEdits(target);
|
||||
for (const message of incoming) {
|
||||
if (!canJoinActivityCluster(segmentId, includesFileEdits, message)) return false;
|
||||
if (!segmentId && message.activitySegmentId) {
|
||||
segmentId = message.activitySegmentId;
|
||||
}
|
||||
includesFileEdits = includesFileEdits || hasFileEdits(message);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function assistantHasInlineReasoning(message: UIMessage): boolean {
|
||||
return (
|
||||
message.role === "assistant"
|
||||
&& message.kind !== "trace"
|
||||
&& message.content.trim().length > 0
|
||||
&& (!!message.reasoning?.trim() || !!message.reasoningStreaming)
|
||||
);
|
||||
}
|
||||
|
||||
function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
|
||||
return {
|
||||
id: `${message.id}-reasoning`,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
createdAt: message.createdAt,
|
||||
reasoning: message.reasoning,
|
||||
reasoningStreaming: message.reasoningStreaming,
|
||||
isStreaming: message.reasoningStreaming,
|
||||
activitySegmentId: message.activitySegmentId,
|
||||
latencyMs: message.latencyMs,
|
||||
};
|
||||
}
|
||||
|
||||
function stripInlineReasoning(message: UIMessage): UIMessage {
|
||||
const next = { ...message };
|
||||
delete next.reasoning;
|
||||
delete next.reasoningStreaming;
|
||||
return next;
|
||||
return normalizeActivityTimeline(messages);
|
||||
}
|
||||
|
||||
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
|
||||
@@ -199,11 +42,11 @@ export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
|
||||
let hasLaterUnitBeforeUser = false;
|
||||
for (let i = units.length - 1; i >= 0; i -= 1) {
|
||||
const unit = units[i];
|
||||
if (unit.type === "single" && unit.message.role === "user") {
|
||||
if (unit.type === "message" && unit.message.role === "user") {
|
||||
hasLaterUnitBeforeUser = false;
|
||||
continue;
|
||||
}
|
||||
if (unit.type === "single" && unit.message.role === "assistant") {
|
||||
if (unit.type === "message" && unit.message.role === "assistant") {
|
||||
flags[i] = !hasLaterUnitBeforeUser;
|
||||
}
|
||||
hasLaterUnitBeforeUser = true;
|
||||
@@ -222,8 +65,8 @@ export function ThreadMessages({
|
||||
const { t } = useTranslation();
|
||||
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
|
||||
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
|
||||
const liveActivityClusterIndex = useMemo(
|
||||
() => isStreaming ? currentActivityClusterIndex(units) : -1,
|
||||
const liveActivityClusterIndices = useMemo(
|
||||
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
|
||||
[isStreaming, units],
|
||||
);
|
||||
|
||||
@@ -251,20 +94,18 @@ export function ThreadMessages({
|
||||
: "";
|
||||
const next = units[index + 1];
|
||||
const hasBodyBelow =
|
||||
unit.type === "cluster"
|
||||
&& next?.type === "single"
|
||||
unit.type === "activity"
|
||||
&& next?.type === "message"
|
||||
&& next.message.role === "assistant";
|
||||
const turnLatencyMs =
|
||||
unit.type === "cluster" ? activityClusterTurnLatencyMs(unit.messages, next) : undefined;
|
||||
|
||||
return (
|
||||
<div key={unitKey(unit, index)} className={marginTop}>
|
||||
{unit.type === "cluster" ? (
|
||||
{unit.type === "activity" ? (
|
||||
<AgentActivityCluster
|
||||
messages={unit.messages}
|
||||
isTurnStreaming={index === liveActivityClusterIndex}
|
||||
isTurnStreaming={liveActivityClusterIndices.has(index)}
|
||||
hasBodyBelow={hasBodyBelow}
|
||||
turnLatencyMs={turnLatencyMs}
|
||||
turnLatencyMs={unit.turnLatencyMs}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
/>
|
||||
@@ -287,49 +128,45 @@ export function ThreadMessages({
|
||||
);
|
||||
}
|
||||
|
||||
function activityClusterTurnLatencyMs(
|
||||
messages: UIMessage[],
|
||||
next: DisplayUnit | undefined,
|
||||
): number | undefined {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const latency = messages[i].latencyMs;
|
||||
if (typeof latency === "number" && Number.isFinite(latency) && latency >= 0) {
|
||||
return latency;
|
||||
}
|
||||
}
|
||||
if (
|
||||
next?.type === "single"
|
||||
&& next.message.role === "assistant"
|
||||
&& typeof next.message.latencyMs === "number"
|
||||
&& Number.isFinite(next.message.latencyMs)
|
||||
&& next.message.latencyMs >= 0
|
||||
) {
|
||||
return next.message.latencyMs;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function currentActivityClusterIndex(units: DisplayUnit[]): number {
|
||||
function currentActivityClusterIndices(units: DisplayUnit[]): Set<number> {
|
||||
const indices = new Set<number>();
|
||||
let markedCurrentActivity = false;
|
||||
for (let i = units.length - 1; i >= 0; i -= 1) {
|
||||
const unit = units[i];
|
||||
if (unit.type === "cluster") return i;
|
||||
if (unit.type === "activity") {
|
||||
if (!markedCurrentActivity) {
|
||||
indices.add(i);
|
||||
markedCurrentActivity = true;
|
||||
continue;
|
||||
}
|
||||
if (activityHasLiveFileEdit(unit)) {
|
||||
indices.add(i);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (unit.message.role === "assistant" && unit.message.isStreaming) continue;
|
||||
if (unit.message.role === "user") break;
|
||||
return -1;
|
||||
}
|
||||
return -1;
|
||||
return indices;
|
||||
}
|
||||
|
||||
function activityHasLiveFileEdit(unit: Extract<DisplayUnit, { type: "activity" }>): boolean {
|
||||
return unit.messages.some((message) => (
|
||||
message.kind === "trace"
|
||||
&& message.fileEdits?.some((edit) => edit.status === "editing" || edit.pending || !edit.path)
|
||||
));
|
||||
}
|
||||
|
||||
function unitKey(unit: DisplayUnit, index: number): string {
|
||||
if (unit.type === "cluster") {
|
||||
if (unit.type === "activity") {
|
||||
const anchor = unit.messages[0]?.id;
|
||||
return anchor != null ? `cluster-${anchor}` : `cluster-idx-${index}`;
|
||||
return anchor != null ? `activity-${anchor}` : `activity-idx-${index}`;
|
||||
}
|
||||
return unit.message.id;
|
||||
}
|
||||
|
||||
function marginAfterPrevUnit(prev: DisplayUnit): string {
|
||||
if (prev.type === "cluster") {
|
||||
if (prev.type === "activity") {
|
||||
return "mt-4";
|
||||
}
|
||||
const p = prev.message;
|
||||
|
||||
Reference in New Issue
Block a user