mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 19:08:27 +00:00
feat: add sessions preview rpc and menu prewarm
This commit is contained in:
@@ -3,6 +3,8 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveSessionTranscriptPath } from "../config/sessions.js";
|
||||
import { stripEnvelope } from "./chat-sanitize.js";
|
||||
import type { SessionPreviewItem } from "./session-utils.types.js";
|
||||
|
||||
export function readSessionMessages(
|
||||
sessionId: string,
|
||||
@@ -189,3 +191,202 @@ export function readLastMessagePreviewFromTranscript(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const PREVIEW_READ_SIZES = [64 * 1024, 256 * 1024, 1024 * 1024];
|
||||
const PREVIEW_MAX_LINES = 200;
|
||||
|
||||
type TranscriptContentEntry = {
|
||||
type?: string;
|
||||
text?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type TranscriptPreviewMessage = {
|
||||
role?: string;
|
||||
content?: string | TranscriptContentEntry[];
|
||||
text?: string;
|
||||
toolName?: string;
|
||||
tool_name?: string;
|
||||
};
|
||||
|
||||
function normalizeRole(role: string | undefined, isTool: boolean): SessionPreviewItem["role"] {
|
||||
if (isTool) return "tool";
|
||||
switch ((role ?? "").toLowerCase()) {
|
||||
case "user":
|
||||
return "user";
|
||||
case "assistant":
|
||||
return "assistant";
|
||||
case "system":
|
||||
return "system";
|
||||
case "tool":
|
||||
return "tool";
|
||||
default:
|
||||
return "other";
|
||||
}
|
||||
}
|
||||
|
||||
function truncatePreviewText(text: string, maxChars: number): string {
|
||||
if (maxChars <= 0 || text.length <= maxChars) return text;
|
||||
if (maxChars <= 3) return text.slice(0, maxChars);
|
||||
return `${text.slice(0, maxChars - 3)}...`;
|
||||
}
|
||||
|
||||
function extractPreviewText(message: TranscriptPreviewMessage): string | null {
|
||||
if (typeof message.content === "string") {
|
||||
const trimmed = message.content.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
const parts = message.content
|
||||
.map((entry) => (typeof entry?.text === "string" ? entry.text : ""))
|
||||
.filter((text) => text.trim().length > 0);
|
||||
if (parts.length > 0) {
|
||||
return parts.join("\n").trim();
|
||||
}
|
||||
}
|
||||
if (typeof message.text === "string") {
|
||||
const trimmed = message.text.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isToolCall(message: TranscriptPreviewMessage): boolean {
|
||||
if (message.toolName || message.tool_name) return true;
|
||||
if (!Array.isArray(message.content)) return false;
|
||||
return message.content.some((entry) => {
|
||||
if (entry?.name) return true;
|
||||
const raw = typeof entry?.type === "string" ? entry.type.toLowerCase() : "";
|
||||
return raw === "toolcall" || raw === "tool_call";
|
||||
});
|
||||
}
|
||||
|
||||
function extractToolNames(message: TranscriptPreviewMessage): string[] {
|
||||
const names: string[] = [];
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const entry of message.content) {
|
||||
if (typeof entry?.name === "string" && entry.name.trim()) {
|
||||
names.push(entry.name.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
const toolName = typeof message.toolName === "string" ? message.toolName : message.tool_name;
|
||||
if (typeof toolName === "string" && toolName.trim()) {
|
||||
names.push(toolName.trim());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function extractMediaSummary(message: TranscriptPreviewMessage): string | null {
|
||||
if (!Array.isArray(message.content)) return null;
|
||||
for (const entry of message.content) {
|
||||
const raw = typeof entry?.type === "string" ? entry.type.trim().toLowerCase() : "";
|
||||
if (!raw || raw === "text" || raw === "toolcall" || raw === "tool_call") continue;
|
||||
return `[${raw}]`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildPreviewItems(
|
||||
messages: TranscriptPreviewMessage[],
|
||||
maxItems: number,
|
||||
maxChars: number,
|
||||
): SessionPreviewItem[] {
|
||||
const items: SessionPreviewItem[] = [];
|
||||
for (const message of messages) {
|
||||
const toolCall = isToolCall(message);
|
||||
const role = normalizeRole(message.role, toolCall);
|
||||
let text = extractPreviewText(message);
|
||||
if (!text) {
|
||||
const toolNames = extractToolNames(message);
|
||||
if (toolNames.length > 0) {
|
||||
const shown = toolNames.slice(0, 2);
|
||||
const overflow = toolNames.length - shown.length;
|
||||
text = `call ${shown.join(", ")}`;
|
||||
if (overflow > 0) text += ` +${overflow}`;
|
||||
}
|
||||
}
|
||||
if (!text) {
|
||||
text = extractMediaSummary(message);
|
||||
}
|
||||
if (!text) continue;
|
||||
let trimmed = text.trim();
|
||||
if (!trimmed) continue;
|
||||
if (role === "user") {
|
||||
trimmed = stripEnvelope(trimmed);
|
||||
}
|
||||
trimmed = truncatePreviewText(trimmed, maxChars);
|
||||
items.push({ role, text: trimmed });
|
||||
}
|
||||
|
||||
if (items.length <= maxItems) return items;
|
||||
return items.slice(-maxItems);
|
||||
}
|
||||
|
||||
function readRecentMessagesFromTranscript(
|
||||
filePath: string,
|
||||
maxMessages: number,
|
||||
readBytes: number,
|
||||
): TranscriptPreviewMessage[] {
|
||||
let fd: number | null = null;
|
||||
try {
|
||||
fd = fs.openSync(filePath, "r");
|
||||
const stat = fs.fstatSync(fd);
|
||||
const size = stat.size;
|
||||
if (size === 0) return [];
|
||||
|
||||
const readStart = Math.max(0, size - readBytes);
|
||||
const readLen = Math.min(size, readBytes);
|
||||
const buf = Buffer.alloc(readLen);
|
||||
fs.readSync(fd, buf, 0, readLen, readStart);
|
||||
|
||||
const chunk = buf.toString("utf-8");
|
||||
const lines = chunk.split(/\r?\n/).filter((l) => l.trim());
|
||||
const tailLines = lines.slice(-PREVIEW_MAX_LINES);
|
||||
|
||||
const collected: TranscriptPreviewMessage[] = [];
|
||||
for (let i = tailLines.length - 1; i >= 0; i--) {
|
||||
const line = tailLines[i];
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
const msg = parsed?.message as TranscriptPreviewMessage | undefined;
|
||||
if (msg && typeof msg === "object") {
|
||||
collected.push(msg);
|
||||
if (collected.length >= maxMessages) break;
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
return collected.reverse();
|
||||
} catch {
|
||||
return [];
|
||||
} finally {
|
||||
if (fd !== null) fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
export function readSessionPreviewItemsFromTranscript(
|
||||
sessionId: string,
|
||||
storePath: string | undefined,
|
||||
sessionFile: string | undefined,
|
||||
agentId: string | undefined,
|
||||
maxItems: number,
|
||||
maxChars: number,
|
||||
): SessionPreviewItem[] {
|
||||
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId);
|
||||
const filePath = candidates.find((p) => fs.existsSync(p));
|
||||
if (!filePath) return [];
|
||||
|
||||
const boundedItems = Math.max(1, Math.min(maxItems, 50));
|
||||
const boundedChars = Math.max(20, Math.min(maxChars, 2000));
|
||||
|
||||
for (const readSize of PREVIEW_READ_SIZES) {
|
||||
const messages = readRecentMessagesFromTranscript(filePath, boundedItems, readSize);
|
||||
if (messages.length > 0 || readSize === PREVIEW_READ_SIZES[PREVIEW_READ_SIZES.length - 1]) {
|
||||
return buildPreviewItems(messages, boundedItems, boundedChars);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user