feat(webui): context-usage indicator fed by custom stream events

This commit is contained in:
m4
2026-08-11 15:07:53 +08:00
parent 1351da2ee0
commit 94e64cc0c2
7 changed files with 314 additions and 1 deletions
@@ -27,7 +27,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
signal: request.signal,
cancelOnDisconnect: false,
lastEventId,
streamMode: ["messages", "updates", "values", "tasks"],
streamMode: ["messages", "updates", "values", "tasks", "custom"],
});
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
@@ -281,6 +281,9 @@ export async function POST(request: NextRequest, context: RouteContext) {
metadata,
config,
multitaskStrategy: "enqueue",
// langgraph dev only buffers the stream modes declared at run
// creation; joinStream subscribers receive nothing outside them.
streamMode: ["messages", "updates", "values", "tasks", "custom"],
}
);
} catch (error) {
+36
View File
@@ -36,6 +36,8 @@ import {
import { ChatMessage } from "@/app/components/ChatMessage";
import { ActionGroup } from "@/app/components/ActionGroup";
import { CompactionSummary } from "@/app/components/CompactionSummary";
import { ContextUsageIndicator } from "@/app/components/ContextUsageIndicator";
import { extractMeasuredInputTokens } from "@/lib/contextUsage";
import { ResearchDashboard } from "@/app/components/ResearchDashboard";
import { isSummarizationMessage } from "@/lib/summarization";
import { useCollapseAgentActions } from "@/lib/uiSettings";
@@ -509,6 +511,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
subAgentActivity,
asyncTasks,
summarizationEvent,
contextUsage,
modelSelection,
setModelSelection,
} = useChatContext();
@@ -771,6 +774,14 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
[selectableModels, modelSelection]
);
// Provider-measured prompt size of the most recent model call — the
// truthful numerator for the ctx pill (the middleware's stream event
// only estimates message text + fixed reserves).
const measuredInputTokens = useMemo(
() => extractMeasuredInputTokens(messages),
[messages]
);
// Bind captured sub-agent activity (keyed by subgraph namespace) to each task
// tool call → its live steps. B': match a finished sub-agent to a task by its
// final text == the task's result; assign still-running sub-agents to the
@@ -1811,11 +1822,21 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
<CompactionSummary
content={summarizationEvent.content}
summarizedCount={summarizationEvent.cutoffIndex}
estimatedTokensBefore={
summarizationEvent.estimatedTokensBefore
}
estimatedTokensAfter={
summarizationEvent.estimatedTokensAfter
}
budgetHardTokens={
summarizationEvent.budget?.hardTokens ?? null
}
/>
)}
<ChatMessage
message={data.message}
toolCalls={data.toolCalls}
threadId={threadId}
isLoading={isLoading}
isStreaming={isLoading && isLastMessage && isAssistant}
actionRequests={
@@ -1840,6 +1861,15 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
<CompactionSummary
content={summarizationEvent.content}
summarizedCount={summarizationEvent.cutoffIndex}
estimatedTokensBefore={
summarizationEvent.estimatedTokensBefore
}
estimatedTokensAfter={
summarizationEvent.estimatedTokensAfter
}
budgetHardTokens={
summarizationEvent.budget?.hardTokens ?? null
}
/>
)}
{askUserQuestions && reviewMode !== "full" && (
@@ -2326,6 +2356,12 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
}}
/>
)}
{threadId && (
<ContextUsageIndicator
usage={contextUsage}
measuredTokens={measuredInputTokens}
/>
)}
{threadId && (
<button
type="button"
@@ -0,0 +1,63 @@
"use client";
import React from "react";
import { CircleGauge } from "lucide-react";
import {
formatCompactTokens,
type ContextUsageEvent,
} from "@/lib/contextUsage";
import { cn } from "@/lib/utils";
/**
* Live context-occupancy pill.
*
* `measuredTokens` is the provider-measured prompt size of the most recent
* model call (covers system prompt, memory and tools) and is preferred over
* the middleware's message-text estimate, which systematically undercounts
* whenever prompts carry large non-message sections. The denominator is the
* snapshot-frozen `input_limit` carried by the stream event, so the pill
* appears once the session's first model call completes.
*/
export const ContextUsageIndicator = React.memo<{
usage: ContextUsageEvent | null;
measuredTokens: number | null;
}>(function ContextUsageIndicator({ usage, measuredTokens }) {
if (!usage) return null;
const estimated = usage.usedTokens + usage.reservedTokens;
const occupied = measuredTokens ?? estimated;
const limit = usage.inputLimit;
const percent = Math.max(0, Math.round((occupied / limit) * 100));
const nearlyFull = occupied >= limit * 0.85;
const label = `ctx ${percent}% · ${formatCompactTokens(
occupied
)}/${formatCompactTokens(limit)}`;
const tooltip =
(measuredTokens !== null
? `Last model call used ${occupied.toLocaleString()} of ` +
`${limit.toLocaleString()} tokens (measured)`
: `Context used (estimate): ${occupied.toLocaleString()} of ` +
`${limit.toLocaleString()} tokens`) +
` · auto-compact messages at ≈${usage.softTokens.toLocaleString()}.`;
return (
<span
title={tooltip}
aria-label={tooltip}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded px-1.5 py-0.5",
nearlyFull
? "text-amber-700 dark:text-amber-300"
: "text-muted-foreground"
)}
>
<CircleGauge
className="size-3.5 shrink-0"
aria-hidden="true"
/>
<span className="hidden sm:inline">{label}</span>
</span>
);
});
ContextUsageIndicator.displayName = "ContextUsageIndicator";
+18
View File
@@ -6,6 +6,10 @@ import { v4 as uuidv4 } from "uuid";
import type { TodoItem } from "@/app/types/types";
import { useQueryState } from "nuqs";
import { parseSummarizationEvent } from "@/lib/summarization";
import {
parseContextUsageEvent,
type ContextUsageEvent,
} from "@/lib/contextUsage";
import { findActiveTurnId } from "@/lib/usageTurn";
import { errorToast } from "@/lib/errorReporter";
import type { ModelRef, ThreadModelSelection } from "@/lib/modelRegistry";
@@ -356,6 +360,12 @@ export function useChat({
null
);
const [fetchedThreadId, setFetchedThreadId] = useState<string | null>(null);
// Latest context-occupancy event from the live run's custom stream, keyed
// by thread so a stale value never leaks across a thread switch.
const [contextUsageEntry, setContextUsageEntry] = useState<{
threadId: string;
usage: ContextUsageEvent;
} | null>(null);
const fetchedThreadIdRef = useRef<string | null>(null);
fetchedThreadIdRef.current = fetchedThreadId;
const recoveryRunRef = useRef(0);
@@ -915,6 +925,10 @@ export function useChat({
: undefined;
messageAccumulator.add(event.data[0], metadata);
}
if (event.event === "custom") {
const usage = parseContextUsageEvent(event.data);
if (usage) setContextUsageEntry({ threadId: tracked.threadId, usage });
}
scheduleStreamFlush();
if (event.event === "error") streamFailure = event.data;
}
@@ -1193,6 +1207,10 @@ export function useChat({
fetchedValues?._summarization_event
),
ui: fetchedValues?.ui,
contextUsage:
contextUsageEntry && contextUsageEntry.threadId === threadId
? contextUsageEntry.usage
: null,
setFiles,
messages,
isLoading: isRunLoading,
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import {
extractMeasuredInputTokens,
formatCompactTokens,
parseContextUsageEvent,
} from "@/lib/contextUsage";
function rawEvent(overrides: Record<string, unknown> = {}) {
return {
type: "evoscientist_context_usage",
used_tokens: 12300,
reserved_tokens: 16384,
input_limit: 1015808,
hard_tokens: 896000,
soft_tokens: 627200,
...overrides,
};
}
describe("parseContextUsageEvent", () => {
it("parses a well-formed event", () => {
expect(parseContextUsageEvent(rawEvent())).toEqual({
usedTokens: 12300,
reservedTokens: 16384,
inputLimit: 1015808,
hardTokens: 896000,
softTokens: 627200,
});
});
it("ignores custom events of other types", () => {
expect(parseContextUsageEvent({ type: "something_else" })).toBeNull();
expect(parseContextUsageEvent({})).toBeNull();
expect(parseContextUsageEvent(null)).toBeNull();
expect(parseContextUsageEvent("evoscientist_context_usage")).toBeNull();
});
it("drops malformed stats", () => {
expect(parseContextUsageEvent(rawEvent({ used_tokens: -1 }))).toBeNull();
expect(
parseContextUsageEvent(rawEvent({ used_tokens: "many" }))
).toBeNull();
expect(parseContextUsageEvent(rawEvent({ input_limit: 0 }))).toBeNull();
expect(
parseContextUsageEvent(rawEvent({ soft_tokens: undefined }))
).toBeNull();
});
});
describe("formatCompactTokens", () => {
it("formats small, kilo and mega ranges", () => {
expect(formatCompactTokens(999)).toBe("999");
expect(formatCompactTokens(12300)).toBe("12k");
expect(formatCompactTokens(54100)).toBe("54k");
expect(formatCompactTokens(1015808)).toBe("1M");
expect(formatCompactTokens(1536000)).toBe("1.5M");
expect(formatCompactTokens(12582912)).toBe("13M");
});
});
describe("extractMeasuredInputTokens", () => {
it("reads input_tokens from the last AI message with usage metadata", () => {
const messages = [
{ type: "human", content: "hi" },
{ type: "ai", content: "a", usage_metadata: { input_tokens: 1000 } },
{ type: "human", content: "again" },
{ type: "ai", content: "b", usage_metadata: { input_tokens: 42000 } },
];
expect(extractMeasuredInputTokens(messages)).toBe(42000);
});
it("falls back to response_metadata token_usage", () => {
const messages = [
{
type: "ai",
content: "a",
response_metadata: { token_usage: { prompt_tokens: 777 } },
},
];
expect(extractMeasuredInputTokens(messages)).toBe(777);
});
it("skips malformed entries and returns null when nothing is measured", () => {
expect(
extractMeasuredInputTokens([
{ type: "ai", content: "a", usage_metadata: { input_tokens: 0 } },
{ type: "human", content: "hi" },
])
).toBeNull();
expect(extractMeasuredInputTokens([])).toBeNull();
expect(extractMeasuredInputTokens(null)).toBeNull();
});
});
+100
View File
@@ -0,0 +1,100 @@
// Live context-occupancy indicator support.
//
// The backend's MessageBudgetMiddleware emits one custom stream event per
// model call carrying the character-estimated size of the prompt actually
// sent (message text) plus the frozen budget of the run snapshot:
//
// {
// type: "evoscientist_context_usage",
// used_tokens: number, // message-text estimate of the sent prompt
// reserved_tokens: number, // fixed system/tools/attachments reserves
// input_limit: number, // snapshot's resolved input limit
// hard_tokens: number, // auto-compaction ceiling (messages)
// soft_tokens: number, // auto-compaction trigger (messages)
// }
//
// `used_tokens + reserved_tokens` approximates total context occupancy; the
// provider-measured sizes keep flowing through the usage pipeline and land in
// the token-consumption view after the call completes.
export const CONTEXT_USAGE_EVENT_TYPE = "evoscientist_context_usage";
export interface ContextUsageEvent {
usedTokens: number;
reservedTokens: number;
inputLimit: number;
hardTokens: number;
softTokens: number;
}
function nonNegativeNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) && value >= 0
? value
: null;
}
/**
* Validate + normalize a raw custom-stream payload. Returns null for events
* of other types or malformed stats so unknown custom events pass through
* untouched.
*/
export function parseContextUsageEvent(
raw: unknown
): ContextUsageEvent | null {
if (!raw || typeof raw !== "object") return null;
const ev = raw as Record<string, unknown>;
if (ev["type"] !== CONTEXT_USAGE_EVENT_TYPE) return null;
const usedTokens = nonNegativeNumber(ev["used_tokens"]);
const reservedTokens = nonNegativeNumber(ev["reserved_tokens"]);
const inputLimit = nonNegativeNumber(ev["input_limit"]);
const hardTokens = nonNegativeNumber(ev["hard_tokens"]);
const softTokens = nonNegativeNumber(ev["soft_tokens"]);
if (
usedTokens === null ||
reservedTokens === null ||
inputLimit === null ||
inputLimit <= 0 ||
hardTokens === null ||
softTokens === null
) {
return null;
}
return { usedTokens, reservedTokens, inputLimit, hardTokens, softTokens };
}
/** Compact token count for the ctx pill: 54000 → "54k", 1048576 → "1M". */
export function formatCompactTokens(value: number): string {
if (value >= 1_000_000) {
const m = value / 1_000_000;
return `${m >= 10 ? Math.round(m) : m.toFixed(1).replace(/\.0$/, "")}M`;
}
if (value >= 1_000) return `${Math.round(value / 1_000)}k`;
return String(Math.round(value));
}
/**
* Provider-measured prompt size of the most recent model call, read from the
* last AI message's `usage_metadata.input_tokens` (langchain stamps it on
* every response; it covers the whole provider request — system prompt,
* memory, tools — unlike the middleware's message-text estimate). Returns
* null when no AI message carries measured usage yet.
*/
export function extractMeasuredInputTokens(messages: unknown): number | null {
if (!Array.isArray(messages)) return null;
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i] as Record<string, unknown> | null;
if (!m || typeof m !== "object" || m["type"] !== "ai") continue;
const usage = m["usage_metadata"] as Record<string, unknown> | undefined;
const measured = nonNegativeNumber(usage?.["input_tokens"]);
if (measured !== null && measured > 0) return measured;
const responseMeta = m["response_metadata"] as
| Record<string, unknown>
| undefined;
const tokenUsage = responseMeta?.["token_usage"] as
| Record<string, unknown>
| undefined;
const promptTokens = nonNegativeNumber(tokenUsage?.["prompt_tokens"]);
if (promptTokens !== null && promptTokens > 0) return promptTokens;
}
return null;
}