Web UI: show Compaction divider in chat history (#11341)
parent
f0722498a4
commit
82419eaad6
|
|
@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
import {
|
import {
|
||||||
readFirstUserMessageFromTranscript,
|
readFirstUserMessageFromTranscript,
|
||||||
readLastMessagePreviewFromTranscript,
|
readLastMessagePreviewFromTranscript,
|
||||||
|
readSessionMessages,
|
||||||
readSessionPreviewItemsFromTranscript,
|
readSessionPreviewItemsFromTranscript,
|
||||||
} from "./session-utils.fs.js";
|
} from "./session-utils.fs.js";
|
||||||
|
|
||||||
|
|
@ -343,6 +344,53 @@ describe("readLastMessagePreviewFromTranscript", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("readSessionMessages", () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let storePath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-fs-test-"));
|
||||||
|
storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes synthetic compaction markers for compaction entries", () => {
|
||||||
|
const sessionId = "test-session-compaction";
|
||||||
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||||
|
JSON.stringify({ message: { role: "user", content: "Hello" } }),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "compaction",
|
||||||
|
id: "comp-1",
|
||||||
|
timestamp: "2026-02-07T00:00:00.000Z",
|
||||||
|
summary: "Compacted history",
|
||||||
|
firstKeptEntryId: "x",
|
||||||
|
tokensBefore: 123,
|
||||||
|
}),
|
||||||
|
JSON.stringify({ message: { role: "assistant", content: "World" } }),
|
||||||
|
];
|
||||||
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
|
const out = readSessionMessages(sessionId, storePath);
|
||||||
|
expect(out).toHaveLength(3);
|
||||||
|
const marker = out[1] as {
|
||||||
|
role: string;
|
||||||
|
content?: Array<{ text?: string }>;
|
||||||
|
__openclaw?: { kind?: string; id?: string };
|
||||||
|
timestamp?: number;
|
||||||
|
};
|
||||||
|
expect(marker.role).toBe("system");
|
||||||
|
expect(marker.content?.[0]?.text).toBe("Compaction");
|
||||||
|
expect(marker.__openclaw?.kind).toBe("compaction");
|
||||||
|
expect(marker.__openclaw?.id).toBe("comp-1");
|
||||||
|
expect(typeof marker.timestamp).toBe("number");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("readSessionPreviewItemsFromTranscript", () => {
|
describe("readSessionPreviewItemsFromTranscript", () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let storePath: string;
|
let storePath: string;
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,23 @@ export function readSessionMessages(
|
||||||
const parsed = JSON.parse(line);
|
const parsed = JSON.parse(line);
|
||||||
if (parsed?.message) {
|
if (parsed?.message) {
|
||||||
messages.push(parsed.message);
|
messages.push(parsed.message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compaction entries are not "message" records, but they're useful context for debugging.
|
||||||
|
// Emit a lightweight synthetic message that the Web UI can render as a divider.
|
||||||
|
if (parsed?.type === "compaction") {
|
||||||
|
const ts = typeof parsed.timestamp === "string" ? Date.parse(parsed.timestamp) : Number.NaN;
|
||||||
|
const timestamp = Number.isFinite(ts) ? ts : Date.now();
|
||||||
|
messages.push({
|
||||||
|
role: "system",
|
||||||
|
content: [{ type: "text", text: "Compaction" }],
|
||||||
|
timestamp,
|
||||||
|
__openclaw: {
|
||||||
|
kind: "compaction",
|
||||||
|
id: typeof parsed.id === "string" ? parsed.id : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore bad lines
|
// ignore bad lines
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,33 @@
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Chat divider (e.g., compaction marker) */
|
||||||
|
.chat-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 18px 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-divider__line {
|
||||||
|
flex: 1 1 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-divider__label {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
/* Avatar Styles */
|
/* Avatar Styles */
|
||||||
.chat-avatar {
|
.chat-avatar {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
/** Union type for items in the chat thread */
|
/** Union type for items in the chat thread */
|
||||||
export type ChatItem =
|
export type ChatItem =
|
||||||
| { kind: "message"; key: string; message: unknown }
|
| { kind: "message"; key: string; message: unknown }
|
||||||
|
| { kind: "divider"; key: string; label: string; timestamp: number }
|
||||||
| { kind: "stream"; key: string; text: string; startedAt: number }
|
| { kind: "stream"; key: string; text: string; startedAt: number }
|
||||||
| { kind: "reading-indicator"; key: string };
|
| { kind: "reading-indicator"; key: string };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,16 @@ export function renderChat(props: ChatProps) {
|
||||||
buildChatItems(props),
|
buildChatItems(props),
|
||||||
(item) => item.key,
|
(item) => item.key,
|
||||||
(item) => {
|
(item) => {
|
||||||
|
if (item.kind === "divider") {
|
||||||
|
return html`
|
||||||
|
<div class="chat-divider" role="separator" data-ts=${String(item.timestamp)}>
|
||||||
|
<span class="chat-divider__line"></span>
|
||||||
|
<span class="chat-divider__label">${item.label}</span>
|
||||||
|
<span class="chat-divider__line"></span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
if (item.kind === "reading-indicator") {
|
if (item.kind === "reading-indicator") {
|
||||||
return renderReadingIndicatorGroup(assistantIdentity);
|
return renderReadingIndicatorGroup(assistantIdentity);
|
||||||
}
|
}
|
||||||
|
|
@ -477,6 +487,20 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
|
||||||
for (let i = historyStart; i < history.length; i++) {
|
for (let i = historyStart; i < history.length; i++) {
|
||||||
const msg = history[i];
|
const msg = history[i];
|
||||||
const normalized = normalizeMessage(msg);
|
const normalized = normalizeMessage(msg);
|
||||||
|
const raw = msg as Record<string, unknown>;
|
||||||
|
const marker = raw.__openclaw as Record<string, unknown> | undefined;
|
||||||
|
if (marker && marker.kind === "compaction") {
|
||||||
|
items.push({
|
||||||
|
kind: "divider",
|
||||||
|
key:
|
||||||
|
typeof marker.id === "string"
|
||||||
|
? `divider:compaction:${marker.id}`
|
||||||
|
: `divider:compaction:${normalized.timestamp}:${i}`,
|
||||||
|
label: "Compaction",
|
||||||
|
timestamp: normalized.timestamp ?? Date.now(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!props.showThinking && normalized.role.toLowerCase() === "toolresult") {
|
if (!props.showThinking && normalized.role.toLowerCase() === "toolresult") {
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue