feat: update version to 0.0.8 and implement conversation compaction summary feature
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@evoscientist/webui",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@evoscientist/webui",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.8",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@langchain/core": "1.1.19",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@evoscientist/webui",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.8",
|
||||
"description": "Web UI for EvoScientist — a self-evolving AI scientist built on DeepAgents/LangGraph.",
|
||||
"author": "Xi Zhang <zacharyzhang2022@gmail.com>",
|
||||
"type": "module",
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { ChatMessage } from "@/app/components/ChatMessage";
|
||||
import { CompactionSummary } from "@/app/components/CompactionSummary";
|
||||
import { isSummarizationMessage } from "@/lib/summarization";
|
||||
import {
|
||||
AskUserInterrupt,
|
||||
type AskUserQuestion,
|
||||
@@ -222,6 +224,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
resumeInterrupt,
|
||||
subAgentActivity,
|
||||
asyncTasks,
|
||||
summarizationEvent,
|
||||
} = useChatContext();
|
||||
|
||||
// Count of background async sub-agents (writing / data-analysis) still
|
||||
@@ -660,6 +663,13 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
const meta = stream.getMessagesMetadata(message)?.streamMetadata;
|
||||
const ns = meta?.["langgraph_checkpoint_ns"];
|
||||
if (typeof ns === "string" && ns.includes("|")) return false;
|
||||
// The conversation-compaction summary is generated by a SEPARATE LLM
|
||||
// call (its own "Context Extraction Assistant" system prompt, like the
|
||||
// tool-selector). Its output transiently leaks into the raw stream as an
|
||||
// AI message (## SESSION INTENT / ## SUMMARY / …) then vanishes — it is
|
||||
// never persisted in `messages`. Drop it here; the stable summary is
|
||||
// surfaced from `_summarization_event` as a collapsible block instead.
|
||||
if (isSummarizationMessage(message)) return false;
|
||||
if (message.type !== "human") return true;
|
||||
const key = asyncUpdateMessageKey(
|
||||
extractStringFromMessageContent(message)
|
||||
@@ -738,6 +748,25 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
});
|
||||
}, [messages, interrupt, isLoading, stream]);
|
||||
|
||||
// Where to anchor the "Conversation compacted" block. The event's
|
||||
// cutoffIndex points into the raw `messages` array (messages[0:cutoff] were
|
||||
// summarized); we render the block right before the first message AFTER the
|
||||
// cutoff so it reads as "everything above was folded into this summary". If
|
||||
// that boundary message isn't in the rendered list (e.g. cutoff past the
|
||||
// end), fall back to appending the block after the transcript.
|
||||
const compactionAnchorId = useMemo(() => {
|
||||
if (!summarizationEvent) return null;
|
||||
const processedIds = new Set(processedMessages.map((d) => d.message.id));
|
||||
// Anchor before the first STILL-VISIBLE message at or after the cutoff, so
|
||||
// a filtered boundary message (a tool result, or the transient summary
|
||||
// leak itself) doesn't bump the block to the very end of the transcript.
|
||||
for (let i = summarizationEvent.cutoffIndex; i < messages.length; i++) {
|
||||
const id = messages[i]?.id;
|
||||
if (id != null && processedIds.has(id)) return id;
|
||||
}
|
||||
return null;
|
||||
}, [summarizationEvent, messages, processedMessages]);
|
||||
|
||||
const groupedTodos = {
|
||||
in_progress: todos.filter((t) => t.status === "in_progress"),
|
||||
pending: todos.filter((t) => t.status === "pending"),
|
||||
@@ -876,30 +905,45 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
(u: any) => u.metadata?.message_id === data.message.id
|
||||
);
|
||||
const isLastMessage = index === processedMessages.length - 1;
|
||||
const showCompactionBefore =
|
||||
compactionAnchorId === data.message.id;
|
||||
return (
|
||||
<ChatMessage
|
||||
key={data.message.id}
|
||||
message={data.message}
|
||||
toolCalls={data.toolCalls}
|
||||
isLoading={isLoading}
|
||||
actionRequests={
|
||||
isLastMessage ? actionRequests : undefined
|
||||
}
|
||||
submittedActionRequestKeys={submittedActionRequestKeys}
|
||||
onActionRequestSubmitted={markActionRequestSubmitted}
|
||||
reviewConfigsMap={
|
||||
isLastMessage ? reviewConfigsMap : undefined
|
||||
}
|
||||
ui={messageUi}
|
||||
stream={stream}
|
||||
onResumeInterrupt={resumeInterrupt}
|
||||
graphId={assistant?.graph_id}
|
||||
onEditMessage={handleEditMessage}
|
||||
autoApprove={autoApprove}
|
||||
subAgentSteps={subAgentSteps}
|
||||
/>
|
||||
<React.Fragment key={data.message.id}>
|
||||
{showCompactionBefore && summarizationEvent && (
|
||||
<CompactionSummary
|
||||
content={summarizationEvent.content}
|
||||
summarizedCount={summarizationEvent.cutoffIndex}
|
||||
/>
|
||||
)}
|
||||
<ChatMessage
|
||||
message={data.message}
|
||||
toolCalls={data.toolCalls}
|
||||
isLoading={isLoading}
|
||||
actionRequests={
|
||||
isLastMessage ? actionRequests : undefined
|
||||
}
|
||||
submittedActionRequestKeys={submittedActionRequestKeys}
|
||||
onActionRequestSubmitted={markActionRequestSubmitted}
|
||||
reviewConfigsMap={
|
||||
isLastMessage ? reviewConfigsMap : undefined
|
||||
}
|
||||
ui={messageUi}
|
||||
stream={stream}
|
||||
onResumeInterrupt={resumeInterrupt}
|
||||
graphId={assistant?.graph_id}
|
||||
onEditMessage={handleEditMessage}
|
||||
autoApprove={autoApprove}
|
||||
subAgentSteps={subAgentSteps}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{summarizationEvent && !compactionAnchorId && (
|
||||
<CompactionSummary
|
||||
content={summarizationEvent.content}
|
||||
summarizedCount={summarizationEvent.cutoffIndex}
|
||||
/>
|
||||
)}
|
||||
{askUserQuestions && (
|
||||
<div className="mt-4">
|
||||
<AskUserInterrupt
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ChevronRight, Layers } from "lucide-react";
|
||||
import { MarkdownContent } from "@/app/components/MarkdownContent";
|
||||
import { extractSummaryBody } from "@/lib/summarization";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CompactionSummaryProps {
|
||||
/** Raw summary message content (wrapper + body). */
|
||||
content: string;
|
||||
/** Number of earlier messages folded into this summary, if known. */
|
||||
summarizedCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a conversation-compaction summary as a collapsible block, mirroring
|
||||
* the "Thinking" disclosure in ChatMessage. Collapsed by default so the (often
|
||||
* large) summary doesn't dominate the transcript — the backend keeps the full
|
||||
* history intact, this is just the context the agent was handed after compaction.
|
||||
*/
|
||||
export const CompactionSummary = React.memo<CompactionSummaryProps>(
|
||||
({ content, summarizedCount }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const body = extractSummaryBody(content);
|
||||
|
||||
return (
|
||||
<div className="my-3 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/40 px-3 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<Layers
|
||||
className="size-3.5 text-[var(--brand)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Conversation compacted
|
||||
{typeof summarizedCount === "number" && summarizedCount > 0 && (
|
||||
<span className="text-muted-foreground/70">
|
||||
· {summarizedCount} earlier{" "}
|
||||
{summarizedCount === 1 ? "message" : "messages"} summarized
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-3.5 transition-transform",
|
||||
open && "rotate-90"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
{open && (
|
||||
<div className="mt-2 overflow-hidden rounded-md border border-border bg-muted/20 px-4 py-3">
|
||||
<MarkdownContent content={body} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CompactionSummary.displayName = "CompactionSummary";
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
extractSubAgentSteps,
|
||||
type SubAgentStep,
|
||||
} from "@/lib/subAgentActivity";
|
||||
import { parseSummarizationEvent } from "@/lib/summarization";
|
||||
|
||||
export type StateType = {
|
||||
messages: Message[];
|
||||
@@ -25,6 +26,10 @@ export type StateType = {
|
||||
// Background async sub-agents (writing-agent / data-analysis-agent) this
|
||||
// conversation launched, keyed by task_id. Shape = deepagents' AsyncTask.
|
||||
async_tasks?: Record<string, unknown>;
|
||||
// Private state field set by the deepagents SummarizationMiddleware when the
|
||||
// conversation is compacted. langgraph dev exposes it over the SDK; the UI
|
||||
// surfaces it as a collapsible "Conversation compacted" block.
|
||||
_summarization_event?: unknown;
|
||||
ui?: any;
|
||||
};
|
||||
|
||||
@@ -349,6 +354,9 @@ export function useChat({
|
||||
files: stream.values.files ?? {},
|
||||
email: stream.values.email,
|
||||
asyncTasks: stream.values.async_tasks ?? {},
|
||||
summarizationEvent: parseSummarizationEvent(
|
||||
stream.values._summarization_event
|
||||
),
|
||||
ui: stream.values.ui,
|
||||
setFiles,
|
||||
messages,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Conversation-compaction (context summarization) support.
|
||||
//
|
||||
// When the backend (deepagents SummarizationMiddleware) compacts the
|
||||
// conversation to stay under the context window, it does NOT rewrite the
|
||||
// persisted `messages` — the full history stays intact. Instead it records the
|
||||
// summary in a private state field `_summarization_event`:
|
||||
//
|
||||
// {
|
||||
// cutoff_index: number, // summarized messages[0:cutoff_index]
|
||||
// summary_message: HumanMessage, // additional_kwargs.lc_source="summarization"
|
||||
// file_path: string | null, // offloaded full history (e.g. /conversation_history/<tid>.md)
|
||||
// }
|
||||
//
|
||||
// langgraph dev exposes this underscore-prefixed key over the SDK (verified on
|
||||
// the live backend), so the UI can surface the summary as a tidy collapsible
|
||||
// block instead of letting it flash by mid-stream and vanish.
|
||||
//
|
||||
// The summary_message content is wrapped in one of two templates (deepagents
|
||||
// `_build_new_messages_with_path`):
|
||||
//
|
||||
// "You are in the middle of a conversation that has been summarized.\n\n
|
||||
// The full conversation history has been saved to {path} ...\n\n
|
||||
// A condensed summary follows:\n\n<summary>\n{body}\n</summary>"
|
||||
//
|
||||
// or, when offload failed:
|
||||
//
|
||||
// "Here is a summary of the conversation to date:\n\n{body}"
|
||||
//
|
||||
// `{body}` is the LLM-authored summary (## SESSION INTENT / ## SUMMARY /
|
||||
// ## ARTIFACTS / ## NEXT STEPS).
|
||||
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { extractStringFromMessageContent } from "@/app/utils/utils";
|
||||
|
||||
export interface SummarizationEvent {
|
||||
cutoffIndex: number;
|
||||
/** The raw summary message content (wrapper + body). */
|
||||
content: string;
|
||||
/** Path where the full history was offloaded, or null. */
|
||||
filePath: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker deepagents/langchain stamps on the summary HumanMessage's
|
||||
* `additional_kwargs`. Most reliable signal when the summary lands in
|
||||
* `messages` (langchain `before_model` path) rather than `_summarization_event`.
|
||||
*/
|
||||
function isSummarizationMarkerMessage(message: Message): boolean {
|
||||
if (message.type !== "human") return false;
|
||||
const ak = (message as { additional_kwargs?: Record<string, unknown> })
|
||||
.additional_kwargs;
|
||||
return ak?.["lc_source"] === "summarization";
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-pattern fallback: true when the text looks like a compaction summary.
|
||||
* Matches the wrapper templates OR the default summary section structure
|
||||
* (so it also catches a transient mid-stream summary that carries no marker).
|
||||
*/
|
||||
export function isSummarizationContent(
|
||||
text: string | null | undefined
|
||||
): boolean {
|
||||
if (!text) return false;
|
||||
if (
|
||||
/\bconversation that has been summarized\b/.test(text) ||
|
||||
/\bHere is a summary of the conversation to date\b/.test(text) ||
|
||||
/<summary>[\s\S]*<\/summary>/.test(text)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// The summary always OPENS with "## SESSION INTENT" (first section of the
|
||||
// DEFAULT_SUMMARY_PROMPT). Match on that lead header alone so detection fires
|
||||
// the moment it streams in — requiring all sections (… NEXT STEPS) let the
|
||||
// partially-streamed summary leak into the transcript until the last header
|
||||
// arrived. "SESSION INTENT" is summarization-specific vocabulary; normal
|
||||
// assistant answers don't emit it, so a single-header match is safe.
|
||||
return /##\s+SESSION INTENT\b/.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a message is a compaction summary.
|
||||
*
|
||||
* Human messages are matched ONLY by the `lc_source` marker (the persisted
|
||||
* summary carries it) — never by content — so a user who happens to type
|
||||
* "## SESSION INTENT" is not silently dropped from the transcript. The transient
|
||||
* mid-stream summary leak is an AI message with no marker, so it is matched by
|
||||
* content via the shared extractor (which also flattens array/content-block
|
||||
* message shapes that a bare `typeof content === "string"` check would miss).
|
||||
*/
|
||||
export function isSummarizationMessage(message: Message): boolean {
|
||||
if (message.type === "human") return isSummarizationMarkerMessage(message);
|
||||
if (message.type !== "ai") return false;
|
||||
return isSummarizationContent(extractStringFromMessageContent(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the wrapper templates and return just the LLM-authored summary body
|
||||
* (## SESSION INTENT …). Falls back to the trimmed input when no wrapper is
|
||||
* recognized.
|
||||
*/
|
||||
export function extractSummaryBody(raw: string): string {
|
||||
if (!raw) return "";
|
||||
// Prefer the explicit <summary>…</summary> region.
|
||||
const tagged = raw.match(/<summary>\s*([\s\S]*?)\s*<\/summary>/);
|
||||
if (tagged) return tagged[1].trim();
|
||||
// "Here is a summary of the conversation to date:\n\n{body}"
|
||||
const lead = raw.match(
|
||||
/Here is a summary of the conversation to date:\s*([\s\S]*)$/
|
||||
);
|
||||
if (lead) return lead[1].trim();
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + normalize the raw `_summarization_event` from thread state.
|
||||
* Returns null when absent or malformed.
|
||||
*/
|
||||
export function parseSummarizationEvent(
|
||||
raw: unknown
|
||||
): SummarizationEvent | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const ev = raw as Record<string, unknown>;
|
||||
const sm = ev["summary_message"] as
|
||||
| { content?: unknown; additional_kwargs?: Record<string, unknown> }
|
||||
| undefined;
|
||||
const content = typeof sm?.content === "string" ? sm.content : "";
|
||||
if (!content) return null;
|
||||
const cutoff = ev["cutoff_index"];
|
||||
const filePath = ev["file_path"];
|
||||
return {
|
||||
cutoffIndex: typeof cutoff === "number" && cutoff >= 0 ? cutoff : 0,
|
||||
content,
|
||||
filePath: typeof filePath === "string" ? filePath : null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user