| 1 | // message actions and components |
| 2 | import { store as imageViewerStore } from "../components/modals/image-viewer/image-viewer-store.js"; |
| 3 | import { marked } from "../vendor/marked/marked.esm.js"; |
| 4 | import { store as _messageResizeStore } from "/components/messages/resize/message-resize-store.js"; // keep here, required in html |
| 5 | import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js"; |
| 6 | import { ttsService } from "/js/tts-service.js"; |
| 7 | import { |
| 8 | createActionButton, |
| 9 | copyToClipboard, |
| 10 | syncActionButtons, |
| 11 | } from "/components/messages/action-buttons/simple-action-buttons.js"; |
| 12 | import { store as stepDetailStore } from "/components/modals/process-step-detail/step-detail-store.js"; |
| 13 | import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js"; |
| 14 | import { |
| 15 | formatDateTime, |
| 16 | formatDuration, |
| 17 | getUserHour12, |
| 18 | getUserTimezone, |
| 19 | } from "./time-utils.js"; |
| 20 | import { Scroller, cancelPendingScroll } from "./scroller.js"; |
| 21 | import { |
| 22 | MessageWindow, |
| 23 | classifyMessageRenderUnits, |
| 24 | getMessageCacheKey, |
| 25 | } from "./message-window.js"; |
| 26 | import { callJsExtensions } from "/js/extensions.js"; |
| 27 | import { addBlankTargetsToLinks } from "/js/html-links.js"; |
| 28 | import { sanitizeHtml } from "/js/safe-markdown.js"; |
| 29 | import { createThreeBubbleLoader } from "/js/loading-indicators.js"; |
| 30 | import { measureMessageCollapseOverflow } from "./message-collapse.js"; |
| 31 | |
| 32 | // Delay before collapsing previous steps when a new step is added |
| 33 | const STEP_COLLAPSE_DELAY = { |
| 34 | agent: 2000, |
| 35 | other: 4000, // tools should stay longer as next gen step is placed quickly |
| 36 | }; |
| 37 | // delay collapse when hovering |
| 38 | const STEP_COLLAPSE_HOVER_DELAY_MS = 5000; |
| 39 | const PROCESS_GROUP_STEP_PAGE_SIZE = 50; |
| 40 | const PROCESS_GROUP_RENDER_INFO = Symbol("processGroupRenderInfo"); |
| 41 | |
| 42 | let _messageProcessGroups = new WeakMap(); |
| 43 | let _messageIsProcessStep = new WeakSet(); |
| 44 | const _processGroupStepLimits = new Map(); |
| 45 | let _renderedProcessGroupPages = new Map(); |
| 46 | |
| 47 | function getMessageRenderUnitKeys(messages) { |
| 48 | _messageProcessGroups = new WeakMap(); |
| 49 | _messageIsProcessStep = new WeakSet(); |
| 50 | const units = classifyMessageRenderUnits(messages); |
| 51 | units.forEach((unit, index) => { |
| 52 | if (!unit.group) return; |
| 53 | _messageProcessGroups.set(messages[index], unit.group); |
| 54 | if (unit.isStep) _messageIsProcessStep.add(messages[index]); |
| 55 | }); |
| 56 | return units.map((unit) => unit.key); |
| 57 | } |
| 58 | |
| 59 | // dom references |
| 60 | let _chatHistory = null; |
| 61 | |
| 62 | // state vars |
| 63 | let _massRender = false; |
| 64 | let _windowedRender = false; |
| 65 | let _scrollOnNextProcessGroup = null; |
| 66 | const _messageWindow = new MessageWindow({ |
| 67 | getUnitKeys: getMessageRenderUnitKeys, |
| 68 | }); |
| 69 | let _messageWindowRenderPromise = null; |
| 70 | let _messageRenderQueue = Promise.resolve(); |
| 71 | let _messageRenderGeneration = 0; |
| 72 | let _messageWindowHistory = null; |
| 73 | let _messageWindowScrollFrame = null; |
| 74 | let _lastMessageWindowScrollTop = 0; |
| 75 | let _messageWindowFollowTail = true; |
| 76 | let _messageWindowLoadingDirection = null; |
| 77 | let _messageWindowSuppressScrollEvents = false; |
| 78 | let _messageWindowPointerActive = false; |
| 79 | let _messageWindowUserScrollUntil = 0; |
| 80 | let _messageWindowResizeObserver = null; |
| 81 | |
| 82 | // Leave a small tolerance for fractional scroll positions and the passive |
| 83 | // boundary indicator, but do not swap pages while the user is still reading. |
| 84 | const MESSAGE_WINDOW_BOUNDARY_TOLERANCE_PX = 48; |
| 85 | const MESSAGE_WINDOW_TAIL_TOLERANCE_PX = 80; |
| 86 | const MESSAGE_WINDOW_USER_SCROLL_GRACE_MS = 1200; |
| 87 | const LAZY_MESSAGE_PREVIEW_CHARS = 6000; |
| 88 | const DEFERRED_REPLAY_ENTRY_THRESHOLD = 30; |
| 89 | const DEFERRED_REPLAY_TEXT_THRESHOLD = 50000; |
| 90 | |
| 91 | /** |
| 92 | * @typedef {object} MessageHandlerArgs |
| 93 | * @property {number} [no] |
| 94 | * @property {string | number} id |
| 95 | * @property {string} type |
| 96 | * @property {string | undefined} [heading] |
| 97 | * @property {string | undefined} [content] |
| 98 | * @property {object | undefined} [kvps] |
| 99 | * @property {number | undefined} [timestamp] |
| 100 | * @property {number} [agentno] |
| 101 | */ |
| 102 | |
| 103 | /** |
| 104 | * @typedef {{ element: Element } & Record<string, any>} MessageHandlerResult |
| 105 | */ |
| 106 | |
| 107 | /** |
| 108 | * @typedef {object} SetMessageResult |
| 109 | * @property {IArguments} args |
| 110 | * @property {MessageHandlerResult} result |
| 111 | */ |
| 112 | |
| 113 | /** |
| 114 | * @typedef {(args: MessageHandlerArgs & Record<string, any>) => (MessageHandlerResult|Promise<MessageHandlerResult>)} MessageHandler |
| 115 | */ |
| 116 | |
| 117 | /** |
| 118 | * @typedef {object} ProcessStepArgs |
| 119 | * @property {string | number} id |
| 120 | * @property {string} title |
| 121 | * @property {string} code |
| 122 | * @property {string[] | undefined} [classes] |
| 123 | * @property {any} [kvps] |
| 124 | * @property {string | undefined} [content] |
| 125 | * @property {string[] | undefined} [contentClasses] |
| 126 | * @property {Element[] | undefined} [actionButtons] |
| 127 | * @property {any} log |
| 128 | * @property {boolean} [allowCompletedGroup] |
| 129 | */ |
| 130 | |
| 131 | |
| 132 | export function scrollOnNextProcessGroup() { |
| 133 | _scrollOnNextProcessGroup = "wait"; |
| 134 | } |
| 135 | |
| 136 | // handlers for log message rendering |
| 137 | /** |
| 138 | * Returns a message renderer for a given log message type. |
| 139 | * |
| 140 | * The returned handler has the same input object shape as `setMessage(...)` passes through |
| 141 | * and may return a rich object `{ element, actionButtons?, ...additional }`. |
| 142 | * |
| 143 | * @param {string} type |
| 144 | * @returns {Promise<MessageHandler>} |
| 145 | */ |
| 146 | export async function getMessageHandler(type) { |
| 147 | switch (type) { |
| 148 | case "user": |
| 149 | return drawMessageUser; |
| 150 | case "agent": |
| 151 | return drawMessageAgent; |
| 152 | case "response": |
| 153 | return drawMessageResponse; |
| 154 | case "tool": |
| 155 | return drawMessageTool; |
| 156 | case "progress": |
| 157 | return drawMessageProgress; |
| 158 | case "mcp": |
| 159 | return drawMessageMcp; |
| 160 | case "subagent": |
| 161 | return drawMessageSubagent; |
| 162 | case "warning": |
| 163 | return drawMessageWarning; |
| 164 | case "rate_limit": |
| 165 | return drawMessageWarning; |
| 166 | case "error": |
| 167 | return drawMessageError; |
| 168 | case "info": |
| 169 | return drawMessageInfo; |
| 170 | case "util": |
| 171 | return drawMessageUtil; |
| 172 | case "hint": |
| 173 | return drawMessageHint; |
| 174 | case "model_setup_gate": |
| 175 | return drawMessageModelSetupGate; |
| 176 | default: |
| 177 | return await getHandlerFromExtensions(type); |
| 178 | } |
| 179 | |
| 180 | async function getHandlerFromExtensions(type){ |
| 181 | const extData = { type: type, handler: undefined } |
| 182 | await callJsExtensions("get_message_handler", extData); |
| 183 | // return handler from extensions |
| 184 | if(typeof extData.handler == "function") return extData.handler; |
| 185 | //not set by extensions, return default |
| 186 | return drawMessageDefault; |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | |
| 191 | // entrypoint called from poll/WS communication, this is how all messages are rendered and updated |
| 192 | // input is raw log format |
| 193 | export function setMessages(messages) { |
| 194 | const generation = _messageRenderGeneration; |
| 195 | const task = _messageRenderQueue.then( |
| 196 | () => setMessagesNow(messages, generation), |
| 197 | () => setMessagesNow(messages, generation), |
| 198 | ); |
| 199 | _messageRenderQueue = task.catch(() => undefined); |
| 200 | return task; |
| 201 | } |
| 202 | |
| 203 | async function setMessagesNow(messages, generation) { |
| 204 | if (generation !== _messageRenderGeneration) return null; |
| 205 | messages = normalizeMessages(messages); |
| 206 | const history = getChatHistoryEl(); |
| 207 | const followTail = shouldFollowMessageTail(); |
| 208 | |
| 209 | const addedMessageKeys = _messageWindow.merge(messages, { followTail }); |
| 210 | bindMessageWindow(history); |
| 211 | if (_messageWindowRenderPromise) await _messageWindowRenderPromise; |
| 212 | |
| 213 | const initialWindow = |
| 214 | _messageWindow.size > 0 && !history?.querySelector(".message-group"); |
| 215 | if (initialWindow && _messageWindowFollowTail) _messageWindow.showTail(); |
| 216 | const compactedTail = _messageWindow.compactTailIfNeeded(); |
| 217 | const windowMessages = _messageWindow.visibleMessages(); |
| 218 | const cappedProcessGroupUpdate = hasCappedProcessGroupUpdate( |
| 219 | messages, |
| 220 | windowMessages, |
| 221 | addedMessageKeys, |
| 222 | ); |
| 223 | if (initialWindow || compactedTail || cappedProcessGroupUpdate) { |
| 224 | return await renderMessageWindow({ |
| 225 | preserveScroll: !initialWindow && !followTail, |
| 226 | generation, |
| 227 | }); |
| 228 | } |
| 229 | |
| 230 | return await renderMessageBatch(messages, { |
| 231 | virtualizeOffscreen: true, |
| 232 | windowedRender: false, |
| 233 | generation, |
| 234 | }); |
| 235 | } |
| 236 | |
| 237 | export function resetMessageRenderState({ clearDom = true } = {}) { |
| 238 | _messageRenderGeneration += 1; |
| 239 | _messageWindow.reset([]); |
| 240 | _massRender = false; |
| 241 | _windowedRender = false; |
| 242 | _scrollOnNextProcessGroup = null; |
| 243 | _messageWindowFollowTail = true; |
| 244 | _messageWindowLoadingDirection = null; |
| 245 | _messageWindowSuppressScrollEvents = false; |
| 246 | _messageWindowPointerActive = false; |
| 247 | _messageWindowUserScrollUntil = 0; |
| 248 | _messageWindowResizeObserver?.disconnect(); |
| 249 | _messageWindowResizeObserver = null; |
| 250 | _processGroupStepLimits.clear(); |
| 251 | _renderedProcessGroupPages.clear(); |
| 252 | |
| 253 | const history = document.getElementById("chat-history") || getChatHistoryEl(); |
| 254 | if (history) cancelPendingScroll(history); |
| 255 | if (clearDom && history) history.replaceChildren(); |
| 256 | if (history) { |
| 257 | delete history.dataset.messageWindowStart; |
| 258 | delete history.dataset.messageWindowEnd; |
| 259 | delete history.dataset.messageWindowTotal; |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | function normalizeMessages(messages) { |
| 264 | const normalized = Array.isArray(messages) ? [...messages].filter(Boolean) : []; |
| 265 | normalized.sort( |
| 266 | (a, b) => |
| 267 | (a.no ?? Number.MAX_SAFE_INTEGER) - |
| 268 | (b.no ?? Number.MAX_SAFE_INTEGER), |
| 269 | ); |
| 270 | return normalized; |
| 271 | } |
| 272 | |
| 273 | async function renderMessageWindow({ |
| 274 | preserveScroll = true, |
| 275 | generation = _messageRenderGeneration, |
| 276 | } = {}) { |
| 277 | if (_messageWindowRenderPromise) return await _messageWindowRenderPromise; |
| 278 | |
| 279 | _messageWindowRenderPromise = (async () => { |
| 280 | const history = getChatHistoryEl(); |
| 281 | if (!history) return null; |
| 282 | const stagingHistory = createMessageWindowStagingHistory(history); |
| 283 | |
| 284 | _messageWindowSuppressScrollEvents = true; |
| 285 | cancelPendingScroll(history); |
| 286 | _messageWindowResizeObserver?.disconnect(); |
| 287 | try { |
| 288 | const anchor = preserveScroll |
| 289 | ? captureMessageWindowAnchor(history) |
| 290 | : null; |
| 291 | const expansionState = captureMessageExpansionState(history); |
| 292 | _chatHistory = stagingHistory; |
| 293 | |
| 294 | const windowMessages = _messageWindow.visibleMessages(); |
| 295 | const renderMessages = getProcessGroupRenderMessages(windowMessages); |
| 296 | const context = await renderMessageBatch(renderMessages, { |
| 297 | forceHistoryEmpty: true, |
| 298 | forceMassRender: true, |
| 299 | suppressScroll: preserveScroll, |
| 300 | windowedRender: shouldDeferReplayDetails(renderMessages), |
| 301 | windowRebuild: true, |
| 302 | generation, |
| 303 | }); |
| 304 | |
| 305 | if (generation !== _messageRenderGeneration) { |
| 306 | return null; |
| 307 | } |
| 308 | |
| 309 | updateProcessGroupPagingControls(stagingHistory); |
| 310 | await restoreMessageExpansionState(stagingHistory, expansionState); |
| 311 | await nextAnimationFrame(); |
| 312 | |
| 313 | if (generation !== _messageRenderGeneration) return null; |
| 314 | |
| 315 | _messageWindowResizeObserver?.disconnect(); |
| 316 | stagingHistory |
| 317 | .querySelectorAll(".message-container") |
| 318 | .forEach((element) => element.classList.add("message-window-restored")); |
| 319 | const stagedChildren = Array.from(stagingHistory.childNodes); |
| 320 | const stagedWindowState = { |
| 321 | messageWindowStart: stagingHistory.dataset.messageWindowStart, |
| 322 | messageWindowEnd: stagingHistory.dataset.messageWindowEnd, |
| 323 | messageWindowTotal: stagingHistory.dataset.messageWindowTotal, |
| 324 | detailMode: stagingHistory.dataset.detailMode, |
| 325 | }; |
| 326 | stagingHistory.remove(); |
| 327 | _chatHistory = history; |
| 328 | |
| 329 | history.replaceChildren(...stagedChildren); |
| 330 | copyMessageWindowDataset(history, stagedWindowState); |
| 331 | let anchorRestored = anchor |
| 332 | ? restoreMessageWindowAnchor(history, anchor) |
| 333 | : false; |
| 334 | if (!anchorRestored && _messageWindow.isAtTail() && _messageWindowFollowTail) { |
| 335 | history.scrollTop = history.scrollHeight; |
| 336 | } |
| 337 | |
| 338 | await nextAnimationFrame(); |
| 339 | refreshCollapsibleMessageOverflow(history); |
| 340 | if (anchor) { |
| 341 | anchorRestored = restoreMessageWindowAnchor(history, anchor) || |
| 342 | anchorRestored; |
| 343 | } |
| 344 | if (!anchorRestored && _messageWindow.isAtTail() && _messageWindowFollowTail) { |
| 345 | history.scrollTop = history.scrollHeight; |
| 346 | } |
| 347 | |
| 348 | context.history = history; |
| 349 | context.mainScroller = null; |
| 350 | refreshMessageWindowResizeObserver(history); |
| 351 | return context; |
| 352 | } finally { |
| 353 | stagingHistory.remove(); |
| 354 | _chatHistory = history; |
| 355 | _lastMessageWindowScrollTop = history.scrollTop; |
| 356 | _messageWindowSuppressScrollEvents = false; |
| 357 | } |
| 358 | })(); |
| 359 | |
| 360 | try { |
| 361 | return await _messageWindowRenderPromise; |
| 362 | } finally { |
| 363 | _messageWindowRenderPromise = null; |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | function shouldDeferReplayDetails(messages) { |
| 368 | if ( |
| 369 | _messageWindow.hasOlder || |
| 370 | _messageWindow.hasNewer || |
| 371 | messages.length > DEFERRED_REPLAY_ENTRY_THRESHOLD |
| 372 | ) { |
| 373 | return true; |
| 374 | } |
| 375 | |
| 376 | let textSize = 0; |
| 377 | for (const message of messages) { |
| 378 | textSize += String(message?.heading ?? "").length; |
| 379 | textSize += String(message?.content ?? "").length; |
| 380 | for (const value of Object.values(message?.kvps || {})) { |
| 381 | textSize += typeof value === "string" ? value.length : 500; |
| 382 | } |
| 383 | if (textSize > DEFERRED_REPLAY_TEXT_THRESHOLD) return true; |
| 384 | } |
| 385 | return false; |
| 386 | } |
| 387 | |
| 388 | async function renderMessageBatch(messages, options = {}) { |
| 389 | const generation = options.generation ?? _messageRenderGeneration; |
| 390 | if (generation !== _messageRenderGeneration) return null; |
| 391 | const history = getChatHistoryEl(); |
| 392 | const context = { |
| 393 | messages: normalizeMessages(messages), |
| 394 | history, |
| 395 | historyEmpty: |
| 396 | options.forceHistoryEmpty ?? !history?.querySelector(".message-group"), |
| 397 | isLargeAppend: false, |
| 398 | cutoff: 0, |
| 399 | massRender: false, |
| 400 | windowRebuild: Boolean(options.windowRebuild), |
| 401 | messageWindow: getMessageWindowContext(), |
| 402 | scrollerOptions: { |
| 403 | smooth: true, |
| 404 | toleranceRem: 4, |
| 405 | reapplyDelayMs: 1000, |
| 406 | applyStabilization: true, |
| 407 | }, |
| 408 | /** @type {Scroller | null} */ |
| 409 | mainScroller: null, |
| 410 | /** @type {SetMessageResult[]} */ |
| 411 | results: [], |
| 412 | }; |
| 413 | |
| 414 | context.isLargeAppend = !context.historyEmpty && context.messages.length > 10; |
| 415 | context.cutoff = context.isLargeAppend |
| 416 | ? Math.max(0, context.messages.length - 2) |
| 417 | : 0; |
| 418 | context.massRender = |
| 419 | Boolean(options.forceMassRender) || |
| 420 | context.historyEmpty || |
| 421 | context.isLargeAppend; |
| 422 | context.scrollerOptions.smooth = !context.massRender; |
| 423 | |
| 424 | await callJsExtensions("set_messages_before_loop", context); |
| 425 | if (generation !== _messageRenderGeneration) { |
| 426 | context.history?.replaceChildren(); |
| 427 | return null; |
| 428 | } |
| 429 | |
| 430 | if (context.history) { |
| 431 | context.mainScroller = new Scroller( |
| 432 | context.history, |
| 433 | context.scrollerOptions, |
| 434 | ); |
| 435 | } |
| 436 | |
| 437 | try { |
| 438 | for (let i = 0; i < context.messages.length; i++) { |
| 439 | if (generation !== _messageRenderGeneration) break; |
| 440 | const message = context.messages[i]; |
| 441 | const messageKey = getMessageCacheKey(message); |
| 442 | if ( |
| 443 | options.virtualizeOffscreen && |
| 444 | messageKey && |
| 445 | !_messageWindow.isKeyVisible(messageKey) |
| 446 | ) { |
| 447 | context.results.push({ |
| 448 | args: message, |
| 449 | result: { element: null, virtualized: true, dontScroll: true }, |
| 450 | }); |
| 451 | continue; |
| 452 | } |
| 453 | _massRender = |
| 454 | Boolean(options.forceMassRender) || |
| 455 | context.historyEmpty || |
| 456 | (context.isLargeAppend && i < context.cutoff); |
| 457 | _windowedRender = Boolean(options.windowedRender); |
| 458 | const entry = await setMessage(message); |
| 459 | if (generation !== _messageRenderGeneration) { |
| 460 | context.history?.replaceChildren(); |
| 461 | break; |
| 462 | } |
| 463 | context.results.push(entry); |
| 464 | } |
| 465 | |
| 466 | if (generation === _messageRenderGeneration) { |
| 467 | updateMessageWindowIndicators(context.history); |
| 468 | if ( |
| 469 | context.windowRebuild && |
| 470 | typeof preferencesStore.applyCurrentDetailMode === "function" |
| 471 | ) { |
| 472 | await preferencesStore.applyCurrentDetailMode(context.history); |
| 473 | } |
| 474 | refreshMessageWindowResizeObserver(context.history); |
| 475 | await callJsExtensions("set_messages_after_loop", context); |
| 476 | } |
| 477 | } finally { |
| 478 | _massRender = false; |
| 479 | _windowedRender = false; |
| 480 | } |
| 481 | |
| 482 | if (generation !== _messageRenderGeneration) return null; |
| 483 | |
| 484 | const lastResult = context.results[context.results.length - 1]?.result; |
| 485 | const shouldScroll = |
| 486 | !options.suppressScroll && |
| 487 | (context.historyEmpty || !lastResult?.dontScroll); |
| 488 | |
| 489 | if (shouldScroll) context.mainScroller?.reApplyScroll(); |
| 490 | |
| 491 | if (_scrollOnNextProcessGroup === "scroll") { |
| 492 | requestAnimationFrame(() => { |
| 493 | if ( |
| 494 | generation !== _messageRenderGeneration || |
| 495 | _scrollOnNextProcessGroup !== "scroll" |
| 496 | ) { |
| 497 | return; |
| 498 | } |
| 499 | context.mainScroller?.scrollToBottom(); |
| 500 | _scrollOnNextProcessGroup = null; |
| 501 | }); |
| 502 | } |
| 503 | |
| 504 | return context; |
| 505 | } |
| 506 | |
| 507 | // entrypoint called from poll/WS communication, this is how all messages are rendered and updated |
| 508 | // input is raw log format |
| 509 | /** |
| 510 | * @param {MessageHandlerArgs & Record<string, any>} param0 |
| 511 | * @returns {Promise<SetMessageResult>} |
| 512 | */ |
| 513 | export async function setMessage({ |
| 514 | no, |
| 515 | id, |
| 516 | type, |
| 517 | heading, |
| 518 | content, |
| 519 | kvps, |
| 520 | timestamp, |
| 521 | agentno, |
| 522 | ...additional |
| 523 | }) { |
| 524 | const rawMessage = arguments[0]; |
| 525 | const handler = await getMessageHandler(type); |
| 526 | // prefer log ID if set to match user message created on frontend with backend updates |
| 527 | const handlerArgs = { |
| 528 | no, |
| 529 | id: id || String(no) || "", |
| 530 | type, |
| 531 | heading, |
| 532 | content, |
| 533 | kvps, |
| 534 | timestamp, |
| 535 | agentno, |
| 536 | ...additional, |
| 537 | }; |
| 538 | handlerArgs[PROCESS_GROUP_RENDER_INFO] = _messageProcessGroups.get(rawMessage); |
| 539 | const handlerResult = await handler(handlerArgs); |
| 540 | const messageKey = getMessageCacheKey(rawMessage); |
| 541 | |
| 542 | if (handlerResult?.element && messageKey) { |
| 543 | handlerResult.element.dataset.messageKey = messageKey; |
| 544 | } |
| 545 | if (handlerResult?.element && no !== undefined && no !== null) { |
| 546 | handlerResult.element.dataset.logNo = String(no); |
| 547 | } |
| 548 | |
| 549 | if (handlerResult?.step) { |
| 550 | handlerResult.step.__renderDetail = async () => { |
| 551 | if (!handlerResult.step?.isConnected) return null; |
| 552 | return await requestDeferredMessageDetail(rawMessage); |
| 553 | }; |
| 554 | handlerResult.step.__discardDetail = () => |
| 555 | discardProcessStepDetail(handlerResult.step); |
| 556 | handlerResult.step.__setExpanded = (expanded) => |
| 557 | toggleStepCollapse(handlerResult.step, expanded); |
| 558 | } |
| 559 | |
| 560 | return { |
| 561 | args: rawMessage, |
| 562 | result: handlerResult, |
| 563 | } |
| 564 | } |
| 565 | |
| 566 | function getOrCreateMessageContainer( |
| 567 | id, |
| 568 | position, |
| 569 | containerClasses = [], |
| 570 | forceNewGroup = false, |
| 571 | ) { |
| 572 | let container = getChatHistoryElementById(`message-${id}`); |
| 573 | if (!container) { |
| 574 | container = document.createElement("div"); |
| 575 | container.id = `message-${id}`; |
| 576 | container.classList.add("message-container"); |
| 577 | } |
| 578 | |
| 579 | if (containerClasses.length) { |
| 580 | container.classList.add(...containerClasses); |
| 581 | } |
| 582 | |
| 583 | if (!container.parentNode) { |
| 584 | appendToMessageGroup(container, position, forceNewGroup); |
| 585 | } |
| 586 | |
| 587 | return container; |
| 588 | } |
| 589 | |
| 590 | function getChatHistoryEl() { |
| 591 | if (!_chatHistory) _chatHistory = document.getElementById("chat-history"); |
| 592 | return _chatHistory; |
| 593 | } |
| 594 | |
| 595 | function getChatHistoryElementById(id) { |
| 596 | const history = getChatHistoryEl(); |
| 597 | if (!history || !id) return null; |
| 598 | if (globalThis.CSS?.escape) { |
| 599 | return history.querySelector(`#${globalThis.CSS.escape(id)}`); |
| 600 | } |
| 601 | return Array.from(history.querySelectorAll("[id]")).find( |
| 602 | (element) => element.id === id, |
| 603 | ) || null; |
| 604 | } |
| 605 | |
| 606 | function getLastMessageGroup() { |
| 607 | const groups = getChatHistoryEl()?.querySelectorAll(":scope > .message-group"); |
| 608 | return groups?.[groups.length - 1] || null; |
| 609 | } |
| 610 | |
| 611 | function getMessageWindowContext() { |
| 612 | return { |
| 613 | start: _messageWindow.visibleStart, |
| 614 | end: _messageWindow.visibleEnd, |
| 615 | total: _messageWindow.size, |
| 616 | rendered: _messageWindow.renderedCount, |
| 617 | older: _messageWindow.olderCount, |
| 618 | newer: _messageWindow.newerCount, |
| 619 | hasOlder: _messageWindow.hasOlder, |
| 620 | hasNewer: _messageWindow.hasNewer, |
| 621 | }; |
| 622 | } |
| 623 | |
| 624 | function getProcessGroupPageState(messages) { |
| 625 | const groups = new Map(); |
| 626 | for (const message of messages) { |
| 627 | const group = _messageProcessGroups.get(message); |
| 628 | if (!group || !_messageIsProcessStep.has(message)) continue; |
| 629 | let state = groups.get(group.key); |
| 630 | if (!state) { |
| 631 | state = { group, steps: [] }; |
| 632 | groups.set(group.key, state); |
| 633 | } |
| 634 | state.steps.push(message); |
| 635 | } |
| 636 | return groups; |
| 637 | } |
| 638 | |
| 639 | function getProcessGroupRenderMessages(messages) { |
| 640 | const groups = getProcessGroupPageState(messages); |
| 641 | const hiddenByGroup = new Map(); |
| 642 | _renderedProcessGroupPages = new Map(); |
| 643 | |
| 644 | for (const [key, state] of groups) { |
| 645 | const limit = _processGroupStepLimits.get(key) || |
| 646 | PROCESS_GROUP_STEP_PAGE_SIZE; |
| 647 | const hidden = Math.max(0, state.steps.length - limit); |
| 648 | hiddenByGroup.set(key, hidden); |
| 649 | _renderedProcessGroupPages.set(key, { |
| 650 | ...state, |
| 651 | hidden, |
| 652 | visible: state.steps.length - hidden, |
| 653 | }); |
| 654 | } |
| 655 | |
| 656 | const seen = new Map(); |
| 657 | return messages.filter((message) => { |
| 658 | const group = _messageProcessGroups.get(message); |
| 659 | if (!group || !_messageIsProcessStep.has(message)) return true; |
| 660 | const index = seen.get(group.key) || 0; |
| 661 | seen.set(group.key, index + 1); |
| 662 | return index >= (hiddenByGroup.get(group.key) || 0); |
| 663 | }); |
| 664 | } |
| 665 | |
| 666 | function hasCappedProcessGroupUpdate(messages, windowMessages, addedMessageKeys) { |
| 667 | if (!messages.length) return false; |
| 668 | const groupStates = getProcessGroupPageState(windowMessages); |
| 669 | return messages.some((message) => { |
| 670 | const group = _messageProcessGroups.get(message); |
| 671 | if (!group || !_messageIsProcessStep.has(message)) return false; |
| 672 | const total = groupStates.get(group.key)?.steps.length || 0; |
| 673 | const limit = _processGroupStepLimits.get(group.key) || |
| 674 | PROCESS_GROUP_STEP_PAGE_SIZE; |
| 675 | return total > limit && addedMessageKeys.has(getMessageCacheKey(message)); |
| 676 | }); |
| 677 | } |
| 678 | |
| 679 | function updateProcessGroupPagingControls(history) { |
| 680 | history |
| 681 | ?.querySelectorAll(".process-group-show-more") |
| 682 | .forEach((element) => element.remove()); |
| 683 | |
| 684 | for (const [key, state] of _renderedProcessGroupPages) { |
| 685 | const group = Array.from( |
| 686 | history?.querySelectorAll(".process-group[data-render-group-key]") || [], |
| 687 | ).find((candidate) => candidate.dataset.renderGroupKey === key); |
| 688 | if (!group) continue; |
| 689 | |
| 690 | const allSteps = state.steps; |
| 691 | const firstTimestamp = allSteps[0]?.timestamp; |
| 692 | const lastTimestamp = allSteps.at(-1)?.timestamp; |
| 693 | if (firstTimestamp != null) { |
| 694 | group.dataset.fullStartTimestamp = String(firstTimestamp); |
| 695 | group.setAttribute("data-start-timestamp", String(firstTimestamp)); |
| 696 | } |
| 697 | if (lastTimestamp != null) { |
| 698 | group.dataset.fullEndTimestamp = String(lastTimestamp); |
| 699 | } |
| 700 | group.dataset.fullAgentSteps = String( |
| 701 | Math.max( |
| 702 | 0, |
| 703 | allSteps.filter((message) => message?.type === "agent").length - 1, |
| 704 | ), |
| 705 | ); |
| 706 | group.dataset.fullWarningSteps = String( |
| 707 | allSteps.filter((message) => message?.type === "warning").length, |
| 708 | ); |
| 709 | group.dataset.fullInfoSteps = String( |
| 710 | allSteps.filter((message) => message?.type === "info").length, |
| 711 | ); |
| 712 | const lastAgentMessage = allSteps.findLast( |
| 713 | (message) => message?.type === "agent", |
| 714 | ); |
| 715 | const fullTitle = cleanStepTitle(lastAgentMessage?.heading, 50); |
| 716 | if (fullTitle) { |
| 717 | const title = group.querySelector(".process-group-header .group-title"); |
| 718 | if (title) title.textContent = fullTitle; |
| 719 | } |
| 720 | updateProcessGroupHeader(group); |
| 721 | |
| 722 | if (state.hidden <= 0) continue; |
| 723 | const stepsContainer = group.querySelector(":scope .process-steps"); |
| 724 | if (!stepsContainer) continue; |
| 725 | const button = document.createElement("button"); |
| 726 | button.type = "button"; |
| 727 | button.className = "process-group-show-more"; |
| 728 | button.textContent = "Show more"; |
| 729 | const nextCount = Math.min(PROCESS_GROUP_STEP_PAGE_SIZE, state.hidden); |
| 730 | button.setAttribute("aria-label", `Show ${nextCount} earlier steps`); |
| 731 | button.addEventListener("click", () => { |
| 732 | void showMoreProcessGroupSteps(key); |
| 733 | }); |
| 734 | stepsContainer.insertBefore(button, stepsContainer.firstChild); |
| 735 | } |
| 736 | } |
| 737 | |
| 738 | function showMoreProcessGroupSteps(groupKey) { |
| 739 | const generation = _messageRenderGeneration; |
| 740 | const task = _messageRenderQueue.then(async () => { |
| 741 | if (generation !== _messageRenderGeneration) return false; |
| 742 | const current = _processGroupStepLimits.get(groupKey) || |
| 743 | PROCESS_GROUP_STEP_PAGE_SIZE; |
| 744 | _processGroupStepLimits.set( |
| 745 | groupKey, |
| 746 | current + PROCESS_GROUP_STEP_PAGE_SIZE, |
| 747 | ); |
| 748 | await renderMessageWindow({ preserveScroll: true, generation }); |
| 749 | return true; |
| 750 | }); |
| 751 | _messageRenderQueue = task.catch(() => undefined); |
| 752 | return task; |
| 753 | } |
| 754 | |
| 755 | function shouldFollowMessageTail() { |
| 756 | if (_messageWindow.size === 0) return true; |
| 757 | return _messageWindowFollowTail && _messageWindow.isAtTail(); |
| 758 | } |
| 759 | |
| 760 | async function renderDeferredMessageDetail(message) { |
| 761 | const entry = await setMessage(message); |
| 762 | await callJsExtensions("set_messages_after_loop", { |
| 763 | messages: [message], |
| 764 | history: getChatHistoryEl(), |
| 765 | historyEmpty: true, |
| 766 | isLargeAppend: false, |
| 767 | cutoff: 0, |
| 768 | massRender: false, |
| 769 | windowRebuild: false, |
| 770 | detailMaterialization: true, |
| 771 | messageWindow: getMessageWindowContext(), |
| 772 | mainScroller: null, |
| 773 | results: [entry], |
| 774 | }); |
| 775 | return entry; |
| 776 | } |
| 777 | |
| 778 | function requestDeferredMessageDetail(message) { |
| 779 | if (_messageWindowRenderPromise) { |
| 780 | return renderDeferredMessageDetail(message); |
| 781 | } |
| 782 | |
| 783 | const generation = _messageRenderGeneration; |
| 784 | const task = _messageRenderQueue.then(async () => { |
| 785 | if (generation !== _messageRenderGeneration) return null; |
| 786 | return await renderDeferredMessageDetail(message); |
| 787 | }); |
| 788 | _messageRenderQueue = task.catch(() => undefined); |
| 789 | return task; |
| 790 | } |
| 791 | |
| 792 | function bindMessageWindow(history) { |
| 793 | if (!history || _messageWindowHistory === history) return; |
| 794 | _messageWindowHistory = history; |
| 795 | _lastMessageWindowScrollTop = history.scrollTop; |
| 796 | |
| 797 | const noteUserScrollIntent = () => { |
| 798 | _messageWindowUserScrollUntil = |
| 799 | messageWindowNow() + MESSAGE_WINDOW_USER_SCROLL_GRACE_MS; |
| 800 | }; |
| 801 | |
| 802 | history.addEventListener("wheel", noteUserScrollIntent, { passive: true }); |
| 803 | history.addEventListener("touchstart", noteUserScrollIntent, { |
| 804 | passive: true, |
| 805 | }); |
| 806 | history.addEventListener("pointerdown", () => { |
| 807 | _messageWindowPointerActive = true; |
| 808 | noteUserScrollIntent(); |
| 809 | }); |
| 810 | globalThis.addEventListener("pointerup", () => { |
| 811 | _messageWindowPointerActive = false; |
| 812 | }); |
| 813 | globalThis.addEventListener("pointercancel", () => { |
| 814 | _messageWindowPointerActive = false; |
| 815 | }); |
| 816 | globalThis.addEventListener("keydown", (event) => { |
| 817 | const target = event.target; |
| 818 | if ( |
| 819 | target instanceof Element && |
| 820 | target.closest("input, textarea, select, [contenteditable='true']") |
| 821 | ) { |
| 822 | return; |
| 823 | } |
| 824 | if ( |
| 825 | ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End"].includes( |
| 826 | event.key, |
| 827 | ) |
| 828 | ) { |
| 829 | noteUserScrollIntent(); |
| 830 | } |
| 831 | }); |
| 832 | |
| 833 | history.addEventListener( |
| 834 | "scroll", |
| 835 | () => { |
| 836 | if (_messageWindowScrollFrame != null) return; |
| 837 | _messageWindowScrollFrame = requestAnimationFrame(() => { |
| 838 | _messageWindowScrollFrame = null; |
| 839 | if (_messageWindowRenderPromise) return; |
| 840 | |
| 841 | const previous = _lastMessageWindowScrollTop; |
| 842 | const current = history.scrollTop; |
| 843 | const direction = current < previous ? "older" : current > previous ? "newer" : null; |
| 844 | _lastMessageWindowScrollTop = current; |
| 845 | |
| 846 | const hasUserScrollIntent = |
| 847 | _messageWindowPointerActive || |
| 848 | messageWindowNow() <= _messageWindowUserScrollUntil; |
| 849 | const bottomDistance = |
| 850 | history.scrollHeight - current - history.clientHeight; |
| 851 | |
| 852 | if (hasUserScrollIntent && direction) { |
| 853 | _messageWindowFollowTail = |
| 854 | _messageWindow.isAtTail() && |
| 855 | bottomDistance <= MESSAGE_WINDOW_TAIL_TOLERANCE_PX; |
| 856 | } |
| 857 | |
| 858 | if ( |
| 859 | _messageWindowSuppressScrollEvents || |
| 860 | _messageWindowRenderPromise || |
| 861 | !hasUserScrollIntent |
| 862 | ) { |
| 863 | return; |
| 864 | } |
| 865 | |
| 866 | if ( |
| 867 | direction === "older" && |
| 868 | current <= MESSAGE_WINDOW_BOUNDARY_TOLERANCE_PX && |
| 869 | _messageWindow.hasOlder |
| 870 | ) { |
| 871 | void shiftMessageWindow("older"); |
| 872 | return; |
| 873 | } |
| 874 | |
| 875 | if ( |
| 876 | direction === "newer" && |
| 877 | bottomDistance <= MESSAGE_WINDOW_BOUNDARY_TOLERANCE_PX && |
| 878 | _messageWindow.hasNewer |
| 879 | ) { |
| 880 | void shiftMessageWindow("newer"); |
| 881 | } |
| 882 | }); |
| 883 | }, |
| 884 | { passive: true }, |
| 885 | ); |
| 886 | } |
| 887 | |
| 888 | function messageWindowNow() { |
| 889 | return globalThis.performance?.now?.() ?? Date.now(); |
| 890 | } |
| 891 | |
| 892 | function refreshMessageWindowResizeObserver(history) { |
| 893 | if (!history || typeof ResizeObserver === "undefined") return; |
| 894 | if (!_messageWindowResizeObserver) { |
| 895 | _messageWindowResizeObserver = new ResizeObserver((entries) => { |
| 896 | entries.forEach((entry) => |
| 897 | refreshCollapsibleMessageOverflow(entry.target), |
| 898 | ); |
| 899 | |
| 900 | const liveHistory = getChatHistoryEl(); |
| 901 | if ( |
| 902 | _messageWindowSuppressScrollEvents || |
| 903 | _messageWindowRenderPromise || |
| 904 | !_messageWindowFollowTail || |
| 905 | !_messageWindow.isAtTail() |
| 906 | ) { |
| 907 | return; |
| 908 | } |
| 909 | |
| 910 | cancelPendingScroll(liveHistory); |
| 911 | liveHistory.scrollTop = liveHistory.scrollHeight; |
| 912 | _lastMessageWindowScrollTop = liveHistory.scrollTop; |
| 913 | }); |
| 914 | } |
| 915 | |
| 916 | history |
| 917 | .querySelectorAll(":scope > .message-group") |
| 918 | .forEach((group) => _messageWindowResizeObserver.observe(group)); |
| 919 | } |
| 920 | |
| 921 | async function shiftMessageWindow(direction) { |
| 922 | await loadAdjacentMessageWindow(direction); |
| 923 | } |
| 924 | |
| 925 | export function loadAdjacentMessageWindow(direction) { |
| 926 | if (!["older", "newer"].includes(direction)) { |
| 927 | return Promise.resolve(false); |
| 928 | } |
| 929 | if (_messageWindowLoadingDirection) return Promise.resolve(false); |
| 930 | |
| 931 | const generation = _messageRenderGeneration; |
| 932 | _messageWindowLoadingDirection = direction; |
| 933 | setMessageWindowIndicatorLoading(getChatHistoryEl(), direction, true); |
| 934 | |
| 935 | const renderTask = _messageRenderQueue.then(async () => { |
| 936 | if (generation !== _messageRenderGeneration) return false; |
| 937 | const shifted = |
| 938 | direction === "older" |
| 939 | ? _messageWindow.shiftOlder() |
| 940 | : _messageWindow.shiftNewer(); |
| 941 | if (!shifted) return false; |
| 942 | await renderMessageWindow({ preserveScroll: true, generation }); |
| 943 | return true; |
| 944 | }); |
| 945 | const task = renderTask.finally(() => { |
| 946 | if (_messageWindowLoadingDirection === direction) { |
| 947 | _messageWindowLoadingDirection = null; |
| 948 | setMessageWindowIndicatorLoading(getChatHistoryEl(), direction, false); |
| 949 | } |
| 950 | }); |
| 951 | _messageRenderQueue = task.catch(() => undefined); |
| 952 | return task; |
| 953 | } |
| 954 | |
| 955 | export function scrollMessageWindowToEdge(edge) { |
| 956 | const generation = _messageRenderGeneration; |
| 957 | const task = _messageRenderQueue.then(async () => { |
| 958 | if (generation !== _messageRenderGeneration) return false; |
| 959 | const history = getChatHistoryEl(); |
| 960 | |
| 961 | if (edge === "start") { |
| 962 | _messageWindowFollowTail = false; |
| 963 | cancelPendingScroll(history); |
| 964 | if (!_messageWindow.hasOlder && _messageWindow.start === 0) { |
| 965 | history?.scrollTo({ top: 0, behavior: "instant" }); |
| 966 | return true; |
| 967 | } |
| 968 | _messageWindow.showHead(); |
| 969 | await renderMessageWindow({ preserveScroll: false, generation }); |
| 970 | history?.scrollTo({ top: 0, behavior: "instant" }); |
| 971 | return true; |
| 972 | } |
| 973 | |
| 974 | _messageWindowFollowTail = true; |
| 975 | cancelPendingScroll(history); |
| 976 | if (_messageWindow.isAtTail()) { |
| 977 | if (history) history.scrollTop = history.scrollHeight; |
| 978 | return true; |
| 979 | } |
| 980 | _messageWindow.showTail(); |
| 981 | await renderMessageWindow({ preserveScroll: false, generation }); |
| 982 | if (history) history.scrollTop = history.scrollHeight; |
| 983 | return true; |
| 984 | }); |
| 985 | _messageRenderQueue = task.catch(() => undefined); |
| 986 | return task; |
| 987 | } |
| 988 | |
| 989 | export function getMessageWindowState() { |
| 990 | return getMessageWindowContext(); |
| 991 | } |
| 992 | |
| 993 | function updateMessageWindowIndicators(history) { |
| 994 | if (!history) return; |
| 995 | history |
| 996 | .querySelectorAll(":scope > [data-message-window-ui]") |
| 997 | .forEach((element) => element.remove()); |
| 998 | |
| 999 | history.dataset.messageWindowStart = String(_messageWindow.visibleStart); |
| 1000 | history.dataset.messageWindowEnd = String(_messageWindow.visibleEnd); |
| 1001 | history.dataset.messageWindowTotal = String(_messageWindow.size); |
| 1002 | |
| 1003 | if (_messageWindow.hasOlder) { |
| 1004 | const older = createMessageWindowIndicator("older"); |
| 1005 | history.insertBefore(older, history.firstChild); |
| 1006 | } |
| 1007 | |
| 1008 | if (_messageWindow.hasNewer) { |
| 1009 | history.appendChild(createMessageWindowIndicator("newer")); |
| 1010 | } |
| 1011 | } |
| 1012 | |
| 1013 | function setMessageWindowIndicatorLoading(history, direction, loading) { |
| 1014 | if (!history) return; |
| 1015 | let indicator = history.querySelector( |
| 1016 | `:scope > [data-message-window-ui="${direction}"]`, |
| 1017 | ); |
| 1018 | if (!indicator && loading) { |
| 1019 | updateMessageWindowIndicators(history); |
| 1020 | indicator = history.querySelector( |
| 1021 | `:scope > [data-message-window-ui="${direction}"]`, |
| 1022 | ); |
| 1023 | } |
| 1024 | if (!indicator) return; |
| 1025 | |
| 1026 | indicator.classList.toggle("is-loading", loading); |
| 1027 | indicator |
| 1028 | .querySelector(":scope > .three-bubble-loader") |
| 1029 | ?.classList.toggle("is-active", loading); |
| 1030 | if (loading) { |
| 1031 | const label = direction === "older" ? "earlier" : "newer"; |
| 1032 | indicator.setAttribute("role", "status"); |
| 1033 | indicator.setAttribute("aria-live", "polite"); |
| 1034 | indicator.setAttribute("aria-label", `Loading ${label} messages`); |
| 1035 | indicator.removeAttribute("aria-hidden"); |
| 1036 | } else { |
| 1037 | indicator.removeAttribute("role"); |
| 1038 | indicator.removeAttribute("aria-live"); |
| 1039 | indicator.removeAttribute("aria-label"); |
| 1040 | indicator.setAttribute("aria-hidden", "true"); |
| 1041 | } |
| 1042 | } |
| 1043 | |
| 1044 | function createMessageWindowIndicator(direction) { |
| 1045 | const indicator = document.createElement("div"); |
| 1046 | const label = direction === "older" ? "earlier" : "newer"; |
| 1047 | const isLoading = _messageWindowLoadingDirection === direction; |
| 1048 | indicator.className = `message-window-loader message-window-${direction}`; |
| 1049 | indicator.classList.toggle("is-loading", isLoading); |
| 1050 | indicator.dataset.messageWindowUi = direction; |
| 1051 | if (isLoading) { |
| 1052 | indicator.setAttribute("role", "status"); |
| 1053 | indicator.setAttribute("aria-live", "polite"); |
| 1054 | indicator.setAttribute("aria-label", `Loading ${label} messages`); |
| 1055 | } else { |
| 1056 | indicator.setAttribute("aria-hidden", "true"); |
| 1057 | } |
| 1058 | indicator.appendChild(createThreeBubbleLoader({ active: isLoading })); |
| 1059 | const statusLabel = document.createElement("span"); |
| 1060 | statusLabel.className = "loading-indicator-label"; |
| 1061 | statusLabel.textContent = `Loading ${label} messages`; |
| 1062 | indicator.appendChild(statusLabel); |
| 1063 | return indicator; |
| 1064 | } |
| 1065 | |
| 1066 | function createMessageWindowStagingHistory(history) { |
| 1067 | const staging = history.cloneNode(false); |
| 1068 | const historyRect = history.getBoundingClientRect(); |
| 1069 | staging.classList.add("message-window-staging"); |
| 1070 | staging.setAttribute("aria-hidden", "true"); |
| 1071 | staging.style.position = "fixed"; |
| 1072 | staging.style.top = "0"; |
| 1073 | staging.style.left = "-100000px"; |
| 1074 | staging.style.width = `${historyRect.width}px`; |
| 1075 | staging.style.height = `${historyRect.height}px`; |
| 1076 | staging.style.visibility = "hidden"; |
| 1077 | staging.style.pointerEvents = "none"; |
| 1078 | staging.style.contain = "layout style paint"; |
| 1079 | delete staging.dataset.scrollerTimeout; |
| 1080 | delete staging.dataset.scrollerReapplySnapshot; |
| 1081 | delete staging.dataset.scrollingTo; |
| 1082 | history.after(staging); |
| 1083 | return staging; |
| 1084 | } |
| 1085 | |
| 1086 | function copyMessageWindowDataset(history, state) { |
| 1087 | for (const [key, value] of Object.entries(state)) { |
| 1088 | if (value === undefined) delete history.dataset[key]; |
| 1089 | else history.dataset[key] = value; |
| 1090 | } |
| 1091 | } |
| 1092 | |
| 1093 | function getMessageWindowAnchorCandidates(history) { |
| 1094 | return Array.from( |
| 1095 | history.querySelectorAll( |
| 1096 | ".process-group[data-render-group-key], [data-message-key]", |
| 1097 | ), |
| 1098 | ).filter((element) => |
| 1099 | element.dataset.renderGroupKey || !element.closest(".process-group") |
| 1100 | ); |
| 1101 | } |
| 1102 | |
| 1103 | function getMessageWindowAnchorIdentity(element) { |
| 1104 | if (element?.dataset?.renderGroupKey) { |
| 1105 | return `group:${element.dataset.renderGroupKey}`; |
| 1106 | } |
| 1107 | if (element?.dataset?.messageKey) { |
| 1108 | return `message:${element.dataset.messageKey}`; |
| 1109 | } |
| 1110 | return null; |
| 1111 | } |
| 1112 | |
| 1113 | function captureMessageWindowAnchor(history) { |
| 1114 | const historyRect = history.getBoundingClientRect(); |
| 1115 | const candidates = getMessageWindowAnchorCandidates(history); |
| 1116 | let fallback = null; |
| 1117 | |
| 1118 | for (const element of candidates) { |
| 1119 | const rect = element.getBoundingClientRect(); |
| 1120 | if (rect.height <= 0 || rect.bottom <= historyRect.top) continue; |
| 1121 | const anchor = { |
| 1122 | identity: getMessageWindowAnchorIdentity(element), |
| 1123 | offset: rect.top - historyRect.top, |
| 1124 | }; |
| 1125 | if (rect.top < historyRect.bottom) return anchor; |
| 1126 | fallback ||= anchor; |
| 1127 | } |
| 1128 | |
| 1129 | return fallback; |
| 1130 | } |
| 1131 | |
| 1132 | function restoreMessageWindowAnchor(history, anchor) { |
| 1133 | if (!anchor?.identity) return false; |
| 1134 | const historyRect = history.getBoundingClientRect(); |
| 1135 | const element = getMessageWindowAnchorCandidates(history).find( |
| 1136 | (candidate) => |
| 1137 | getMessageWindowAnchorIdentity(candidate) === anchor.identity, |
| 1138 | ); |
| 1139 | if (!element) return false; |
| 1140 | const nextOffset = element.getBoundingClientRect().top - historyRect.top; |
| 1141 | history.scrollTop += nextOffset - anchor.offset; |
| 1142 | return true; |
| 1143 | } |
| 1144 | |
| 1145 | function captureMessageExpansionState(history) { |
| 1146 | const state = new Map(); |
| 1147 | history |
| 1148 | .querySelectorAll(".process-group[id], .process-step[id]") |
| 1149 | .forEach((element) => { |
| 1150 | const kind = element.classList.contains("process-group") |
| 1151 | ? "group" |
| 1152 | : "step"; |
| 1153 | state.set( |
| 1154 | `${kind}:${element.id}`, |
| 1155 | element.classList.contains("expanded"), |
| 1156 | ); |
| 1157 | }); |
| 1158 | history |
| 1159 | .querySelectorAll("[data-message-key] > .message") |
| 1160 | .forEach((element) => { |
| 1161 | state.set( |
| 1162 | `message:${element.parentElement.dataset.messageKey}`, |
| 1163 | element.classList.contains("expanded"), |
| 1164 | ); |
| 1165 | }); |
| 1166 | return state; |
| 1167 | } |
| 1168 | |
| 1169 | async function restoreMessageExpansionState(history, state) { |
| 1170 | const pending = []; |
| 1171 | for (const [key, expanded] of state) { |
| 1172 | let element = null; |
| 1173 | if (key.startsWith("group:") || key.startsWith("step:")) { |
| 1174 | const separator = key.indexOf(":"); |
| 1175 | const kind = key.slice(0, separator); |
| 1176 | const id = key.slice(separator + 1); |
| 1177 | const selector = kind === "group" ? ".process-group[id]" : ".process-step[id]"; |
| 1178 | element = Array.from(history.querySelectorAll(selector)).find( |
| 1179 | (candidate) => candidate.id === id, |
| 1180 | ); |
| 1181 | } else if (key.startsWith("message:")) { |
| 1182 | const messageKey = key.slice(8); |
| 1183 | const container = Array.from( |
| 1184 | history.querySelectorAll("[data-message-key]"), |
| 1185 | ).find((candidate) => candidate.dataset.messageKey === messageKey); |
| 1186 | element = container?.querySelector(":scope > .message") || null; |
| 1187 | } |
| 1188 | if (!element || !history.contains(element)) continue; |
| 1189 | if (typeof element.__setExpanded === "function") { |
| 1190 | pending.push(Promise.resolve(element.__setExpanded(expanded))); |
| 1191 | } else { |
| 1192 | element.classList.toggle("expanded", expanded); |
| 1193 | } |
| 1194 | } |
| 1195 | await Promise.allSettled(pending); |
| 1196 | } |
| 1197 | |
| 1198 | function nextAnimationFrame() { |
| 1199 | return new Promise((resolve) => requestAnimationFrame(() => resolve())); |
| 1200 | } |
| 1201 | |
| 1202 | function appendToMessageGroup( |
| 1203 | messageContainer, |
| 1204 | position, |
| 1205 | forceNewGroup = false, |
| 1206 | ) { |
| 1207 | const chatHistoryEl = getChatHistoryEl(); |
| 1208 | if (!chatHistoryEl) return; |
| 1209 | |
| 1210 | const lastGroup = getLastMessageGroup(); |
| 1211 | const lastGroupType = lastGroup?.getAttribute("data-group-type"); |
| 1212 | |
| 1213 | if (!forceNewGroup && lastGroup && lastGroupType === position) { |
| 1214 | lastGroup.appendChild(messageContainer); |
| 1215 | } else { |
| 1216 | const group = document.createElement("div"); |
| 1217 | group.classList.add("message-group", `message-group-${position}`); |
| 1218 | group.setAttribute("data-group-type", position); |
| 1219 | group.appendChild(messageContainer); |
| 1220 | const bottomControl = chatHistoryEl.querySelector( |
| 1221 | ':scope > [data-message-window-ui="newer"]', |
| 1222 | ); |
| 1223 | chatHistoryEl.insertBefore(group, bottomControl || null); |
| 1224 | } |
| 1225 | } |
| 1226 | |
| 1227 | function getLastProcessGroup(allowCompleted = true) { |
| 1228 | const lastContainer = getLastMessageGroup(); |
| 1229 | if (!lastContainer) return null; |
| 1230 | const groups = lastContainer.querySelectorAll(".process-group"); |
| 1231 | if (groups.length === 0) return null; |
| 1232 | const group = groups[groups.length - 1]; |
| 1233 | if (!allowCompleted && isProcessGroupComplete(group)) return null; |
| 1234 | |
| 1235 | return group; |
| 1236 | } |
| 1237 | |
| 1238 | function getOrCreateProcessGroup(id, allowCompleted = true, renderInfo = null) { |
| 1239 | const groupIdentity = renderInfo?.id || id; |
| 1240 | // first try direct match by ID |
| 1241 | const byId = getChatHistoryElementById(`process-group-${groupIdentity}`); |
| 1242 | if (byId) return byId; |
| 1243 | |
| 1244 | // if not found, try to find the last process group |
| 1245 | const existing = getLastProcessGroup(allowCompleted); |
| 1246 | if (existing) return existing; |
| 1247 | |
| 1248 | // lastly create new |
| 1249 | const messageContainer = document.createElement("div"); |
| 1250 | messageContainer.id = `process-group-${groupIdentity}`; |
| 1251 | messageContainer.classList.add( |
| 1252 | "message-container", |
| 1253 | "ai-container", |
| 1254 | "has-process-group", |
| 1255 | ); |
| 1256 | |
| 1257 | const group = createProcessGroup(groupIdentity); |
| 1258 | if (renderInfo?.key) group.dataset.renderGroupKey = renderInfo.key; |
| 1259 | group.classList.add("embedded"); |
| 1260 | messageContainer.appendChild(group); |
| 1261 | |
| 1262 | if (_scrollOnNextProcessGroup === "wait") { |
| 1263 | _scrollOnNextProcessGroup = "scroll"; |
| 1264 | } |
| 1265 | |
| 1266 | appendToMessageGroup(messageContainer, "left"); |
| 1267 | return group; |
| 1268 | } |
| 1269 | |
| 1270 | export function buildDetailPayload(stepData, extras = {}) { |
| 1271 | if (!stepData) return null; |
| 1272 | return { |
| 1273 | ...stepData, |
| 1274 | ...extras, |
| 1275 | }; |
| 1276 | } |
| 1277 | |
| 1278 | /** |
| 1279 | * @param {ProcessStepArgs & Record<string, any>} param0 |
| 1280 | * @returns {MessageHandlerResult} |
| 1281 | */ |
| 1282 | export function drawProcessStep({ |
| 1283 | id, |
| 1284 | title, |
| 1285 | code, |
| 1286 | classes, |
| 1287 | kvps, |
| 1288 | content, |
| 1289 | contentClasses, |
| 1290 | actionButtons = [], |
| 1291 | log, |
| 1292 | allowCompletedGroup = false, |
| 1293 | ...additional |
| 1294 | }) { |
| 1295 | // group and steps DOM elements |
| 1296 | const stepId = `process-step-${id}`; |
| 1297 | let step = getChatHistoryElementById(stepId); |
| 1298 | |
| 1299 | const renderInfo = log[PROCESS_GROUP_RENDER_INFO]; |
| 1300 | const group = |
| 1301 | getStepProcessGroup(step) || |
| 1302 | getOrCreateProcessGroup( |
| 1303 | id, |
| 1304 | allowCompletedGroup, |
| 1305 | renderInfo, |
| 1306 | ); |
| 1307 | if (renderInfo) { |
| 1308 | // A later process step can promote a previously standalone live utility |
| 1309 | // into a substantive unit when the full cache is reclassified. |
| 1310 | group.classList.remove("utility-only"); |
| 1311 | } else if (log.type === "util") { |
| 1312 | // Standalone utilities are not part of a substantive render unit. Mark |
| 1313 | // them directly from the full-log classifier instead of inferring group |
| 1314 | // visibility from whichever child steps happen to be mounted so far. |
| 1315 | group.classList.add("utility-only"); |
| 1316 | } |
| 1317 | const stepsContainer = group.querySelector(".process-steps"); |
| 1318 | |
| 1319 | const isNewStep = !step; |
| 1320 | const isGroupComplete = isProcessGroupComplete(group); |
| 1321 | |
| 1322 | // Set start timestamp on group when first step is created |
| 1323 | if ( |
| 1324 | isNewStep && |
| 1325 | !group.hasAttribute("data-start-timestamp") && |
| 1326 | log.timestamp |
| 1327 | ) { |
| 1328 | group.setAttribute("data-start-timestamp", String(log.timestamp)); |
| 1329 | } |
| 1330 | |
| 1331 | if (!step) { |
| 1332 | // create the base DOM element for the step |
| 1333 | step = document.createElement("div"); |
| 1334 | step.id = stepId; |
| 1335 | step.classList.add("process-step"); |
| 1336 | |
| 1337 | // set data attributes of the step |
| 1338 | step.setAttribute("data-log-type", log.type); |
| 1339 | step.setAttribute("data-step-id", String(id)); |
| 1340 | step.setAttribute("data-agent-number", log.agentno); |
| 1341 | |
| 1342 | // set timestamp attribute (convert to milliseconds for duration calculation) |
| 1343 | if (log.timestamp) { |
| 1344 | step.setAttribute( |
| 1345 | "data-timestamp", |
| 1346 | String(Math.round(log.timestamp * 1000)), |
| 1347 | ); |
| 1348 | } |
| 1349 | |
| 1350 | // apply step classes |
| 1351 | if (classes) step.classList.add(...classes); |
| 1352 | |
| 1353 | let appendTarget = stepsContainer; |
| 1354 | |
| 1355 | // grouping subordinate chain under the delegation call |
| 1356 | // for now disabled, let's keep the UI simple and unified for now |
| 1357 | // const parentStep = findParentDelegationStep(group, log.agentno); |
| 1358 | // if (parentStep) { |
| 1359 | // appendTarget = getNestedContainer(parentStep); |
| 1360 | // step.classList.add("nested-step"); |
| 1361 | // } |
| 1362 | |
| 1363 | // remove any existing shiny-text from group |
| 1364 | group |
| 1365 | .querySelectorAll(".process-step .step-title.shiny-text") |
| 1366 | .forEach((el) => { |
| 1367 | el.classList.remove("shiny-text"); |
| 1368 | }); |
| 1369 | |
| 1370 | // insert step |
| 1371 | appendTarget.appendChild(step); |
| 1372 | |
| 1373 | // expand all or current step based on settings |
| 1374 | const detailMode = preferencesStore.detailMode; |
| 1375 | // const isActiveGroup = group.classList.contains("active"); |
| 1376 | |
| 1377 | //expand all |
| 1378 | if (detailMode === "expanded") { |
| 1379 | toggleStepCollapse(step, true); |
| 1380 | // expand current step and schedule collapse of previous |
| 1381 | } else if ( |
| 1382 | detailMode === "current" && |
| 1383 | !isMassRender() && |
| 1384 | !isGroupComplete |
| 1385 | ) { |
| 1386 | stepsContainer |
| 1387 | .querySelectorAll(".process-step.expanded") |
| 1388 | .forEach((expandedStep) => { |
| 1389 | const delay = |
| 1390 | STEP_COLLAPSE_DELAY[expandedStep.getAttribute("data-log-type")] || |
| 1391 | STEP_COLLAPSE_DELAY.other; |
| 1392 | console.log( |
| 1393 | "collapsing", |
| 1394 | expandedStep.getAttribute("data-log-type"), |
| 1395 | delay, |
| 1396 | ); |
| 1397 | scheduleStepCollapse(expandedStep, delay); |
| 1398 | }); |
| 1399 | toggleStepCollapse(step, true); |
| 1400 | } |
| 1401 | |
| 1402 | // create step header |
| 1403 | const stepHeader = ensureChild( |
| 1404 | step, |
| 1405 | ".process-step-header", |
| 1406 | "div", |
| 1407 | "process-step-header", |
| 1408 | ); |
| 1409 | } |
| 1410 | |
| 1411 | // is step expanded? |
| 1412 | const isExpanded = step.classList.contains("expanded"); |
| 1413 | const shouldRenderDetail = |
| 1414 | isExpanded && group.classList.contains("expanded"); |
| 1415 | |
| 1416 | // create step header |
| 1417 | const stepHeader = ensureChild( |
| 1418 | step, |
| 1419 | ".process-step-header", |
| 1420 | "div", |
| 1421 | "process-step-header", |
| 1422 | ); |
| 1423 | |
| 1424 | // Keep the lightweight detail shell and action hooks mounted for extensions, |
| 1425 | // but materialize text-heavy detail content only while the step is expanded. |
| 1426 | const stepDetail = ensureChild( |
| 1427 | step, |
| 1428 | ".process-step-detail", |
| 1429 | "div", |
| 1430 | "process-step-detail", |
| 1431 | ); |
| 1432 | // set click handlers |
| 1433 | setupProcessStepHandlers(step, stepHeader); |
| 1434 | |
| 1435 | // header row - expand icon |
| 1436 | ensureChild(stepHeader, ".step-expand-icon", "span", "step-expand-icon"); |
| 1437 | |
| 1438 | // header row - status badge |
| 1439 | const badge = ensureChild(stepHeader, ".step-badge", "span", "step-badge"); |
| 1440 | |
| 1441 | // set code class if changed |
| 1442 | const prevCode = step.getAttribute("data-step-code"); |
| 1443 | if (prevCode !== code) { |
| 1444 | if (prevCode) step.classList.remove(prevCode); |
| 1445 | step.setAttribute("data-step-code", code); |
| 1446 | step.classList.add(code); |
| 1447 | badge.innerText = code; |
| 1448 | } |
| 1449 | |
| 1450 | // header row - title |
| 1451 | const titleEl = ensureChild(stepHeader, ".step-title", "span", "step-title"); |
| 1452 | titleEl.textContent = title; |
| 1453 | |
| 1454 | // Render action buttons: get/create container, clear, append |
| 1455 | const stepActionBtns = ensureChild( |
| 1456 | stepDetail, |
| 1457 | ".step-detail-actions", |
| 1458 | "div", |
| 1459 | "step-detail-actions", |
| 1460 | "step-action-buttons", |
| 1461 | ); |
| 1462 | syncActionButtons(stepActionBtns, actionButtons); |
| 1463 | |
| 1464 | let detailResult = { |
| 1465 | content: undefined, |
| 1466 | contentScroller: null, |
| 1467 | kvpsTable: null, |
| 1468 | }; |
| 1469 | if (shouldRenderDetail) { |
| 1470 | detailResult = renderProcessStepDetail({ |
| 1471 | stepDetail, |
| 1472 | kvps, |
| 1473 | content, |
| 1474 | contentClasses, |
| 1475 | }); |
| 1476 | } else { |
| 1477 | discardProcessStepDetail(step); |
| 1478 | } |
| 1479 | |
| 1480 | // update the process grop header by this step |
| 1481 | updateProcessGroupHeader(group); |
| 1482 | |
| 1483 | // remove shine from previous steps and add to this one if new and not completed |
| 1484 | if (isNewStep && !isGroupComplete) { |
| 1485 | group |
| 1486 | .querySelectorAll(".step-title.shiny-text") |
| 1487 | .forEach((el) => { |
| 1488 | el.classList.remove("shiny-text"); |
| 1489 | }); |
| 1490 | titleEl.classList.add("shiny-text"); |
| 1491 | } |
| 1492 | |
| 1493 | // return anything useful |
| 1494 | return { |
| 1495 | element: step, |
| 1496 | actionButtons, |
| 1497 | step, |
| 1498 | detail: stepDetail, |
| 1499 | content: detailResult.content, |
| 1500 | contentScroller: detailResult.contentScroller, |
| 1501 | kvpsTable: detailResult.kvpsTable, |
| 1502 | isExpanded, |
| 1503 | detailPending: !shouldRenderDetail, |
| 1504 | }; |
| 1505 | } |
| 1506 | |
| 1507 | function renderProcessStepDetail({ |
| 1508 | stepDetail, |
| 1509 | kvps, |
| 1510 | content, |
| 1511 | contentClasses, |
| 1512 | }) { |
| 1513 | let stepDetailScroll = stepDetail.querySelector( |
| 1514 | ":scope > .process-step-detail-scroll", |
| 1515 | ); |
| 1516 | if (!stepDetailScroll) { |
| 1517 | stepDetailScroll = document.createElement("div"); |
| 1518 | stepDetailScroll.classList.add("process-step-detail-scroll"); |
| 1519 | stepDetail.insertBefore( |
| 1520 | stepDetailScroll, |
| 1521 | stepDetail.querySelector(":scope > .step-detail-actions"), |
| 1522 | ); |
| 1523 | } |
| 1524 | |
| 1525 | const detailScroller = new Scroller(stepDetailScroll, { |
| 1526 | smooth: !isMassRender(), |
| 1527 | toleranceRem: 4, |
| 1528 | }); |
| 1529 | const kvpsTable = drawKvpsIncremental(stepDetailScroll, kvps); |
| 1530 | |
| 1531 | let stepDetailContent; |
| 1532 | if (content) { |
| 1533 | stepDetailContent = ensureChild( |
| 1534 | stepDetailScroll, |
| 1535 | ".process-step-detail-content", |
| 1536 | "p", |
| 1537 | "process-step-detail-content", |
| 1538 | ...(contentClasses || []), |
| 1539 | ); |
| 1540 | stepDetailContent.innerHTML = adjustStepContent(content); |
| 1541 | } else { |
| 1542 | stepDetailScroll |
| 1543 | .querySelector(":scope > .process-step-detail-content") |
| 1544 | ?.remove(); |
| 1545 | } |
| 1546 | |
| 1547 | detailScroller.reApplyScroll(); |
| 1548 | return { |
| 1549 | content: stepDetailContent, |
| 1550 | contentScroller: detailScroller, |
| 1551 | kvpsTable, |
| 1552 | }; |
| 1553 | } |
| 1554 | |
| 1555 | function discardProcessStepDetail(step, { force = false } = {}) { |
| 1556 | if (!step) return; |
| 1557 | const remove = () => { |
| 1558 | if (!force && step.classList.contains("expanded")) return; |
| 1559 | if ( |
| 1560 | force && |
| 1561 | step.classList.contains("expanded") && |
| 1562 | step.closest(".process-group")?.classList.contains("expanded") |
| 1563 | ) { |
| 1564 | return; |
| 1565 | } |
| 1566 | step |
| 1567 | .querySelector(":scope > .process-step-detail > .process-step-detail-scroll") |
| 1568 | ?.remove(); |
| 1569 | }; |
| 1570 | |
| 1571 | if (isMassRender()) remove(); |
| 1572 | else setTimeout(remove, 250); |
| 1573 | } |
| 1574 | |
| 1575 | function adjustStepContent(content) { |
| 1576 | content = escapeHTML(content); |
| 1577 | content = convertPathsToLinks(content); |
| 1578 | return content; |
| 1579 | } |
| 1580 | |
| 1581 | function toggleStepCollapse(step, expanded) { |
| 1582 | if (!step) return; |
| 1583 | |
| 1584 | let nextExpanded = expanded; |
| 1585 | if (nextExpanded === undefined || nextExpanded === null) { |
| 1586 | nextExpanded = !step.classList.contains("expanded"); |
| 1587 | } |
| 1588 | nextExpanded = Boolean(nextExpanded); |
| 1589 | |
| 1590 | step.classList.toggle("expanded", nextExpanded); |
| 1591 | |
| 1592 | if (nextExpanded) { |
| 1593 | if (step.querySelector(".process-step-detail-scroll")) return null; |
| 1594 | return materializeProcessStepDetail(step); |
| 1595 | } |
| 1596 | |
| 1597 | const scroller = step.querySelector(".process-step-detail-scroll"); |
| 1598 | if (scroller) scroller.scrollTop = 0; |
| 1599 | discardProcessStepDetail(step); |
| 1600 | } |
| 1601 | |
| 1602 | function materializeProcessStepDetail(step) { |
| 1603 | if (!step || typeof step.__renderDetail !== "function") return null; |
| 1604 | if (step.__detailRenderPromise) return step.__detailRenderPromise; |
| 1605 | |
| 1606 | step.__detailRenderPromise = Promise.resolve(step.__renderDetail()).finally( |
| 1607 | () => { |
| 1608 | delete step.__detailRenderPromise; |
| 1609 | }, |
| 1610 | ); |
| 1611 | return step.__detailRenderPromise; |
| 1612 | } |
| 1613 | |
| 1614 | function drawStandaloneMessage({ |
| 1615 | id, |
| 1616 | heading, |
| 1617 | content, |
| 1618 | position = "mid", |
| 1619 | forceNewGroup = false, |
| 1620 | containerClasses = [], |
| 1621 | mainClass = "", |
| 1622 | messageClasses = [], |
| 1623 | contentClasses = [], |
| 1624 | markdown = false, |
| 1625 | latex = false, |
| 1626 | kvps = null, |
| 1627 | actionButtons = [], |
| 1628 | }) { |
| 1629 | // end last process group on any standalone messge |
| 1630 | completeLastProcessGroup(); |
| 1631 | |
| 1632 | const container = getOrCreateMessageContainer( |
| 1633 | id, |
| 1634 | position, |
| 1635 | containerClasses, |
| 1636 | forceNewGroup, |
| 1637 | ); |
| 1638 | const messageDiv = _drawMessage({ |
| 1639 | messageContainer: container, |
| 1640 | heading, |
| 1641 | content, |
| 1642 | kvps, |
| 1643 | messageClasses, |
| 1644 | contentClasses, |
| 1645 | markdown, |
| 1646 | latex, |
| 1647 | mainClass, |
| 1648 | }); |
| 1649 | |
| 1650 | // Collapsible with action buttons |
| 1651 | setupCollapsible(messageDiv, ".step-action-buttons", false, actionButtons); |
| 1652 | |
| 1653 | return container; |
| 1654 | } |
| 1655 | |
| 1656 | // draw a message with a specific type |
| 1657 | export function _drawMessage({ |
| 1658 | messageContainer, |
| 1659 | heading, |
| 1660 | content, |
| 1661 | kvps = null, |
| 1662 | messageClasses = [], |
| 1663 | contentClasses = [], |
| 1664 | markdown = false, |
| 1665 | latex = false, |
| 1666 | mainClass = "", |
| 1667 | smoothStream = false, |
| 1668 | }) { |
| 1669 | // Find existing message div or create new one |
| 1670 | let messageDiv = messageContainer.querySelector(".message"); |
| 1671 | if (!messageDiv) { |
| 1672 | messageDiv = document.createElement("div"); |
| 1673 | messageDiv.classList.add("message"); |
| 1674 | messageContainer.appendChild(messageDiv); |
| 1675 | } |
| 1676 | |
| 1677 | // Update message classes (preserve collapsible state) |
| 1678 | const preserve = ["message-collapsible", "expanded", "has-overflow"] |
| 1679 | .filter((c) => messageDiv.classList.contains(c)) |
| 1680 | .join(" "); |
| 1681 | messageDiv.className = `message ${mainClass} ${messageClasses.join(" ")} ${preserve}`; |
| 1682 | |
| 1683 | // Handle heading (important for error/rate_limit messages that show context) |
| 1684 | if (heading) { |
| 1685 | let headingElement = messageDiv.querySelector(".msg-heading"); |
| 1686 | if (!headingElement) { |
| 1687 | headingElement = document.createElement("div"); |
| 1688 | headingElement.classList.add("msg-heading"); |
| 1689 | messageDiv.insertBefore(headingElement, messageDiv.firstChild); |
| 1690 | } |
| 1691 | |
| 1692 | let headingH4 = headingElement.querySelector("h4"); |
| 1693 | if (!headingH4) { |
| 1694 | headingH4 = document.createElement("h4"); |
| 1695 | headingElement.appendChild(headingH4); |
| 1696 | } |
| 1697 | headingH4.innerHTML = convertIcons(escapeHTML(heading)); |
| 1698 | } else { |
| 1699 | // Remove heading if it exists but heading is null |
| 1700 | const existingHeading = messageDiv.querySelector(".msg-heading"); |
| 1701 | if (existingHeading) { |
| 1702 | existingHeading.remove(); |
| 1703 | } |
| 1704 | } |
| 1705 | |
| 1706 | // Find existing body div or create new one |
| 1707 | let bodyDiv = messageDiv.querySelector(".message-body"); |
| 1708 | if (!bodyDiv) { |
| 1709 | bodyDiv = document.createElement("div"); |
| 1710 | bodyDiv.classList.add("message-body"); |
| 1711 | messageDiv.appendChild(bodyDiv); |
| 1712 | } |
| 1713 | |
| 1714 | // reapply scroll position or autoscroll |
| 1715 | bodyDiv.dataset.scrollStabilization = "1"; |
| 1716 | const scroller = new Scroller(bodyDiv, { smooth: !isMassRender() }); |
| 1717 | |
| 1718 | const contentText = String(content ?? ""); |
| 1719 | const lazyContent = |
| 1720 | _windowedRender && |
| 1721 | contentText.length + estimateKvpTextSize(kvps) > LAZY_MESSAGE_PREVIEW_CHARS; |
| 1722 | const contentOptions = { |
| 1723 | bodyDiv, |
| 1724 | content: contentText, |
| 1725 | kvps, |
| 1726 | contentClasses, |
| 1727 | markdown, |
| 1728 | latex, |
| 1729 | smoothStream, |
| 1730 | }; |
| 1731 | |
| 1732 | if (lazyContent) { |
| 1733 | messageDiv.classList.add("lazy-content"); |
| 1734 | delete messageDiv.__lazyRenderedExpanded; |
| 1735 | messageDiv.__renderLazyContent = (expanded) => { |
| 1736 | if (messageDiv.__lazyRenderedExpanded === Boolean(expanded)) return; |
| 1737 | messageDiv.__lazyRenderedExpanded = Boolean(expanded); |
| 1738 | renderStandaloneMessageContent({ |
| 1739 | ...contentOptions, |
| 1740 | content: expanded |
| 1741 | ? contentText |
| 1742 | : `${contentText.slice(0, LAZY_MESSAGE_PREVIEW_CHARS)}\n\n…`, |
| 1743 | kvps: expanded ? kvps : null, |
| 1744 | smoothStream: false, |
| 1745 | }); |
| 1746 | }; |
| 1747 | messageDiv.__renderLazyContent( |
| 1748 | messageDiv.classList.contains("expanded"), |
| 1749 | ); |
| 1750 | } else { |
| 1751 | messageDiv.classList.remove("lazy-content"); |
| 1752 | delete messageDiv.__renderLazyContent; |
| 1753 | delete messageDiv.__lazyRenderedExpanded; |
| 1754 | renderStandaloneMessageContent(contentOptions); |
| 1755 | } |
| 1756 | |
| 1757 | // reapply scroll position or reset for collapsed |
| 1758 | messageDiv.classList.contains("expanded") |
| 1759 | ? scroller.reApplyScroll() |
| 1760 | : (bodyDiv.scrollTop = 0); |
| 1761 | |
| 1762 | return messageDiv; |
| 1763 | } |
| 1764 | |
| 1765 | function renderStandaloneMessageContent({ |
| 1766 | bodyDiv, |
| 1767 | content, |
| 1768 | kvps, |
| 1769 | contentClasses, |
| 1770 | markdown, |
| 1771 | latex, |
| 1772 | smoothStream, |
| 1773 | }) { |
| 1774 | drawKvpsIncremental(bodyDiv, kvps); |
| 1775 | if (!content || !content.trim()) { |
| 1776 | bodyDiv.querySelector(".msg-content")?.remove(); |
| 1777 | return; |
| 1778 | } |
| 1779 | |
| 1780 | if (markdown) { |
| 1781 | let contentDiv = bodyDiv.querySelector(".msg-content"); |
| 1782 | if (!contentDiv || contentDiv.tagName === "PRE") { |
| 1783 | contentDiv?.remove(); |
| 1784 | contentDiv = document.createElement("div"); |
| 1785 | bodyDiv.appendChild(contentDiv); |
| 1786 | } |
| 1787 | contentDiv.className = `msg-content ${contentClasses.join(" ")}`; |
| 1788 | |
| 1789 | let processedContent = content; |
| 1790 | if (latex) processedContent = convertLatexDelimiters(processedContent); |
| 1791 | processedContent = convertImageTags(processedContent); |
| 1792 | processedContent = convertImgFilePaths(processedContent); |
| 1793 | processedContent = convertFilePaths(processedContent); |
| 1794 | processedContent = marked.parse(processedContent, { breaks: true }); |
| 1795 | processedContent = sanitizeHtml(processedContent, { |
| 1796 | allowDataImages: true, |
| 1797 | allowLatex: latex, |
| 1798 | }); |
| 1799 | processedContent = convertPathsToLinks(processedContent); |
| 1800 | processedContent = addBlankTargetsToLinks(processedContent); |
| 1801 | |
| 1802 | if (smoothStream) smoothRender(contentDiv, processedContent); |
| 1803 | else contentDiv.innerHTML = processedContent; |
| 1804 | |
| 1805 | if (latex) renderLatexElements(contentDiv); |
| 1806 | adjustMarkdownRender(contentDiv); |
| 1807 | return; |
| 1808 | } |
| 1809 | |
| 1810 | let preElement = bodyDiv.querySelector(".msg-content"); |
| 1811 | if (!preElement || preElement.tagName !== "PRE") { |
| 1812 | preElement?.remove(); |
| 1813 | preElement = document.createElement("pre"); |
| 1814 | preElement.style.whiteSpace = "pre-wrap"; |
| 1815 | preElement.style.wordBreak = "break-word"; |
| 1816 | bodyDiv.appendChild(preElement); |
| 1817 | } |
| 1818 | preElement.className = `msg-content ${contentClasses.join(" ")}`; |
| 1819 | |
| 1820 | if (smoothStream) smoothRender(preElement, convertHTML(content)); |
| 1821 | else preElement.innerHTML = convertHTML(content); |
| 1822 | } |
| 1823 | |
| 1824 | function estimateKvpTextSize(kvps) { |
| 1825 | if (!kvps) return 0; |
| 1826 | try { |
| 1827 | return JSON.stringify(kvps)?.length || 0; |
| 1828 | } catch { |
| 1829 | return LAZY_MESSAGE_PREVIEW_CHARS + 1; |
| 1830 | } |
| 1831 | } |
| 1832 | |
| 1833 | export { addBlankTargetsToLinks }; |
| 1834 | |
| 1835 | /** |
| 1836 | * @param {MessageHandlerArgs & Record<string, any>} param0 |
| 1837 | * @returns {MessageHandlerResult} |
| 1838 | */ |
| 1839 | export function drawMessageDefault({ |
| 1840 | id, |
| 1841 | heading, |
| 1842 | content, |
| 1843 | kvps = null, |
| 1844 | ...additional |
| 1845 | }) { |
| 1846 | const contentText = String(content ?? ""); |
| 1847 | const actionButtons = contentText.trim() |
| 1848 | ? [ |
| 1849 | createActionButton("copy", "", () => copyToClipboard(contentText)), |
| 1850 | createActionButton("speak", "", () => ttsService.speak(contentText)), |
| 1851 | ].filter(Boolean) |
| 1852 | : []; |
| 1853 | |
| 1854 | const element = drawStandaloneMessage({ |
| 1855 | id, |
| 1856 | heading, |
| 1857 | content, |
| 1858 | position: "left", |
| 1859 | containerClasses: ["ai-container"], |
| 1860 | mainClass: "message-default", |
| 1861 | messageClasses: ["message-ai"], |
| 1862 | contentClasses: ["msg-json"], |
| 1863 | kvps, |
| 1864 | actionButtons, |
| 1865 | }); |
| 1866 | |
| 1867 | return { element }; |
| 1868 | } |
| 1869 | |
| 1870 | /** |
| 1871 | * @param {MessageHandlerArgs & Record<string, any>} param0 |
| 1872 | * @returns {MessageHandlerResult} |
| 1873 | */ |
| 1874 | export function drawMessageAgent({ |
| 1875 | id, |
| 1876 | type, |
| 1877 | heading, |
| 1878 | content, |
| 1879 | kvps = undefined, |
| 1880 | timestamp = undefined, |
| 1881 | agentno = 0, |
| 1882 | ...additional |
| 1883 | }) { |
| 1884 | const title = cleanStepTitle(heading); |
| 1885 | let displayKvps = {}; |
| 1886 | if (kvps?.thoughts) displayKvps["icon://lightbulb[Thoughts]"] = kvps.thoughts; |
| 1887 | if (kvps?.step) displayKvps["icon://step[Step]"] = kvps.step; |
| 1888 | const thoughtsText = String(kvps?.thoughts ?? ""); |
| 1889 | const headerLabels = [ |
| 1890 | kvps?.tool_name && { label: kvps.tool_name, class: "tool-name-badge" }, |
| 1891 | ].filter(Boolean); |
| 1892 | const actionButtons = [ |
| 1893 | createActionButton("detail", "", () => |
| 1894 | stepDetailStore.showStepDetail( |
| 1895 | buildDetailPayload(arguments[0], { headerLabels }), |
| 1896 | ), |
| 1897 | ), |
| 1898 | ]; |
| 1899 | |
| 1900 | if (thoughtsText.trim()) { |
| 1901 | actionButtons.push( |
| 1902 | createActionButton("copy", "", () => copyToClipboard(thoughtsText)), |
| 1903 | ); |
| 1904 | actionButtons.push( |
| 1905 | createActionButton("speak", "", () => ttsService.speak(thoughtsText)), |
| 1906 | ); |
| 1907 | } |
| 1908 | |
| 1909 | const result = drawProcessStep({ |
| 1910 | id, |
| 1911 | title, |
| 1912 | code: "GEN", |
| 1913 | classes: undefined, |
| 1914 | kvps: displayKvps, |
| 1915 | actionButtons, |
| 1916 | log: arguments[0], |
| 1917 | }); |
| 1918 | if (result.kvpsTable) renderLatexText(result.kvpsTable); |
| 1919 | return result; |
| 1920 | } |
| 1921 | |
| 1922 | /** |
| 1923 | * @param {MessageHandlerArgs & Record<string, any>} param0 |
| 1924 | * @returns {MessageHandlerResult} |
| 1925 | */ |
| 1926 | export function drawMessageResponse({ |
| 1927 | id, |
| 1928 | type, |
| 1929 | heading, |
| 1930 | content, |
| 1931 | kvps = undefined, |
| 1932 | timestamp = undefined, |
| 1933 | agentno = 0, |
| 1934 | ...additional |
| 1935 | }) { |
| 1936 | // response of subordinate agent - render as process step |
| 1937 | if (agentno && agentno > 0) { |
| 1938 | const title = getStepTitle(heading, content, type); |
| 1939 | const contentText = String(content ?? ""); |
| 1940 | const actionButtons = contentText.trim() |
| 1941 | ? [ |
| 1942 | createActionButton("copy", "", () => copyToClipboard(contentText)), |
| 1943 | createActionButton("speak", "", () => ttsService.speak(contentText)), |
| 1944 | ].filter(Boolean) |
| 1945 | : []; |
| 1946 | return drawProcessStep({ |
| 1947 | id, |
| 1948 | title, |
| 1949 | code: "RES", |
| 1950 | kvps: {}, |
| 1951 | type, |
| 1952 | heading, |
| 1953 | content, |
| 1954 | timestamp, |
| 1955 | agentno, |
| 1956 | actionButtons, |
| 1957 | log: arguments[0], |
| 1958 | }); |
| 1959 | } |
| 1960 | |
| 1961 | // response of agent 0, render as response to user |
| 1962 | // get last process group or create new container (if first message) |
| 1963 | |
| 1964 | let group = getLastProcessGroup(); |
| 1965 | if (group?.classList.contains("utility-only")) { |
| 1966 | group.setAttribute("data-group-complete", "true"); |
| 1967 | updateProcessGroupHeader(group); |
| 1968 | group = null; |
| 1969 | } |
| 1970 | let container = getChatHistoryElementById(`message-${id}`); // first check for already existing message |
| 1971 | |
| 1972 | |
| 1973 | // if no container found, add to previous process group if exists |
| 1974 | if (!container) { |
| 1975 | if (group) { |
| 1976 | // new response, collapse all previous steps once |
| 1977 | if (!group.querySelector(".process-group-response")) { |
| 1978 | if (preferencesStore.detailMode == "current") |
| 1979 | group.querySelectorAll(".process-step").forEach((step) => { |
| 1980 | scheduleStepCollapse(step); |
| 1981 | }); |
| 1982 | } |
| 1983 | |
| 1984 | container = ensureChild( |
| 1985 | group, |
| 1986 | `#message-${id}.process-group-response`, |
| 1987 | "div", |
| 1988 | "process-group-response", |
| 1989 | ); |
| 1990 | container.id = `message-${id}`; |
| 1991 | } |
| 1992 | } |
| 1993 | |
| 1994 | // no container or valid process group, create new container |
| 1995 | if (!container) container = getOrCreateMessageContainer(id, "left"); |
| 1996 | |
| 1997 | const messageDiv = _drawMessage({ |
| 1998 | messageContainer: container, |
| 1999 | heading: undefined, |
| 2000 | content, |
| 2001 | kvps: undefined, |
| 2002 | messageClasses: [], |
| 2003 | contentClasses: [], |
| 2004 | markdown: true, |
| 2005 | latex: true, |
| 2006 | mainClass: "message-agent-response", |
| 2007 | smoothStream: false, // smooth render disabled, not reliable yet !isMassRender(), // stream smoothly if not in mass render mode |
| 2008 | }); |
| 2009 | |
| 2010 | // Collapsible with action buttons |
| 2011 | const responseText = String(content ?? ""); |
| 2012 | const responseActionButtons = responseText.trim() |
| 2013 | ? [ |
| 2014 | createActionButton("copy", "", () => copyToClipboard(responseText)), |
| 2015 | createActionButton("speak", "", () => ttsService.speak(responseText)), |
| 2016 | ].filter(Boolean) |
| 2017 | : []; |
| 2018 | setupCollapsible( |
| 2019 | messageDiv, |
| 2020 | ":scope > .step-action-buttons", |
| 2021 | !isMassRender(), |
| 2022 | responseActionButtons, |
| 2023 | ); |
| 2024 | |
| 2025 | if (group) updateProcessGroupHeader(group); |
| 2026 | |
| 2027 | return { element: container }; |
| 2028 | } |
| 2029 | |
| 2030 | export function drawMessageModelSetupGate({ id }) { |
| 2031 | const container = getOrCreateMessageContainer(id, "left"); |
| 2032 | container.classList.add("model-setup-gate-container"); |
| 2033 | container.innerHTML = ""; |
| 2034 | |
| 2035 | const messageDiv = document.createElement("div"); |
| 2036 | messageDiv.className = "message message-agent-response model-setup-gate-message"; |
| 2037 | |
| 2038 | const component = document.createElement("x-component"); |
| 2039 | component.setAttribute("path", "chat/model-setup-gate.html"); |
| 2040 | messageDiv.appendChild(component); |
| 2041 | container.appendChild(messageDiv); |
| 2042 | |
| 2043 | return { element: container }; |
| 2044 | } |
| 2045 | |
| 2046 | /** |
| 2047 | * @param {MessageHandlerArgs & Record<string, any>} param0 |
| 2048 | * @returns {MessageHandlerResult} |
| 2049 | */ |
| 2050 | export function drawMessageUser({ |
| 2051 | id, |
| 2052 | heading, |
| 2053 | content, |
| 2054 | kvps = null, |
| 2055 | ...additional |
| 2056 | }) { |
| 2057 | // end last process group on any user message |
| 2058 | completeLastProcessGroup(); |
| 2059 | |
| 2060 | const messageContainer = getOrCreateMessageContainer( |
| 2061 | id, |
| 2062 | "right", |
| 2063 | ["user-container"], |
| 2064 | true, |
| 2065 | ); |
| 2066 | |
| 2067 | // Find existing message div or create new one |
| 2068 | let messageDiv = messageContainer.querySelector(".message"); |
| 2069 | if (!messageDiv) { |
| 2070 | messageDiv = document.createElement("div"); |
| 2071 | messageDiv.classList.add("message", "message-user"); |
| 2072 | messageContainer.appendChild(messageDiv); |
| 2073 | } else { |
| 2074 | // Ensure it has the correct classes if it already exists |
| 2075 | messageDiv.className = "message message-user"; |
| 2076 | } |
| 2077 | |
| 2078 | // Handle content |
| 2079 | let textDiv = messageDiv.querySelector(".message-text"); |
| 2080 | if (content && content.trim().length > 0) { |
| 2081 | if (!textDiv) { |
| 2082 | textDiv = document.createElement("div"); |
| 2083 | textDiv.classList.add("message-text"); |
| 2084 | messageDiv.appendChild(textDiv); |
| 2085 | } |
| 2086 | let spanElement = textDiv.querySelector("pre"); |
| 2087 | if (!spanElement) { |
| 2088 | spanElement = document.createElement("pre"); |
| 2089 | textDiv.appendChild(spanElement); |
| 2090 | } |
| 2091 | spanElement.innerHTML = escapeHTML(content); |
| 2092 | } else { |
| 2093 | if (textDiv) textDiv.remove(); |
| 2094 | } |
| 2095 | |
| 2096 | // Handle attachments |
| 2097 | let attachmentsContainer = messageDiv.querySelector(".attachments-container"); |
| 2098 | if (kvps && kvps.attachments && kvps.attachments.length > 0) { |
| 2099 | if (!attachmentsContainer) { |
| 2100 | attachmentsContainer = document.createElement("div"); |
| 2101 | attachmentsContainer.classList.add("attachments-container"); |
| 2102 | messageDiv.appendChild(attachmentsContainer); |
| 2103 | } |
| 2104 | // Important: Clear existing attachments to re-render, preventing duplicates on update |
| 2105 | attachmentsContainer.innerHTML = ""; |
| 2106 | |
| 2107 | kvps.attachments.forEach((attachment) => { |
| 2108 | const attachmentDiv = document.createElement("div"); |
| 2109 | attachmentDiv.classList.add("attachment-item"); |
| 2110 | |
| 2111 | const displayInfo = attachmentsStore.getAttachmentDisplayInfo(attachment); |
| 2112 | |
| 2113 | if (displayInfo.isImage) { |
| 2114 | attachmentDiv.classList.add("image-type"); |
| 2115 | |
| 2116 | const img = document.createElement("img"); |
| 2117 | img.src = displayInfo.previewUrl; |
| 2118 | img.alt = displayInfo.filename; |
| 2119 | img.classList.add("attachment-preview"); |
| 2120 | img.style.cursor = "pointer"; |
| 2121 | |
| 2122 | attachmentDiv.appendChild(img); |
| 2123 | } else { |
| 2124 | // Render as file tile with title and icon |
| 2125 | attachmentDiv.classList.add("file-type"); |
| 2126 | |
| 2127 | // File icon |
| 2128 | if ( |
| 2129 | displayInfo.previewUrl && |
| 2130 | displayInfo.previewUrl !== displayInfo.filename |
| 2131 | ) { |
| 2132 | const iconImg = document.createElement("img"); |
| 2133 | iconImg.src = displayInfo.previewUrl; |
| 2134 | iconImg.alt = `${displayInfo.extension} file`; |
| 2135 | iconImg.classList.add("file-icon"); |
| 2136 | attachmentDiv.appendChild(iconImg); |
| 2137 | } |
| 2138 | |
| 2139 | // File title |
| 2140 | const fileTitle = document.createElement("div"); |
| 2141 | fileTitle.classList.add("file-title"); |
| 2142 | fileTitle.textContent = displayInfo.filename; |
| 2143 | |
| 2144 | attachmentDiv.appendChild(fileTitle); |
| 2145 | } |
| 2146 | |
| 2147 | attachmentDiv.addEventListener("click", displayInfo.clickHandler); |
| 2148 | |
| 2149 | // @ts-ignore |
| 2150 | attachmentsContainer.appendChild(attachmentDiv); |
| 2151 | }); |
| 2152 | } else { |
| 2153 | if (attachmentsContainer) attachmentsContainer.remove(); |
| 2154 | } |
| 2155 | |
| 2156 | // Render heading below message, if provided |
| 2157 | let headingElement = messageDiv.querySelector(".message-user-heading"); |
| 2158 | if (heading && heading.trim() && heading.trim() !== "User message") { |
| 2159 | if (!headingElement) { |
| 2160 | headingElement = document.createElement("div"); |
| 2161 | headingElement.className = "message-user-heading shiny-text"; |
| 2162 | } |
| 2163 | headingElement.textContent = heading; |
| 2164 | messageDiv.appendChild(headingElement); |
| 2165 | } else if (headingElement) { |
| 2166 | headingElement.remove(); |
| 2167 | } |
| 2168 | |
| 2169 | // Render action buttons: get/create container, clear, append |
| 2170 | const userText = String(content ?? ""); |
| 2171 | const userActionButtons = userText.trim() |
| 2172 | ? [ |
| 2173 | createActionButton("copy", "", () => copyToClipboard(userText)), |
| 2174 | createActionButton("speak", "", () => ttsService.speak(userText)), |
| 2175 | ].filter(Boolean) |
| 2176 | : []; |
| 2177 | setupCollapsible( |
| 2178 | messageDiv, |
| 2179 | ":scope > .step-action-buttons", |
| 2180 | false, |
| 2181 | userActionButtons, |
| 2182 | ":scope > .message-text", |
| 2183 | ); |
| 2184 | |
| 2185 | return { element: messageContainer }; |
| 2186 | } |
| 2187 | |
| 2188 | /** |
| 2189 | * @param {MessageHandlerArgs & Record<string, any>} param0 |
| 2190 | * @returns {Promise<MessageHandlerResult>} |
| 2191 | */ |
| 2192 | export async function drawMessageTool({ |
| 2193 | id, |
| 2194 | type, |
| 2195 | heading, |
| 2196 | content, |
| 2197 | kvps, |
| 2198 | timestamp, |
| 2199 | agentno = 0, |
| 2200 | ...additional |
| 2201 | }) { |
| 2202 | const tool_name = kvps?._tool_name || ""; |
| 2203 | |
| 2204 | if (!tool_name) { |
| 2205 | return drawMessageToolSimple({ ...arguments[0] }); |
| 2206 | } else if (kvps._tool_name === "skills_tool") { |
| 2207 | const displayKvps = { ...(kvps || {}) }; |
| 2208 | delete displayKvps._tool_name; |
| 2209 | return drawMessageToolSimple({ ...arguments[0], code: "SKL", displayKvps }); |
| 2210 | } else if (kvps._tool_name === "vision_load") { |
| 2211 | return drawMessageToolSimple({ ...arguments[0], code: "EYE" }); |
| 2212 | } else if (kvps._tool_name === "search_engine") { |
| 2213 | return drawMessageToolSimple({ ...arguments[0], code: "WEB" }); |
| 2214 | } else if (kvps._tool_name.startsWith("memory_")) { |
| 2215 | return drawMessageToolSimple({ ...arguments[0], code: "MEM" }); |
| 2216 | } |
| 2217 | |
| 2218 | /** @type {{ tool_name: string, kvps: any, handler: Function | undefined }} */ |
| 2219 | const extData = { |
| 2220 | tool_name, |
| 2221 | kvps, |
| 2222 | handler: undefined, |
| 2223 | }; |
| 2224 | await callJsExtensions("get_tool_message_handler", extData); |
| 2225 | if (typeof extData.handler === "function") { |
| 2226 | return extData.handler(arguments[0]); |
| 2227 | } |
| 2228 | return drawMessageToolSimple({ ...arguments[0] }); |
| 2229 | } |
| 2230 | |
| 2231 | /** |
| 2232 | * @param {MessageHandlerArgs & Record<string, any>} param0 |
| 2233 | * @returns {MessageHandlerResult} |
| 2234 | */ |
| 2235 | export function drawMessageToolSimple({ |
| 2236 | id, |
| 2237 | type, |
| 2238 | heading, |
| 2239 | content, |
| 2240 | kvps, |
| 2241 | timestamp, |
| 2242 | agentno = 0, |
| 2243 | code, |
| 2244 | displayKvps, |
| 2245 | ...additional |
| 2246 | }) { |
| 2247 | const title = cleanStepTitle(heading); |
| 2248 | displayKvps = displayKvps || { ...kvps }; |
| 2249 | const headerLabels = [ |
| 2250 | kvps?._tool_name && { label: kvps._tool_name, class: "tool-name-badge" }, |
| 2251 | ].filter(Boolean); |
| 2252 | const contentText = String(content ?? ""); |
| 2253 | const actionButtons = contentText.trim() |
| 2254 | ? [ |
| 2255 | createActionButton("detail", "", () => |
| 2256 | stepDetailStore.showStepDetail( |
| 2257 | buildDetailPayload(arguments[0], { headerLabels }), |
| 2258 | ), |
| 2259 | ), |
| 2260 | createActionButton("copy", "", () => copyToClipboard(contentText)), |
| 2261 | createActionButton("speak", "", () => ttsService.speak(contentText)), |
| 2262 | ].filter(Boolean) |
| 2263 | : []; |
| 2264 | |
| 2265 | return drawProcessStep({ |
| 2266 | id, |
| 2267 | title, |
| 2268 | code: code || "USE", |
| 2269 | classes: undefined, |
| 2270 | kvps: displayKvps, |
| 2271 | content, |
| 2272 | // contentClasses: [], |
| 2273 | actionButtons, |
| 2274 | log: arguments[0], |
| 2275 | }); |
| 2276 | } |
| 2277 | |
| 2278 | /** |
| 2279 | * @param {MessageHandlerArgs & Record<string, any>} param0 |
| 2280 | * @returns {MessageHandlerResult} |
| 2281 | */ |
| 2282 | export function drawMessageMcp({ |
| 2283 | id, |
| 2284 | type, |
| 2285 | heading, |
| 2286 | content, |
| 2287 | kvps, |
| 2288 | timestamp, |
| 2289 | agentno = 0, |
| 2290 | ...additional |
| 2291 | }) { |
| 2292 | const title = cleanStepTitle(heading); |
| 2293 | let displayKvps = { ...kvps }; |
| 2294 | const headerLabels = [ |
| 2295 | kvps?.tool_name && { label: kvps.tool_name, class: "tool-name-badge" }, |
| 2296 | ].filter(Boolean); |
| 2297 | const contentText = String(content ?? ""); |
| 2298 | const actionButtons = contentText.trim() |
| 2299 | ? [ |
| 2300 | createActionButton("detail", "", () => |
| 2301 | stepDetailStore.showStepDetail( |
| 2302 | buildDetailPayload(arguments[0], { headerLabels }), |
| 2303 | ), |
| 2304 | ), |
| 2305 | createActionButton("copy", "", () => copyToClipboard(contentText)), |
| 2306 | createActionButton("speak", "", () => ttsService.speak(contentText)), |
| 2307 | ].filter(Boolean) |
| 2308 | : []; |
| 2309 | |
| 2310 | return drawProcessStep({ |
| 2311 | id, |
| 2312 | title, |
| 2313 | code: "MCP", |
| 2314 | classes: undefined, |
| 2315 | kvps: displayKvps, |
| 2316 | content, |
| 2317 | // contentClasses: [], |
| 2318 | actionButtons, |
| 2319 | log: arguments[0], |
| 2320 | }); |
| 2321 | } |
| 2322 | |
| 2323 | /** |
| 2324 | * @param {MessageHandlerArgs & Record<string, any>} param0 |
| 2325 | * @returns {MessageHandlerResult} |
| 2326 | */ |
| 2327 | export function drawMessageSubagent({ |
| 2328 | id, |
| 2329 | type, |
| 2330 | heading, |
| 2331 | content, |
| 2332 | kvps, |
| 2333 | timestamp, |
| 2334 | agentno = 0, |
| 2335 | ...additional |
| 2336 | }) { |
| 2337 | const title = cleanStepTitle(heading); |
| 2338 | let displayKvps = { ...kvps }; |
| 2339 | const headerLabels = [ |
| 2340 | kvps?.tool_name && { label: kvps.tool_name, class: "tool-name-badge" }, |
| 2341 | ].filter(Boolean); |
| 2342 | const contentText = String(content ?? ""); |
| 2343 | const actionButtons = contentText.trim() |
| 2344 | ? [ |
| 2345 | createActionButton("detail", "", () => |
| 2346 | stepDetailStore.showStepDetail( |
| 2347 | buildDetailPayload(arguments[0], { headerLabels }), |
| 2348 | ), |
| 2349 | ), |
| 2350 | createActionButton("copy", "", () => copyToClipboard(contentText)), |
| 2351 | createActionButton("speak", "", () => ttsService.speak(contentText)), |
| 2352 | ].filter(Boolean) |
| 2353 | : []; |
| 2354 | |
| 2355 | return drawProcessStep({ |
| 2356 | id, |
| 2357 | title, |
| 2358 | code: "SUB", |
| 2359 | classes: undefined, |
| 2360 | kvps: displayKvps, |
| 2361 | content, |
| 2362 | // contentClasses: [], |
| 2363 | actionButtons, |
| 2364 | log: arguments[0], |
| 2365 | }); |
| 2366 | } |
| 2367 | |
| 2368 | /** |
| 2369 | * @param {MessageHandlerArgs & Record<string, any>} param0 |
| 2370 | * @returns {MessageHandlerResult} |
| 2371 | */ |
| 2372 | export function drawMessageInfo({ |
| 2373 | id, |
| 2374 | heading, |
| 2375 | content, |
| 2376 | kvps, |
| 2377 | ...additional |
| 2378 | }) { |
| 2379 | const title = cleanStepTitle(heading || content); |
| 2380 | let displayKvps = { ...kvps }; |
| 2381 | delete displayKvps.finished; |
| 2382 | const contentText = String(content ?? ""); |
| 2383 | const actionButtons = contentText.trim() |
| 2384 | ? [ |
| 2385 | createActionButton("copy", "", () => copyToClipboard(contentText)), |
| 2386 | createActionButton("speak", "", () => ttsService.speak(contentText)), |
| 2387 | ].filter(Boolean) |
| 2388 | : []; |
| 2389 | |
| 2390 | const result = drawProcessStep({ |
| 2391 | id, |
| 2392 | title, |
| 2393 | code: "INF", |
| 2394 | classes: undefined, |
| 2395 | kvps: displayKvps, |
| 2396 | content, |
| 2397 | // contentClasses: [], |
| 2398 | actionButtons, |
| 2399 | log: arguments[0], |
| 2400 | }); |
| 2401 | |
| 2402 | if (kvps?.finished) completeLastProcessGroup(); |
| 2403 | return result; |
| 2404 | } |
| 2405 | |
| 2406 | /** |
| 2407 | * @param {MessageHandlerArgs & Record<string, any>} param0 |
| 2408 | * @returns {MessageHandlerResult} |
| 2409 | */ |
| 2410 | export function drawMessageUtil({ |
| 2411 | id, |
| 2412 | type, |
| 2413 | heading, |
| 2414 | content, |
| 2415 | kvps, |
| 2416 | timestamp, |
| 2417 | agentno = 0, |
| 2418 | ...additional |
| 2419 | }) { |
| 2420 | const title = cleanStepTitle(heading || content); |
| 2421 | const contentText = String(content ?? ""); |
| 2422 | const actionButtons = contentText.trim() |
| 2423 | ? [ |
| 2424 | createActionButton("copy", "", () => copyToClipboard(contentText)), |
| 2425 | createActionButton("speak", "", () => ttsService.speak(contentText)), |
| 2426 | ].filter(Boolean) |
| 2427 | : []; |
| 2428 | |
| 2429 | const result = drawProcessStep({ |
| 2430 | id, |
| 2431 | title, |
| 2432 | code: "UTL", |
| 2433 | classes: ["message-util"], |
| 2434 | kvps, |
| 2435 | content, |
| 2436 | actionButtons, |
| 2437 | log: arguments[0], |
| 2438 | allowCompletedGroup: false, |
| 2439 | }); |
| 2440 | |
| 2441 | result.dontScroll = !preferencesStore.showUtils; |
| 2442 | return result; |
| 2443 | } |
| 2444 | |
| 2445 | /** |
| 2446 | * @param {MessageHandlerArgs & Record<string, any>} param0 |
| 2447 | * @returns {MessageHandlerResult} |
| 2448 | */ |
| 2449 | export function drawMessageHint({ |
| 2450 | id, |
| 2451 | type, |
| 2452 | heading, |
| 2453 | content, |
| 2454 | kvps, |
| 2455 | timestamp, |
| 2456 | agentno = 0, |
| 2457 | ...additional |
| 2458 | }) { |
| 2459 | const title = getStepTitle(heading, content, type); |
| 2460 | const contentText = String(content ?? ""); |
| 2461 | const actionButtons = contentText.trim() |
| 2462 | ? [ |
| 2463 | createActionButton("copy", "", () => copyToClipboard(contentText)), |
| 2464 | createActionButton("speak", "", () => ttsService.speak(contentText)), |
| 2465 | ].filter(Boolean) |
| 2466 | : []; |
| 2467 | |
| 2468 | const element = drawStandaloneMessage({ |
| 2469 | id, |
| 2470 | heading: title, |
| 2471 | // statusClass, |
| 2472 | // statusCode: "HNT", |
| 2473 | kvps, |
| 2474 | // type, |
| 2475 | content, |
| 2476 | // timestamp, |
| 2477 | // agentno, |
| 2478 | actionButtons, |
| 2479 | }); |
| 2480 | |
| 2481 | return { element }; |
| 2482 | } |
| 2483 | |
| 2484 | /** |
| 2485 | * @param {MessageHandlerArgs & Record<string, any>} param0 |
| 2486 | * @returns {MessageHandlerResult} |
| 2487 | */ |
| 2488 | export function drawMessageProgress({ |
| 2489 | id, |
| 2490 | type, |
| 2491 | heading, |
| 2492 | content, |
| 2493 | kvps, |
| 2494 | timestamp, |
| 2495 | agentno = 0, |
| 2496 | ...additional |
| 2497 | }) { |
| 2498 | const title = cleanStepTitle(heading || content); |
| 2499 | let displayKvps = { ...kvps }; |
| 2500 | |
| 2501 | return drawProcessStep({ |
| 2502 | id, |
| 2503 | title, |
| 2504 | code: "HDL", |
| 2505 | classes: undefined, |
| 2506 | kvps: displayKvps, |
| 2507 | content, |
| 2508 | // contentClasses: [], |
| 2509 | actionButtons: [], |
| 2510 | log: arguments[0], |
| 2511 | }); |
| 2512 | } |
| 2513 | |
| 2514 | /** |
| 2515 | * @param {MessageHandlerArgs & Record<string, any>} param0 |
| 2516 | * @returns {MessageHandlerResult} |
| 2517 | */ |
| 2518 | export function drawMessageWarning({ |
| 2519 | id, |
| 2520 | type, |
| 2521 | heading, |
| 2522 | content, |
| 2523 | kvps = null, |
| 2524 | ...additional |
| 2525 | }) { |
| 2526 | const title = getStepTitle(heading, content, type); |
| 2527 | let displayKvps = { ...kvps }; |
| 2528 | const contentText = String(content ?? ""); |
| 2529 | const actionButtons = contentText.trim() |
| 2530 | ? [ |
| 2531 | createActionButton("copy", "", () => copyToClipboard(contentText)), |
| 2532 | createActionButton("speak", "", () => ttsService.speak(contentText)), |
| 2533 | ].filter(Boolean) |
| 2534 | : []; |
| 2535 | |
| 2536 | // Keep replayed warnings in their classified process group. |
| 2537 | if ( |
| 2538 | arguments[0][PROCESS_GROUP_RENDER_INFO] || |
| 2539 | getLastProcessGroup(false) |
| 2540 | ) { |
| 2541 | return drawProcessStep({ |
| 2542 | id, |
| 2543 | title, |
| 2544 | code: "WRN", |
| 2545 | // classes: null, |
| 2546 | kvps: displayKvps, |
| 2547 | content, |
| 2548 | // contentClasses: [], |
| 2549 | actionButtons, |
| 2550 | log: arguments[0], |
| 2551 | }); |
| 2552 | } |
| 2553 | |
| 2554 | // if no process group is running, draw as standalone |
| 2555 | const element = drawStandaloneMessage({ |
| 2556 | id, |
| 2557 | heading: title, |
| 2558 | content, |
| 2559 | position: "mid", |
| 2560 | containerClasses: ["ai-container", "center-container"], |
| 2561 | mainClass: "message-warning", |
| 2562 | kvps: displayKvps, |
| 2563 | actionButtons, |
| 2564 | }); |
| 2565 | |
| 2566 | return { element }; |
| 2567 | } |
| 2568 | |
| 2569 | /** |
| 2570 | * @param {MessageHandlerArgs & Record<string, any>} param0 |
| 2571 | * @returns {MessageHandlerResult} |
| 2572 | */ |
| 2573 | export function drawMessageError({ |
| 2574 | id, |
| 2575 | type, |
| 2576 | heading, |
| 2577 | content, |
| 2578 | kvps = null, |
| 2579 | ...additional |
| 2580 | }) { |
| 2581 | const contentText = String(content ?? ""); |
| 2582 | let title = getStepTitle(heading, content, type); |
| 2583 | let displayKvps = { ...kvps }; |
| 2584 | |
| 2585 | const actionButtons = []; |
| 2586 | actionButtons.push( |
| 2587 | createActionButton("detail", "", () => |
| 2588 | stepDetailStore.showStepDetail( |
| 2589 | buildDetailPayload(arguments[0], { headerLabels: [] }), |
| 2590 | ), |
| 2591 | ), |
| 2592 | ); |
| 2593 | if (contentText.trim()) { |
| 2594 | actionButtons.push( |
| 2595 | createActionButton("copy", "", () => copyToClipboard(contentText)), |
| 2596 | ); |
| 2597 | } |
| 2598 | |
| 2599 | const element = drawStandaloneMessage({ |
| 2600 | id, |
| 2601 | heading: title, |
| 2602 | content: contentText, |
| 2603 | position: "mid", |
| 2604 | containerClasses: ["ai-container", "center-container"], |
| 2605 | mainClass: "message-error", |
| 2606 | kvps: displayKvps, |
| 2607 | actionButtons, |
| 2608 | }); |
| 2609 | |
| 2610 | return { element }; |
| 2611 | } |
| 2612 | |
| 2613 | function drawKvpsIncremental(container, kvps) { |
| 2614 | // existing KVPS table |
| 2615 | let table = container.querySelector(".msg-kvps"); |
| 2616 | if (kvps) { |
| 2617 | // create table if not found |
| 2618 | if (!table) { |
| 2619 | table = document.createElement("table"); |
| 2620 | table.classList.add("msg-kvps"); |
| 2621 | container.insertBefore(table, container.firstChild); |
| 2622 | } |
| 2623 | |
| 2624 | // Get all current rows for comparison |
| 2625 | let existingRows = table.querySelectorAll(".kvps-row"); |
| 2626 | // Filter out reasoning |
| 2627 | const kvpEntries = Object.entries(kvps).filter( |
| 2628 | ([key]) => key !== "reasoning", |
| 2629 | ); |
| 2630 | |
| 2631 | // Update or create rows as needed |
| 2632 | kvpEntries.forEach(([key, value], index) => { |
| 2633 | let row = existingRows[index]; |
| 2634 | |
| 2635 | if (!row) { |
| 2636 | // Create new row if it doesn't exist |
| 2637 | row = table.insertRow(); |
| 2638 | row.classList.add("kvps-row"); |
| 2639 | } |
| 2640 | |
| 2641 | // Update row classes |
| 2642 | row.className = "kvps-row"; |
| 2643 | |
| 2644 | // Handle key cell |
| 2645 | let th = row.querySelector(".kvps-key"); |
| 2646 | if (!th) { |
| 2647 | th = row.insertCell(0); |
| 2648 | th.classList.add("kvps-key"); |
| 2649 | } |
| 2650 | const convertedKey = convertIcons(String(key), ""); |
| 2651 | if (convertedKey !== String(key)) { |
| 2652 | th.innerHTML = convertedKey; |
| 2653 | } else { |
| 2654 | th.textContent = convertToTitleCase(key); |
| 2655 | } |
| 2656 | |
| 2657 | // Handle value cell |
| 2658 | let td = row.cells[1]; |
| 2659 | if (!td) { |
| 2660 | td = row.insertCell(1); |
| 2661 | td.classList.add("kvps-val"); |
| 2662 | } |
| 2663 | |
| 2664 | // reapply scroll position or autoscroll |
| 2665 | // no inner scrolling for kvps anymore |
| 2666 | // const scroller = new Scroller(td); |
| 2667 | |
| 2668 | // Clear and rebuild content (for now - could be optimized further) |
| 2669 | td.innerHTML = ""; |
| 2670 | |
| 2671 | if (Array.isArray(value)) { |
| 2672 | for (const item of value) { |
| 2673 | addValue(item, td); |
| 2674 | } |
| 2675 | } else { |
| 2676 | addValue(value, td); |
| 2677 | } |
| 2678 | |
| 2679 | // reapply scroll position or autoscroll |
| 2680 | // scroller.reApplyScroll(); |
| 2681 | }); |
| 2682 | |
| 2683 | // Remove extra rows if we have fewer kvps now |
| 2684 | while (existingRows.length > kvpEntries.length) { |
| 2685 | const lastRow = existingRows[existingRows.length - 1]; |
| 2686 | lastRow.remove(); |
| 2687 | existingRows = table.querySelectorAll(".kvps-row"); |
| 2688 | } |
| 2689 | |
| 2690 | function addValue(value, tdiv) { |
| 2691 | if (typeof value === "object") value = JSON.stringify(value, null, 2); |
| 2692 | |
| 2693 | if (typeof value === "string" && value.startsWith("img://")) { |
| 2694 | const imgElement = document.createElement("img"); |
| 2695 | imgElement.classList.add("kvps-img"); |
| 2696 | imgElement.src = value.replace("img://", "/api/image_get?path="); |
| 2697 | imgElement.alt = "Image Attachment"; |
| 2698 | tdiv.appendChild(imgElement); |
| 2699 | |
| 2700 | // Add click handler and cursor change |
| 2701 | imgElement.style.cursor = "pointer"; |
| 2702 | imgElement.addEventListener("click", () => { |
| 2703 | imageViewerStore.open(imgElement.src, { refreshInterval: 1000 }); |
| 2704 | }); |
| 2705 | } else { |
| 2706 | const span = document.createElement("p"); |
| 2707 | span.innerHTML = convertHTML(value); |
| 2708 | tdiv.appendChild(span); |
| 2709 | } |
| 2710 | } |
| 2711 | } else { |
| 2712 | // Remove table if kvps is null/empty |
| 2713 | if (table) table.remove(); |
| 2714 | return null; |
| 2715 | } |
| 2716 | return table; |
| 2717 | } |
| 2718 | |
| 2719 | function convertToTitleCase(str) { |
| 2720 | return str |
| 2721 | .replace(/_/g, " ") // Replace underscores with spaces |
| 2722 | .toLowerCase() // Convert the entire string to lowercase |
| 2723 | .replace(/\b\w/g, function (match) { |
| 2724 | return match.toUpperCase(); // Capitalize the first letter of each word |
| 2725 | }); |
| 2726 | } |
| 2727 | |
| 2728 | function convertImageTags(content) { |
| 2729 | // Regular expression to match <image> tags and extract base64 content |
| 2730 | const imageTagRegex = /<image>(.*?)<\/image>/g; |
| 2731 | |
| 2732 | // Replace <image> tags with <img> tags with base64 source |
| 2733 | const updatedContent = content.replace( |
| 2734 | imageTagRegex, |
| 2735 | (match, base64Content) => { |
| 2736 | return `<img src="data:image/jpeg;base64,${base64Content}" alt="Image Attachment" style="max-width: 250px !important;"/>`; |
| 2737 | }, |
| 2738 | ); |
| 2739 | |
| 2740 | return updatedContent; |
| 2741 | } |
| 2742 | |
| 2743 | function convertHTML(str) { |
| 2744 | if (typeof str !== "string") str = JSON.stringify(str, null, 2); |
| 2745 | |
| 2746 | let result = escapeHTML(str); |
| 2747 | result = convertImageTags(result); |
| 2748 | result = convertPathsToLinks(result); |
| 2749 | return result; |
| 2750 | } |
| 2751 | |
| 2752 | function convertLatexDelimiters(content) { |
| 2753 | return content.replace( |
| 2754 | /(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`)|\\\[([\s\S]*?)\\\]|\\\(([\s\S]*?)\\\)|\$\$([\s\S]*?)\$\$/g, |
| 2755 | (match, code, display, inline, dollars) => { |
| 2756 | if (code) return code; |
| 2757 | const tex = display ?? inline ?? dollars; |
| 2758 | const displayAttribute = |
| 2759 | display !== undefined || dollars !== undefined |
| 2760 | ? ' data-display="true"' |
| 2761 | : ""; |
| 2762 | const encodedTex = Array.from( |
| 2763 | tex.trim(), |
| 2764 | (char) => `&#${char.codePointAt(0)};`, |
| 2765 | ).join(""); |
| 2766 | return `<latex${displayAttribute}>${encodedTex}</latex>`; |
| 2767 | }, |
| 2768 | ); |
| 2769 | } |
| 2770 | |
| 2771 | function renderLatexElements(container) { |
| 2772 | container.querySelectorAll("latex").forEach((element) => { |
| 2773 | globalThis.katex.render(element.textContent, element, { |
| 2774 | displayMode: element.dataset.display === "true", |
| 2775 | throwOnError: false, |
| 2776 | }); |
| 2777 | }); |
| 2778 | } |
| 2779 | |
| 2780 | function renderLatexText(container) { |
| 2781 | globalThis.renderMathInElement(container, { |
| 2782 | throwOnError: false, |
| 2783 | errorCallback: () => {}, |
| 2784 | }); |
| 2785 | } |
| 2786 | |
| 2787 | function convertImgFilePaths(str) { |
| 2788 | return str.replace(/img:\/\//g, "/api/image_get?path="); |
| 2789 | } |
| 2790 | |
| 2791 | function convertFilePaths(str) { |
| 2792 | return str.replace(/file:\/\//g, "/api/download_work_dir_file?path="); |
| 2793 | } |
| 2794 | |
| 2795 | function escapeHTML(str) { |
| 2796 | const escapeChars = { |
| 2797 | "&": "&", |
| 2798 | "<": "<", |
| 2799 | ">": ">", |
| 2800 | "'": "'", |
| 2801 | '"': """, |
| 2802 | }; |
| 2803 | return str.replace(/[&<>'"]/g, (char) => escapeChars[char]); |
| 2804 | } |
| 2805 | |
| 2806 | function convertPathsToLinks(str) { |
| 2807 | function generateLinks(match) { |
| 2808 | const parts = match.split("/"); |
| 2809 | if (!parts[0]) parts.shift(); // drop empty element left of first " |
| 2810 | let conc = ""; |
| 2811 | let html = ""; |
| 2812 | for (const part of parts) { |
| 2813 | conc += "/" + part; |
| 2814 | html += `/<a href="#" class="path-link" data-path="${conc}" onclick="event.preventDefault(); openFileLink(this.dataset.path);">${part}</a>`; |
| 2815 | } |
| 2816 | return html; |
| 2817 | } |
| 2818 | |
| 2819 | const prefix = `(?:^|[> \`'"\\n]|'|")`; |
| 2820 | const pathPart = `[a-zA-Z0-9_.~@%+=,()\\-]+(?: [a-zA-Z0-9_.~@%+=,()\\-]+)*`; |
| 2821 | const spacedFilePath = `\\/(?:${pathPart}\\/)*${pathPart}\\.[a-zA-Z0-9]{1,12}`; |
| 2822 | const folder = `[a-zA-Z0-9_\\/.\\-]`; |
| 2823 | const file = `[a-zA-Z0-9_\\-\\/]`; |
| 2824 | const simplePath = `\\/${folder}*${file}(?<!\\.)`; |
| 2825 | const suffix = `(?=$|[\\s.,;:!?\\)\\]\\}]|'|")`; |
| 2826 | const pathRegex = new RegExp( |
| 2827 | `(?<=${prefix})(?:${spacedFilePath}|${simplePath})${suffix}`, |
| 2828 | "g", |
| 2829 | ); |
| 2830 | |
| 2831 | // skip paths inside html tags, like <img src="/path/to/image"> |
| 2832 | const tagRegex = /(<(?:[^<>"']+|"[^"]*"|'[^']*')*>)/g; |
| 2833 | |
| 2834 | return str |
| 2835 | .split(tagRegex) // keep tags & text separate |
| 2836 | .map((chunk) => { |
| 2837 | // if it *starts* with '<', it's a tag -> leave untouched |
| 2838 | if (chunk.startsWith("<")) return chunk; |
| 2839 | // otherwise run your link-generation |
| 2840 | return chunk.replace(pathRegex, generateLinks); |
| 2841 | }) |
| 2842 | .join(""); |
| 2843 | } |
| 2844 | |
| 2845 | // markdown render helpers // |
| 2846 | |
| 2847 | // wraps an element with a container div |
| 2848 | const wrapElement = (el, className) => { |
| 2849 | const wrapper = document.createElement("div"); |
| 2850 | wrapper.className = className; |
| 2851 | el.parentNode.insertBefore(wrapper, el); |
| 2852 | wrapper.appendChild(el); |
| 2853 | return wrapper; |
| 2854 | }; |
| 2855 | |
| 2856 | // data extractors |
| 2857 | const extractTableTSV = (table) => |
| 2858 | [...table.rows] |
| 2859 | .map((row) => |
| 2860 | [...row.cells] |
| 2861 | .map((cell) => |
| 2862 | cell.textContent.replace(/\t/g, " ").replace(/\n/g, " "), |
| 2863 | ) |
| 2864 | .join("\t"), |
| 2865 | ) |
| 2866 | .join("\n"); |
| 2867 | |
| 2868 | function adjustMarkdownRender(element) { |
| 2869 | // find all tables in the element |
| 2870 | const tables = element.querySelectorAll("table"); |
| 2871 | tables.forEach((el) => { |
| 2872 | const wrapper = wrapElement(el, "message-markdown-table-wrap"); |
| 2873 | const outerWrapper = wrapElement(wrapper, "markdown-block-wrap"); |
| 2874 | const actionsDiv = document.createElement("div"); |
| 2875 | actionsDiv.className = "step-action-buttons"; |
| 2876 | actionsDiv.appendChild( |
| 2877 | createActionButton("copy", "", () => |
| 2878 | copyToClipboard(extractTableTSV(el)), |
| 2879 | ), |
| 2880 | ); |
| 2881 | outerWrapper.appendChild(actionsDiv); |
| 2882 | }); |
| 2883 | |
| 2884 | // find all code blocks |
| 2885 | const codeElements = element.querySelectorAll("pre > code"); |
| 2886 | codeElements.forEach((code) => { |
| 2887 | const pre = code.parentNode; |
| 2888 | const wrapper = wrapElement(pre, "code-block-wrapper"); |
| 2889 | const outerWrapper = wrapElement(wrapper, "markdown-block-wrap"); |
| 2890 | const actionsDiv = document.createElement("div"); |
| 2891 | actionsDiv.className = "step-action-buttons"; |
| 2892 | actionsDiv.appendChild( |
| 2893 | createActionButton("copy", "", () => copyToClipboard(code.textContent)), |
| 2894 | ); |
| 2895 | outerWrapper.appendChild(actionsDiv); |
| 2896 | }); |
| 2897 | |
| 2898 | // find all images |
| 2899 | const images = element.querySelectorAll("img"); |
| 2900 | |
| 2901 | // wrap each image in <a> |
| 2902 | images.forEach((img) => { |
| 2903 | if (img.parentNode?.tagName === "A") return; |
| 2904 | const link = document.createElement("a"); |
| 2905 | link.className = "message-markdown-image-wrap"; |
| 2906 | link.href = img.src; |
| 2907 | img.parentNode.insertBefore(link, img); |
| 2908 | link.appendChild(img); |
| 2909 | link.onclick = (e) => ( |
| 2910 | e.preventDefault(), |
| 2911 | imageViewerStore.open(img.src, { name: img.alt || "Image" }) |
| 2912 | ); |
| 2913 | }); |
| 2914 | } |
| 2915 | |
| 2916 | /** |
| 2917 | * Create a new collapsible process group |
| 2918 | */ |
| 2919 | function createProcessGroup(id) { |
| 2920 | const groupId = `process-group-${id}`; |
| 2921 | const group = document.createElement("div"); |
| 2922 | group.id = groupId; |
| 2923 | group.classList.add("process-group"); |
| 2924 | group.setAttribute("data-group-id", groupId); |
| 2925 | |
| 2926 | // Determine initial expansion state from current detail mode |
| 2927 | const initiallyExpanded = preferencesStore.detailMode !== "collapsed"; |
| 2928 | if (initiallyExpanded) { |
| 2929 | group.classList.add("expanded"); |
| 2930 | } |
| 2931 | |
| 2932 | // Create header |
| 2933 | const header = document.createElement("div"); |
| 2934 | header.classList.add("process-group-header"); |
| 2935 | header.innerHTML = ` |
| 2936 | <span class="expand-icon"></span> |
| 2937 | <span class="group-title">Processing...</span> |
| 2938 | <span class="step-badge GEN">GEN</span> |
| 2939 | <span class="group-metrics"> |
| 2940 | <span class="metric-time" title="Start time"><x-icon name="schedule"></x-icon><span class="metric-value">--:--</span></span> |
| 2941 | <span class="metric-steps display-none" title="Steps"><x-icon name="footprint"></x-icon><span class="metric-value">0</span></span> |
| 2942 | <span class="metric-notifications" title="Warnings/Info/Hint" hidden><x-icon name="priority_high"></x-icon><span class="metric-value">0</span></span> |
| 2943 | <span class="metric-duration display-none" title="Duration"><x-icon name="timer"></x-icon><span class="metric-value">--</span></span> |
| 2944 | |
| 2945 | </span> |
| 2946 | `; |
| 2947 | |
| 2948 | group.__setExpanded = (expanded) => { |
| 2949 | const nextExpanded = Boolean(expanded); |
| 2950 | group.classList.toggle("expanded", nextExpanded); |
| 2951 | const steps = group.querySelectorAll(".process-step"); |
| 2952 | if (nextExpanded) { |
| 2953 | steps.forEach((step) => { |
| 2954 | if ( |
| 2955 | step.classList.contains("expanded") && |
| 2956 | !step.querySelector(".process-step-detail-scroll") |
| 2957 | ) { |
| 2958 | void materializeProcessStepDetail(step); |
| 2959 | } |
| 2960 | }); |
| 2961 | } else { |
| 2962 | steps.forEach((step) => |
| 2963 | discardProcessStepDetail(step, { force: true }), |
| 2964 | ); |
| 2965 | } |
| 2966 | }; |
| 2967 | |
| 2968 | // Add click handler for expansion |
| 2969 | header.addEventListener("click", () => { |
| 2970 | group.__setExpanded(!group.classList.contains("expanded")); |
| 2971 | }); |
| 2972 | |
| 2973 | group.appendChild(header); |
| 2974 | |
| 2975 | // Create content container |
| 2976 | const content = document.createElement("div"); |
| 2977 | content.classList.add("process-group-content"); |
| 2978 | |
| 2979 | // Create steps container |
| 2980 | const steps = document.createElement("div"); |
| 2981 | steps.classList.add("process-steps"); |
| 2982 | content.appendChild(steps); |
| 2983 | |
| 2984 | group.appendChild(content); |
| 2985 | |
| 2986 | return group; |
| 2987 | } |
| 2988 | |
| 2989 | /** |
| 2990 | * Create or get nested container within a parent step |
| 2991 | */ |
| 2992 | function getNestedContainer(parentStep) { |
| 2993 | let nestedContainer = parentStep.querySelector(".process-nested-container"); |
| 2994 | |
| 2995 | if (!nestedContainer) { |
| 2996 | // Create new container |
| 2997 | nestedContainer = document.createElement("div"); |
| 2998 | nestedContainer.classList.add("process-nested-container"); |
| 2999 | |
| 3000 | // Create inner wrapper for animation support |
| 3001 | const innerWrapper = document.createElement("div"); |
| 3002 | innerWrapper.classList.add("process-nested-inner"); |
| 3003 | nestedContainer.appendChild(innerWrapper); |
| 3004 | |
| 3005 | parentStep.appendChild(nestedContainer); |
| 3006 | parentStep.classList.add("has-nested-steps"); |
| 3007 | } |
| 3008 | |
| 3009 | // Return the inner wrapper for appending steps |
| 3010 | const innerWrapper = nestedContainer.querySelector(".process-nested-inner"); |
| 3011 | return innerWrapper || nestedContainer; // Fallback to container if wrapper missing |
| 3012 | } |
| 3013 | |
| 3014 | /** |
| 3015 | * Schedule a step to collapse after a delay |
| 3016 | * Automatically handles cancellation on click and reset on hover |
| 3017 | */ |
| 3018 | function scheduleStepCollapse( |
| 3019 | stepElement, |
| 3020 | delayMs = STEP_COLLAPSE_DELAY.other, |
| 3021 | ) { |
| 3022 | // skip if any existing timeout for this step |
| 3023 | if (stepElement.hasAttribute("data-collapse-timeout-id")) return; |
| 3024 | // skip already collapsed steps |
| 3025 | if (!stepElement.classList.contains("expanded")) return; |
| 3026 | |
| 3027 | // Schedule the collapse |
| 3028 | const timeoutId = setTimeout(() => { |
| 3029 | stepElement.removeAttribute("data-collapse-timeout-id"); |
| 3030 | |
| 3031 | if (stepElement.dataset.clicked === "true") { |
| 3032 | console.log(`Skip clicked collapse: ${stepElement.id}`); |
| 3033 | return; |
| 3034 | } |
| 3035 | |
| 3036 | if (stepElement.matches(":hover")) { |
| 3037 | console.log(`Delay hover collapse: ${stepElement.id}`); |
| 3038 | scheduleStepCollapse(stepElement, STEP_COLLAPSE_HOVER_DELAY_MS); |
| 3039 | return; |
| 3040 | } |
| 3041 | |
| 3042 | console.log(`Collapse step: ${stepElement.id}`); |
| 3043 | toggleStepCollapse(stepElement, false); |
| 3044 | }, delayMs); |
| 3045 | |
| 3046 | // Store the timeout ID |
| 3047 | stepElement.setAttribute("data-collapse-timeout-id", String(timeoutId)); |
| 3048 | } |
| 3049 | |
| 3050 | function setupProcessStepHandlers(stepElement, stepHeader) { |
| 3051 | if (!stepElement.hasAttribute("data-step-handlers")) { |
| 3052 | stepElement.setAttribute("data-step-handlers", "true"); |
| 3053 | |
| 3054 | stepElement.addEventListener( |
| 3055 | "click", |
| 3056 | function handler() { |
| 3057 | stepElement.dataset.clicked = "true"; |
| 3058 | console.log(`Step clicked: ${stepElement.id}`); |
| 3059 | }, |
| 3060 | { once: true }, |
| 3061 | ); |
| 3062 | } |
| 3063 | |
| 3064 | if (stepHeader && !stepHeader.hasAttribute("data-expand-handler")) { |
| 3065 | stepHeader.setAttribute("data-expand-handler", "true"); |
| 3066 | stepHeader.addEventListener("click", (e) => { |
| 3067 | e.stopPropagation(); |
| 3068 | cancelStepCollapse(stepElement); |
| 3069 | stepElement.dataset.clicked = "true"; |
| 3070 | toggleStepCollapse(stepElement); |
| 3071 | }); |
| 3072 | } |
| 3073 | } |
| 3074 | |
| 3075 | /** |
| 3076 | * Cancel a scheduled collapse for a step |
| 3077 | */ |
| 3078 | function cancelStepCollapse(stepElement) { |
| 3079 | const timeoutIdStr = stepElement.getAttribute("data-collapse-timeout-id"); |
| 3080 | if (!timeoutIdStr) return; |
| 3081 | const timeoutId = Number(timeoutIdStr); |
| 3082 | if (!Number.isNaN(timeoutId)) clearTimeout(timeoutId); |
| 3083 | stepElement.removeAttribute("data-collapse-timeout-id"); |
| 3084 | } |
| 3085 | |
| 3086 | /** |
| 3087 | * Find parent delegation step for nested agents (DOM-first, reverse scan). |
| 3088 | */ |
| 3089 | function findParentDelegationStep(group, agentno) { |
| 3090 | if (!group || !agentno || agentno <= 0) return null; |
| 3091 | const steps = group.querySelectorAll(".process-step"); |
| 3092 | for (let i = steps.length - 1; i >= 0; i -= 1) { |
| 3093 | const step = steps[i]; |
| 3094 | const stepAgent = Number(step.getAttribute("data-agent-number")); |
| 3095 | if ( |
| 3096 | stepAgent === agentno - 1 && |
| 3097 | step.getAttribute("data-log-type") === "subagent" // map to the last tool call of superior agent |
| 3098 | ) { |
| 3099 | return step; |
| 3100 | } |
| 3101 | } |
| 3102 | return null; |
| 3103 | } |
| 3104 | |
| 3105 | /** |
| 3106 | * Get a concise title for a process step |
| 3107 | */ |
| 3108 | function getStepTitle(heading, content, type) { |
| 3109 | // Try to get a meaningful title from heading or kvps |
| 3110 | if (heading && heading.trim()) { |
| 3111 | return cleanStepTitle(heading, 60); |
| 3112 | } |
| 3113 | |
| 3114 | if (content && content.trim()) { |
| 3115 | return cleanStepTitle(content, 60); |
| 3116 | } |
| 3117 | |
| 3118 | // Fallback: capitalize type (backend is source of truth) |
| 3119 | return type |
| 3120 | ? type.charAt(0).toUpperCase() + type.slice(1).replace(/_/g, " ") |
| 3121 | : "Process"; |
| 3122 | } |
| 3123 | |
| 3124 | /** |
| 3125 | * Convert icon://name[Optional Tooltip] into a material icon span. |
| 3126 | * Tooltip supports escaped brackets inside, e.g. [Tooltip of \[brackets\]]. |
| 3127 | */ |
| 3128 | export function convertIcons(html, classes = "") { |
| 3129 | if (html == null) return ""; |
| 3130 | |
| 3131 | return String(html).replace( |
| 3132 | /icon:\/\/([a-zA-Z0-9_]+)(\[(?:\\.|[^\]])*\])?/g, |
| 3133 | (match, iconName, tooltipBlock) => { |
| 3134 | if (!tooltipBlock) { |
| 3135 | return `<x-icon class="icon ${classes}" name="${iconName}"></x-icon>`; |
| 3136 | } |
| 3137 | |
| 3138 | const tooltipRaw = tooltipBlock |
| 3139 | .slice(1, -1) |
| 3140 | .replace(/\\\[/g, "[") |
| 3141 | .replace(/\\\]/g, "]") |
| 3142 | .replace(/\\\\/g, "\\"); |
| 3143 | |
| 3144 | const tooltip = escapeHTML(tooltipRaw); |
| 3145 | |
| 3146 | return `<x-icon class="icon ${classes}" title="${tooltip}" data-bs-placement="top" data-bs-trigger="hover" name="${iconName}"></x-icon>`; |
| 3147 | }, |
| 3148 | ); |
| 3149 | } |
| 3150 | |
| 3151 | /** |
| 3152 | * Clean step title by removing icon:// prefixes and status phrases |
| 3153 | * Preserves agent markers (A1:, A2:, etc.) so users can see which subordinate agent is executing |
| 3154 | */ |
| 3155 | export function cleanStepTitle(text, maxLength = 100) { |
| 3156 | if (!text) return ""; |
| 3157 | let cleaned = String(text) |
| 3158 | .replace(/icon:\/\/[a-zA-Z0-9_]+(\[(?:\\.|[^\]])*\])?\s*/g, "") |
| 3159 | .replace(/\s+/g, " ") |
| 3160 | .trim(); |
| 3161 | return truncateText(cleaned, maxLength); |
| 3162 | } |
| 3163 | |
| 3164 | /** |
| 3165 | * Update process group header with step count, status, and metrics |
| 3166 | */ |
| 3167 | function updateProcessGroupHeader(group) { |
| 3168 | const header = group.querySelector(".process-group-header"); |
| 3169 | const steps = group.querySelectorAll(".process-step"); |
| 3170 | const titleEl = header.querySelector(".group-title"); |
| 3171 | const badgeEl = header.querySelector(".step-badge"); |
| 3172 | const metricsEl = header.querySelector(".group-metrics"); |
| 3173 | const isCompleted = isProcessGroupComplete(group); |
| 3174 | const notificationsEl = metricsEl?.querySelector(".metric-notifications"); |
| 3175 | |
| 3176 | // Update group title with the latest agent step heading |
| 3177 | if (titleEl) { |
| 3178 | // Find the last "agent" type step |
| 3179 | const agentSteps = Array.from(steps).filter( |
| 3180 | (step) => step.getAttribute("data-log-type") === "agent", |
| 3181 | ); |
| 3182 | if (agentSteps.length > 0) { |
| 3183 | const lastAgentStep = agentSteps[agentSteps.length - 1]; |
| 3184 | const lastHeading = |
| 3185 | lastAgentStep.querySelector(".step-title")?.textContent; |
| 3186 | if (lastHeading) { |
| 3187 | const cleanTitle = cleanStepTitle(lastHeading, 50); |
| 3188 | if (cleanTitle) { |
| 3189 | titleEl.textContent = cleanTitle; |
| 3190 | } |
| 3191 | } |
| 3192 | } |
| 3193 | } |
| 3194 | |
| 3195 | // If completed, set badge to END |
| 3196 | if (isCompleted) { |
| 3197 | // set end badge |
| 3198 | badgeEl.outerHTML = `<span class="step-badge END">END</span>`; |
| 3199 | // remove shine from any steps |
| 3200 | group.querySelectorAll(".step-title.shiny-text").forEach((el) => { |
| 3201 | el.classList.remove("shiny-text"); |
| 3202 | }); |
| 3203 | } else { |
| 3204 | // if not complete, clone the last step badge |
| 3205 | if (badgeEl && steps.length > 0) { |
| 3206 | const lastStep = steps[steps.length - 1]; |
| 3207 | const code = lastStep.getAttribute("data-step-code"); |
| 3208 | badgeEl.outerHTML = `<span class="step-badge ${code}">${code}</span>`; |
| 3209 | } |
| 3210 | } |
| 3211 | |
| 3212 | // Update step count in metrics - All GEN steps from all agents per process group |
| 3213 | const stepMetricContainerEl = metricsEl?.querySelector(".metric-steps"); |
| 3214 | const stepsMetricValEl = |
| 3215 | stepMetricContainerEl?.querySelector(".metric-value"); |
| 3216 | if (stepsMetricValEl) { |
| 3217 | let genSteps = Number(group.dataset.fullAgentSteps); |
| 3218 | if (!Number.isFinite(genSteps)) { |
| 3219 | genSteps = group.querySelectorAll( |
| 3220 | '.process-step[data-log-type="agent"]', |
| 3221 | ).length; |
| 3222 | genSteps -= 1; // don't count response as step |
| 3223 | } |
| 3224 | stepsMetricValEl.textContent = genSteps.toString(); |
| 3225 | if (genSteps <= 0) |
| 3226 | stepMetricContainerEl.classList.add("display-none"); // hide when no steps |
| 3227 | else stepMetricContainerEl.classList.remove("display-none"); |
| 3228 | } |
| 3229 | |
| 3230 | // Update time metric |
| 3231 | const timeMetricContainerEl = metricsEl?.querySelector(".metric-time"); |
| 3232 | const timeMetricEl = metricsEl?.querySelector(".metric-time .metric-value"); |
| 3233 | const startTimestamp = group.getAttribute("data-start-timestamp"); |
| 3234 | if (timeMetricEl && startTimestamp) { |
| 3235 | const date = new Date(parseFloat(startTimestamp) * 1000); |
| 3236 | const hour12 = getUserHour12(); |
| 3237 | timeMetricEl.textContent = new Intl.DateTimeFormat(undefined, { |
| 3238 | hour: hour12 ? "numeric" : "2-digit", |
| 3239 | minute: "2-digit", |
| 3240 | hour12, |
| 3241 | timeZone: getUserTimezone(), |
| 3242 | }).format(date); |
| 3243 | if (timeMetricContainerEl) { |
| 3244 | const fullDateTime = formatDateTime(date.toISOString(), "short"); |
| 3245 | timeMetricContainerEl.title = |
| 3246 | timeMetricContainerEl.dataset.bsOriginalTitle = fullDateTime; |
| 3247 | } |
| 3248 | } |
| 3249 | |
| 3250 | const firstTimestampMs = group.dataset.fullStartTimestamp |
| 3251 | ? Math.round(Number(group.dataset.fullStartTimestamp) * 1000) |
| 3252 | : parseInt(steps[0]?.getAttribute("data-timestamp") || "0", 10); |
| 3253 | const lastTimestampMs = group.dataset.fullEndTimestamp |
| 3254 | ? Math.round(Number(group.dataset.fullEndTimestamp) * 1000) |
| 3255 | : parseInt( |
| 3256 | steps[steps.length - 1]?.getAttribute("data-timestamp") || "0", |
| 3257 | 10, |
| 3258 | ); |
| 3259 | const durationText = |
| 3260 | isCompleted && |
| 3261 | metricsEl && |
| 3262 | steps.length > 0 && |
| 3263 | firstTimestampMs > 0 && |
| 3264 | lastTimestampMs > 0 && |
| 3265 | formatDuration(Math.max(0, lastTimestampMs - firstTimestampMs)); |
| 3266 | |
| 3267 | const durationMetricContainerEl = |
| 3268 | metricsEl?.querySelector(".metric-duration"); |
| 3269 | const durationMetricValEl = |
| 3270 | durationMetricContainerEl?.querySelector(".metric-value"); |
| 3271 | if (durationMetricContainerEl && durationMetricValEl && durationText) { |
| 3272 | durationMetricValEl.textContent = durationText; |
| 3273 | durationMetricContainerEl.classList.remove("display-none"); |
| 3274 | } else if (durationMetricContainerEl) { |
| 3275 | durationMetricContainerEl.classList.add("display-none"); |
| 3276 | } |
| 3277 | |
| 3278 | if (notificationsEl) { |
| 3279 | const fullWarningSteps = Number(group.dataset.fullWarningSteps); |
| 3280 | const fullInfoSteps = Number(group.dataset.fullInfoSteps); |
| 3281 | const counts = Number.isFinite(fullWarningSteps) && |
| 3282 | Number.isFinite(fullInfoSteps) |
| 3283 | ? { warning: fullWarningSteps, info: fullInfoSteps } |
| 3284 | : { warning: 0, info: 0 }; |
| 3285 | if (!Number.isFinite(fullWarningSteps) || !Number.isFinite(fullInfoSteps)) { |
| 3286 | steps.forEach((step) => { |
| 3287 | const stepType = step.getAttribute("data-log-type"); |
| 3288 | if (Object.prototype.hasOwnProperty.call(counts, stepType)) { |
| 3289 | counts[stepType] += 1; |
| 3290 | } |
| 3291 | }); |
| 3292 | } |
| 3293 | |
| 3294 | const totalNotifications = counts.warning + counts.info; |
| 3295 | const countEl = notificationsEl.querySelector(".metric-value"); |
| 3296 | notificationsEl.classList.remove("status-wrn", "status-inf"); |
| 3297 | |
| 3298 | if (totalNotifications > 0) { |
| 3299 | if (countEl) { |
| 3300 | countEl.textContent = totalNotifications.toString(); |
| 3301 | } |
| 3302 | if (counts.warning > 0) { |
| 3303 | notificationsEl.classList.add("status-wrn"); |
| 3304 | } else if (counts.info > 0) { |
| 3305 | notificationsEl.classList.add("status-inf"); |
| 3306 | } |
| 3307 | notificationsEl.hidden = false; |
| 3308 | notificationsEl.title = `Warnings: ${counts.warning}, Info: ${counts.info}`; |
| 3309 | } else { |
| 3310 | notificationsEl.hidden = true; |
| 3311 | } |
| 3312 | } |
| 3313 | } |
| 3314 | |
| 3315 | function isProcessGroupComplete(group) { |
| 3316 | // manually closed group |
| 3317 | if (group?.hasAttribute?.("data-group-complete")) return true; |
| 3318 | // naturally completed group |
| 3319 | const response = group.querySelector(".process-group-response"); |
| 3320 | return !!response; |
| 3321 | } |
| 3322 | |
| 3323 | // manually complete last process group |
| 3324 | export function completeLastProcessGroup() { |
| 3325 | const group = getLastProcessGroup(); |
| 3326 | if (!group || isProcessGroupComplete(group)) return; |
| 3327 | group.setAttribute("data-group-complete", "true"); |
| 3328 | updateProcessGroupHeader(group); |
| 3329 | } |
| 3330 | |
| 3331 | function getStepProcessGroup(step) { |
| 3332 | return step?.closest(".process-group"); |
| 3333 | } |
| 3334 | |
| 3335 | /** |
| 3336 | * Truncate text to a maximum length |
| 3337 | */ |
| 3338 | function truncateText(text, maxLength) { |
| 3339 | if (!text) return ""; |
| 3340 | text = String(text).trim(); |
| 3341 | if (text.length <= maxLength) return text; |
| 3342 | return text.substring(0, maxLength - 3) + "..."; |
| 3343 | } |
| 3344 | |
| 3345 | // gets or creates a child DOM element |
| 3346 | /** |
| 3347 | * @param {Element} parent |
| 3348 | * @param {string} selector |
| 3349 | * @param {string} tagName |
| 3350 | * @param {...string} classNames |
| 3351 | * @returns {HTMLElement} |
| 3352 | */ |
| 3353 | function ensureChild(parent, selector, tagName, ...classNames) { |
| 3354 | /** @type {HTMLElement | null} */ |
| 3355 | let el = /** @type {any} */ (parent.querySelector(selector)); |
| 3356 | if (!el) { |
| 3357 | el = document.createElement(tagName); |
| 3358 | if (classNames.length) el.classList.add(...classNames); |
| 3359 | parent.appendChild(el); |
| 3360 | } |
| 3361 | return el; |
| 3362 | } |
| 3363 | |
| 3364 | // Setup collapsible message with expand button and action buttons |
| 3365 | function setupCollapsible( |
| 3366 | messageDiv, |
| 3367 | containerSelector, |
| 3368 | initialExpanded, |
| 3369 | actionButtons = [], |
| 3370 | contentSelector = ":scope > .message-body", |
| 3371 | ) { |
| 3372 | messageDiv.classList.add("message-collapsible"); |
| 3373 | messageDiv |
| 3374 | .querySelectorAll(":scope > .message-collapse-content") |
| 3375 | .forEach((element) => element.classList.remove("message-collapse-content")); |
| 3376 | const collapseContent = messageDiv.querySelector(contentSelector); |
| 3377 | collapseContent?.classList.add("message-collapse-content"); |
| 3378 | const initialState = |
| 3379 | Boolean(initialExpanded) && !messageDiv.classList.contains("lazy-content"); |
| 3380 | messageDiv.classList.toggle("expanded", initialState); |
| 3381 | |
| 3382 | const container = ensureChild( |
| 3383 | messageDiv, |
| 3384 | containerSelector, |
| 3385 | "div", |
| 3386 | "step-action-buttons", |
| 3387 | ); |
| 3388 | const btn = ensureChild(container, ".expand-btn", "button", "expand-btn"); |
| 3389 | const syncBtn = () => { |
| 3390 | const exp = messageDiv.classList.contains("expanded"); |
| 3391 | btn.textContent = exp ? "Show less" : "Show more"; |
| 3392 | btn.classList.toggle("show-less-btn", exp); |
| 3393 | btn.classList.toggle("show-more-btn", !exp); |
| 3394 | }; |
| 3395 | const setExpanded = (expanded) => { |
| 3396 | const nextExpanded = Boolean(expanded); |
| 3397 | messageDiv.classList.toggle("expanded", nextExpanded); |
| 3398 | messageDiv.__renderLazyContent?.(nextExpanded); |
| 3399 | syncBtn(); |
| 3400 | if (!nextExpanded) { |
| 3401 | if (collapseContent) collapseContent.scrollTop = 0; |
| 3402 | } |
| 3403 | }; |
| 3404 | messageDiv.__setExpanded = setExpanded; |
| 3405 | setExpanded(initialState); |
| 3406 | btn.onclick = () => |
| 3407 | setExpanded(!messageDiv.classList.contains("expanded")); |
| 3408 | |
| 3409 | syncActionButtons(container, actionButtons); |
| 3410 | |
| 3411 | const refreshOverflow = () => { |
| 3412 | const hasOverflow = measureMessageCollapseOverflow(collapseContent, { |
| 3413 | expanded: messageDiv.classList.contains("expanded"), |
| 3414 | lazy: messageDiv.classList.contains("lazy-content"), |
| 3415 | }); |
| 3416 | if (hasOverflow === null) return false; |
| 3417 | messageDiv.classList.toggle("has-overflow", hasOverflow); |
| 3418 | return true; |
| 3419 | }; |
| 3420 | messageDiv.__refreshCollapseOverflow = refreshOverflow; |
| 3421 | |
| 3422 | // Detect overflow after render. Window replays are measured again after the |
| 3423 | // staged DOM has moved into the live, correctly sized chat history. |
| 3424 | requestAnimationFrame(() => { |
| 3425 | if (messageDiv.__refreshCollapseOverflow === refreshOverflow) { |
| 3426 | refreshOverflow(); |
| 3427 | } |
| 3428 | }); |
| 3429 | } |
| 3430 | |
| 3431 | function refreshCollapsibleMessageOverflow(root) { |
| 3432 | if (!root) return; |
| 3433 | const messages = root.matches?.(".message-collapsible") |
| 3434 | ? [root] |
| 3435 | : root.querySelectorAll?.(".message-collapsible") || []; |
| 3436 | messages.forEach((message) => { |
| 3437 | if (typeof message.__refreshCollapseOverflow === "function") { |
| 3438 | message.__refreshCollapseOverflow(); |
| 3439 | } |
| 3440 | }); |
| 3441 | } |
| 3442 | |
| 3443 | // returns true if this is the initial render of a chat eg. when reloading window, switching chat or catching up after a break |
| 3444 | // returns false when already in a rendered chat and adding messages regurarly |
| 3445 | function isMassRender() { |
| 3446 | return _massRender; |
| 3447 | } |
| 3448 | |
| 3449 | // smooth fade in animation for new chunks when streaming |
| 3450 | function smoothRender(element, newContent, delay = 350) { |
| 3451 | // skip on mass render |
| 3452 | if (isMassRender()) { |
| 3453 | element.innerHTML = newContent; |
| 3454 | return; |
| 3455 | } |
| 3456 | |
| 3457 | element.dataset.smoothPendingHtml = newContent; |
| 3458 | |
| 3459 | if (element.dataset.smoothTimeoutId) return; |
| 3460 | |
| 3461 | const timeoutId = window.setTimeout(() => { |
| 3462 | const pending = element.dataset.smoothPendingHtml || ""; |
| 3463 | delete element.dataset.smoothPendingHtml; |
| 3464 | delete element.dataset.smoothTimeoutId; |
| 3465 | |
| 3466 | const existing = element.querySelector( |
| 3467 | ":scope > div.smooth-render-visible", |
| 3468 | ); |
| 3469 | if (existing) { |
| 3470 | existing.classList.remove("smooth-render-visible"); |
| 3471 | existing.classList.add("smooth-render-invisible"); |
| 3472 | |
| 3473 | existing.addEventListener("animationend", () => existing.remove(), { |
| 3474 | once: true, |
| 3475 | }); |
| 3476 | } |
| 3477 | |
| 3478 | const nextLayer = document.createElement("div"); |
| 3479 | nextLayer.className = "smooth-render-visible"; |
| 3480 | nextLayer.innerHTML = pending; |
| 3481 | element.appendChild(nextLayer); |
| 3482 | |
| 3483 | // Keep container height stable while layers are absolute |
| 3484 | element.style.height = `${nextLayer.scrollHeight}px`; |
| 3485 | }, delay); |
| 3486 | |
| 3487 | element.dataset.smoothTimeoutId = String(timeoutId); |
| 3488 | } |