mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-08-03 21:23:33 +00:00
Refactor of messages.js to support lazy rendering. Move chat rename functionality out of core and into a new _chat_naming plugin, and add a built-in _pin_to_top plugin for sidebar pinning. The chat-naming plugin includes API handlers, prompts, helper logic for selecting user messages and budgeted Utility Model calls, a python monologue_start extension, web UI (modal, store, config, sidebar action), and comprehensive tests. Removed the old core renaming extension and deprecated prompts/tests. Also added pin-to-top plugin files (API, helpers, webui store, tests/docs) and updated various AGENTS.md docs to reflect the new plugins and lifecycle clarifications. Minor UI/js tweak: reorder speak/copy buttons in browser tool handler and add a new webui message-window.js along with related webui/store/component updates and test adjustments.
393 lines
12 KiB
JavaScript
393 lines
12 KiB
JavaScript
const DEFAULT_INITIAL_LIMIT = 60;
|
|
const DEFAULT_PAGE_SIZE = 60;
|
|
const DEFAULT_MAX_WINDOW = DEFAULT_PAGE_SIZE * 2;
|
|
|
|
function compareRecords(a, b) {
|
|
const aNo = getRecordOrder(a.message);
|
|
const bNo = getRecordOrder(b.message);
|
|
return aNo - bNo || a.sequence - b.sequence;
|
|
}
|
|
|
|
function getRecordOrder(message) {
|
|
const rawNo = message?.no;
|
|
return rawNo !== undefined && rawNo !== null && Number.isFinite(Number(rawNo))
|
|
? Number(rawNo)
|
|
: Number.MAX_SAFE_INTEGER;
|
|
}
|
|
|
|
export function getMessageCacheKey(message) {
|
|
const id = message?.id;
|
|
if (id !== undefined && id !== null && String(id) !== "") {
|
|
// A root agent's final GEN record and its response intentionally share an
|
|
// id, but they are separate log entries and both must survive replay.
|
|
// Including the type still lets optimistic user messages merge with their
|
|
// backend update while keeping that GEN/response pair distinct.
|
|
const type = String(message?.type || "unknown");
|
|
return `id:${String(id)}:type:${type}`;
|
|
}
|
|
|
|
const no = message?.no;
|
|
if (no !== undefined && no !== null && String(no) !== "") {
|
|
return `no:${String(no)}`;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
const PROCESS_STEP_TYPES = new Set([
|
|
"agent",
|
|
"code_exe",
|
|
"tool",
|
|
"mcp",
|
|
"subagent",
|
|
"progress",
|
|
"info",
|
|
]);
|
|
|
|
function hasUpcomingProcessStep(messages, startIndex) {
|
|
for (let index = startIndex + 1; index < messages.length; index++) {
|
|
const message = messages[index];
|
|
const type = String(message?.type || "");
|
|
if (type === "util") continue;
|
|
if (PROCESS_STEP_TYPES.has(type)) return true;
|
|
if (type === "warning" || type === "rate_limit") return true;
|
|
if (type === "response" && Number(message?.agentno || 0) > 0) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Classifies raw log entries into the same logical units that the message DOM
|
|
* renderer creates. Window boundaries use these units so plugin-backed steps
|
|
* such as code execution cannot split an otherwise contiguous process group.
|
|
*/
|
|
export function classifyMessageRenderUnits(messages = []) {
|
|
let activeGroup = null;
|
|
let lastGroup = null;
|
|
let lastUnitType = null;
|
|
|
|
const startGroup = (message, index) => {
|
|
const rawIdentity = message?.id !== undefined && message?.id !== null &&
|
|
String(message.id) !== ""
|
|
? message.id
|
|
: message?.no !== undefined && message?.no !== null
|
|
? message.no
|
|
: `anonymous-${index}`;
|
|
const identity = String(rawIdentity);
|
|
return { id: identity, key: `process:${identity}`, complete: false };
|
|
};
|
|
const assignGroup = (group, isStep) => {
|
|
lastGroup = group;
|
|
lastUnitType = "process";
|
|
return { key: group.key, group, isStep };
|
|
};
|
|
|
|
return messages.map((message, index) => {
|
|
const type = String(message?.type || "");
|
|
const standalone = {
|
|
key: `entry:${getMessageCacheKey(message) || index}`,
|
|
group: null,
|
|
isStep: false,
|
|
};
|
|
|
|
if (PROCESS_STEP_TYPES.has(type)) {
|
|
activeGroup ||= startGroup(message, index);
|
|
const unit = assignGroup(activeGroup, true);
|
|
if (type === "info" && message?.kvps?.finished) {
|
|
activeGroup.complete = true;
|
|
activeGroup = null;
|
|
}
|
|
return unit;
|
|
}
|
|
|
|
if (type === "util") {
|
|
if (activeGroup || hasUpcomingProcessStep(messages, index)) {
|
|
activeGroup ||= startGroup(message, index);
|
|
return assignGroup(activeGroup, true);
|
|
}
|
|
|
|
// Utilities on their own must not manufacture a visible process group
|
|
// around a root response. They remain standalone until a real process
|
|
// step appears, and post-response utilities cannot reopen the group.
|
|
activeGroup = null;
|
|
lastUnitType = "standalone";
|
|
return standalone;
|
|
}
|
|
|
|
if (type === "response" && Number(message?.agentno || 0) > 0) {
|
|
activeGroup ||= startGroup(message, index);
|
|
return assignGroup(activeGroup, true);
|
|
}
|
|
|
|
if (
|
|
type === "response" &&
|
|
(activeGroup || (lastUnitType === "process" && lastGroup))
|
|
) {
|
|
const group = activeGroup || lastGroup;
|
|
const unit = assignGroup(group, false);
|
|
group.complete = true;
|
|
activeGroup = null;
|
|
return unit;
|
|
}
|
|
|
|
if ((type === "warning" || type === "rate_limit") && activeGroup) {
|
|
return assignGroup(activeGroup, true);
|
|
}
|
|
|
|
activeGroup = null;
|
|
lastUnitType = "standalone";
|
|
return standalone;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Keeps the complete raw log in JavaScript while exposing a bounded contiguous
|
|
* slice for DOM rendering. The class deliberately has no DOM dependencies so
|
|
* window selection can be tested independently from message handlers.
|
|
*/
|
|
export class MessageWindow {
|
|
constructor({
|
|
initialLimit = DEFAULT_INITIAL_LIMIT,
|
|
pageSize = DEFAULT_PAGE_SIZE,
|
|
maxWindow = DEFAULT_MAX_WINDOW,
|
|
getUnitKeys = null,
|
|
} = {}) {
|
|
this.initialLimit = Math.max(1, initialLimit);
|
|
this.pageSize = Math.max(1, pageSize);
|
|
this.maxWindow = Math.max(this.initialLimit, maxWindow);
|
|
this.getUnitKeys = typeof getUnitKeys === "function" ? getUnitKeys : null;
|
|
this.reset([]);
|
|
}
|
|
|
|
reset(messages = []) {
|
|
this._recordsByKey = new Map();
|
|
this._indexByKey = new Map();
|
|
this._records = [];
|
|
this._nextSequence = 0;
|
|
this._nextAnonymous = 0;
|
|
this.start = 0;
|
|
this.end = 0;
|
|
this.merge(messages);
|
|
this.showTail();
|
|
}
|
|
|
|
merge(messages = [], { followTail = true } = {}) {
|
|
const previousStartKey = this._records[this.start]?.key || null;
|
|
const previousEndKey = this._records[this.end - 1]?.key || null;
|
|
const wasAtTail = followTail && this.end >= this._records.length;
|
|
let requiresSort = false;
|
|
|
|
for (const message of Array.isArray(messages) ? messages : []) {
|
|
if (!message) continue;
|
|
const key = getMessageCacheKey(message) ||
|
|
`anonymous:${this._nextAnonymous++}`;
|
|
const existing = this._recordsByKey.get(key);
|
|
if (existing) {
|
|
requiresSort ||=
|
|
getRecordOrder(existing.message) !== getRecordOrder(message);
|
|
existing.message = message;
|
|
} else {
|
|
const record = {
|
|
key,
|
|
message,
|
|
sequence: this._nextSequence++,
|
|
};
|
|
const previous = this._records[this._records.length - 1];
|
|
if (previous && compareRecords(previous, record) > 0) {
|
|
requiresSort = true;
|
|
}
|
|
this._recordsByKey.set(key, record);
|
|
this._indexByKey.set(key, this._records.length);
|
|
this._records.push(record);
|
|
}
|
|
}
|
|
|
|
if (requiresSort) {
|
|
this._records.sort(compareRecords);
|
|
this._rebuildIndexes();
|
|
}
|
|
this._rebuildRenderUnits();
|
|
|
|
if (!previousStartKey || !this._records.length) {
|
|
this.showTail();
|
|
} else if (wasAtTail) {
|
|
const previousStart = this._indexOf(previousStartKey);
|
|
this.start = previousStart >= 0
|
|
? previousStart
|
|
: Math.max(0, this._records.length - this.initialLimit);
|
|
this.end = this._records.length;
|
|
} else {
|
|
const previousStart = this._indexOf(previousStartKey);
|
|
const previousEnd = this._indexOf(previousEndKey);
|
|
this.start = previousStart >= 0 ? previousStart : this.start;
|
|
this.end = previousEnd >= 0 ? previousEnd + 1 : this.end;
|
|
this._clampBounds();
|
|
}
|
|
}
|
|
|
|
showTail() {
|
|
this.end = this._records.length;
|
|
this.start = Math.max(0, this.end - this.initialLimit);
|
|
}
|
|
|
|
showHead() {
|
|
this.start = 0;
|
|
this.end = Math.min(this._records.length, this.initialLimit);
|
|
}
|
|
|
|
compactTailIfNeeded() {
|
|
if (!this.isAtTail() || this.baseRenderedCount <= this.maxWindow) {
|
|
return false;
|
|
}
|
|
this.end = this._records.length;
|
|
this.start = Math.max(0, this.end - this.maxWindow);
|
|
return true;
|
|
}
|
|
|
|
shiftOlder() {
|
|
if (!this.hasOlder) return false;
|
|
const previous = this._getVisibleBounds(this.start, this.end);
|
|
let nextStart = Math.max(0, this.start - this.pageSize);
|
|
let nextEnd = Math.min(this._records.length, nextStart + this.maxWindow);
|
|
const next = this._getVisibleBounds(nextStart, nextEnd);
|
|
if (
|
|
next.start === previous.start &&
|
|
next.end === previous.end &&
|
|
previous.start > 0
|
|
) {
|
|
nextStart = Math.max(0, previous.start - this.pageSize);
|
|
nextEnd = Math.min(this._records.length, nextStart + this.maxWindow);
|
|
}
|
|
this.start = nextStart;
|
|
this.end = nextEnd;
|
|
this._clampBounds();
|
|
return true;
|
|
}
|
|
|
|
shiftNewer() {
|
|
if (!this.hasNewer) return false;
|
|
const previous = this._getVisibleBounds(this.start, this.end);
|
|
let nextEnd = Math.min(this._records.length, this.end + this.pageSize);
|
|
let nextStart = Math.max(0, nextEnd - this.maxWindow);
|
|
const next = this._getVisibleBounds(nextStart, nextEnd);
|
|
if (
|
|
next.start === previous.start &&
|
|
next.end === previous.end &&
|
|
previous.end < this._records.length
|
|
) {
|
|
nextEnd = Math.min(
|
|
this._records.length,
|
|
previous.end + this.pageSize,
|
|
);
|
|
nextStart = Math.max(0, nextEnd - this.maxWindow);
|
|
}
|
|
this.start = nextStart;
|
|
this.end = nextEnd;
|
|
this._clampBounds();
|
|
return true;
|
|
}
|
|
|
|
visibleMessages() {
|
|
const bounds = this._getVisibleBounds(this.start, this.end);
|
|
return this._records.slice(bounds.start, bounds.end).map((record) =>
|
|
record.message
|
|
);
|
|
}
|
|
|
|
isKeyVisible(key) {
|
|
if (!key) return false;
|
|
const index = this._indexOf(key);
|
|
const bounds = this._getVisibleBounds(this.start, this.end);
|
|
return index >= bounds.start && index < bounds.end;
|
|
}
|
|
|
|
isAtTail() {
|
|
return this.visibleEnd >= this._records.length;
|
|
}
|
|
|
|
get size() {
|
|
return this._records.length;
|
|
}
|
|
|
|
get renderedCount() {
|
|
return Math.max(0, this.visibleEnd - this.visibleStart);
|
|
}
|
|
|
|
get baseRenderedCount() {
|
|
return Math.max(0, this.end - this.start);
|
|
}
|
|
|
|
get visibleStart() {
|
|
return this._getVisibleBounds(this.start, this.end).start;
|
|
}
|
|
|
|
get visibleEnd() {
|
|
return this._getVisibleBounds(this.start, this.end).end;
|
|
}
|
|
|
|
get hasOlder() {
|
|
return this.visibleStart > 0;
|
|
}
|
|
|
|
get hasNewer() {
|
|
return this.visibleEnd < this._records.length;
|
|
}
|
|
|
|
get olderCount() {
|
|
return this.visibleStart;
|
|
}
|
|
|
|
get newerCount() {
|
|
return Math.max(0, this._records.length - this.visibleEnd);
|
|
}
|
|
|
|
_indexOf(key) {
|
|
if (!key) return -1;
|
|
return this._indexByKey.get(key) ?? -1;
|
|
}
|
|
|
|
_rebuildIndexes() {
|
|
this._indexByKey.clear();
|
|
this._records.forEach((record, index) => {
|
|
this._indexByKey.set(record.key, index);
|
|
});
|
|
}
|
|
|
|
_rebuildRenderUnits() {
|
|
const messages = this._records.map((record) => record.message);
|
|
const suppliedKeys = this.getUnitKeys?.(messages);
|
|
const unitKeys = Array.isArray(suppliedKeys) &&
|
|
suppliedKeys.length === messages.length
|
|
? suppliedKeys
|
|
: messages.map((_, index) => index);
|
|
|
|
this._unitStartByIndex = new Array(messages.length);
|
|
this._unitEndByIndex = new Array(messages.length);
|
|
let start = 0;
|
|
while (start < unitKeys.length) {
|
|
let end = start + 1;
|
|
while (end < unitKeys.length && unitKeys[end] === unitKeys[start]) end++;
|
|
for (let index = start; index < end; index++) {
|
|
this._unitStartByIndex[index] = start;
|
|
this._unitEndByIndex[index] = end;
|
|
}
|
|
start = end;
|
|
}
|
|
}
|
|
|
|
_getVisibleBounds(start, end) {
|
|
if (!this._records.length || end <= start) return { start, end };
|
|
return {
|
|
start: this._unitStartByIndex?.[start] ?? start,
|
|
end: this._unitEndByIndex?.[end - 1] ?? end,
|
|
};
|
|
}
|
|
|
|
_clampBounds() {
|
|
this.start = Math.max(0, Math.min(this.start, this._records.length));
|
|
this.end = Math.max(this.start, Math.min(this.end, this._records.length));
|
|
}
|
|
}
|