import { html, nothing } from "lit" ;
import { unsafeHTML } from "lit/directives/unsafe-html.js" ;
import { until } from "lit/directives/until.js" ;
import { getSafeLocalStorage } from "../../local-storage.ts" ;
import { DEFAULT_ASSISTANT_AVATAR, type AssistantIdentity } from "../assistant-identity.ts" ;
import type { EmbedSandboxMode } from "../embed-sandbox.ts" ;
import { icons } from "../icons.ts" ;
import { toSanitizedMarkdownHtml } from "../markdown.ts" ;
import { openExternalUrlSafe } from "../open-external-url.ts" ;
import type { SidebarContent } from "../sidebar-content.ts" ;
import { detectTextDirection } from "../text-direction.ts" ;
import type {
MessageContentItem,
MessageGroup,
NormalizedMessage,
ToolCard,
} from "../types/chat-types.ts" ;
import {
resolveLocalUserAvatarText,
resolveLocalUserAvatarUrl,
resolveLocalUserName,
} from "../user-identity.ts" ;
import { agentLogoUrl, isRenderableControlUiAvatarUrl } from "../views/agents-utils.ts" ;
import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts" ;
import {
extractTextCached,
extractThinkingCached,
formatReasoningMarkdown,
} from "./message-extract.ts" ;
import {
isToolResultMessage,
normalizeMessage,
normalizeRoleForGrouping,
} from "./message-normalizer.ts" ;
import { isTtsSupported, speakText, stopTts, isTtsSpeaking } from "./speech.ts" ;
import {
extractToolCards,
renderExpandedToolCardContent,
renderRawOutputToggle,
renderToolCard,
renderToolPreview,
} from "./tool-cards.ts" ;
type AssistantAttachmentAvailability =
| { status: "checking" }
| { status: "available" }
| { status: "unavailable" ; reason: string; checkedAt: number };
const assistantAttachmentAvailabilityCache = new Map<string, AssistantAttachmentAvailability>();
const ASSISTANT_ATTACHMENT_UNAVAILABLE_RETRY_MS = 5 _000 ;
export function resetAssistantAttachmentAvailabilityCacheForTest() {
assistantAttachmentAvailabilityCache.clear();
for (const blobUrl of managedImageBlobUrlResolvedCache.values()) {
URL.revokeObjectURL(blobUrl);
}
managedImageBlobUrlCache.clear();
managedImageBlobUrlResolvedCache.clear();
managedImageBlobUrlMissCache.clear();
}
type ImageBlock = {
url: string;
openUrl?: string;
alt?: string;
width?: number;
height?: number;
};
type ImageRenderOptions = {
localMediaPreviewRoots?: readonly string[];
basePath?: string;
authToken?: string | null ;
};
type RenderableImageBlock = ImageBlock & {
displayUrl: string;
};
const managedImageBlobUrlCache = new Map<string, Promise<string | null >>();
const managedImageBlobUrlResolvedCache = new Map<string, string>();
const managedImageBlobUrlMissCache = new Map<string, number>();
const MANAGED_IMAGE_BLOB_URL_MISS_RETRY_MS = 5 _000 ;
function appendImageBlock(images: ImageBlock[], block: ImageBlock) {
if (!images.some((entry) => entry.url === block.url && entry.alt === block.alt)) {
images.push(block);
}
}
function buildBase64ImageUrl(params: { data: string; mediaType?: string }): string {
return params.data.startsWith("data:" )
? params.data
: `data:${params.mediaType ?? "image/png" };base64,${params.data}`;
}
function getFileExtension(url: string): string | undefined {
const source = (() => {
try {
const trimmed = url.trim();
if (/^https?:\/\//i.test(trimmed)) {
return new URL(trimmed).pathname;
}
} catch {
// Fall back to the raw path when URL parsing fails.
}
return url;
})();
const fileName = source.split(/[\\/]/).pop() ?? source;
const match = /\.([a-zA-Z0-9 ]+)$/.exec(fileName);
return match?.[1 ]?.toLowerCase();
}
function isImageTranscriptMediaPath(path: string, mediaType: unknown): boolean {
if (typeof mediaType === "string" && mediaType.trim()) {
const normalized = mediaType.trim().toLowerCase();
if (normalized.startsWith("image/" )) {
return true ;
}
if (normalized !== "application/octet-stream" ) {
return false ;
}
}
const ext = getFileExtension(path);
return (
ext !== undefined &&
["png" , "jpg" , "jpeg" , "gif" , "webp" , "bmp" , "svg" , "heic" , "heif" , "avif" ].includes(ext)
);
}
function extractImages(message: unknown): ImageBlock[] {
const m = message as Record<string, unknown>;
const content = m.content;
const images: ImageBlock[] = [];
if (Array.isArray(content)) {
for (const block of content) {
if (typeof block !== "object" || block === null ) {
continue ;
}
const b = block as Record<string, unknown>;
if (b.type === "image" ) {
// Handle source object format (from sendChatMessage)
const source = b.source as Record<string, unknown> | undefined;
const imageMeta = {
alt: typeof b.alt === "string" ? b.alt : undefined,
openUrl: typeof b.openUrl === "string" ? b.openUrl : undefined,
width: typeof b.width === "number" ? b.width : undefined,
height: typeof b.height === "number" ? b.height : undefined,
};
if (source?.type === "base64" && typeof source.data === "string" ) {
appendImageBlock(images, {
url: buildBase64ImageUrl({
data: source.data,
mediaType: typeof source.media_type === "string" ? source.media_type : undefined,
}),
...imageMeta,
});
} else if (typeof b.url === "string" ) {
appendImageBlock(images, { url: b.url, ...imageMeta });
}
} else if (b.type === "image_url" ) {
// OpenAI format
const imageUrl = b.image_url as Record<string, unknown> | undefined;
if (typeof imageUrl?.url === "string" ) {
appendImageBlock(images, { url: imageUrl.url });
}
} else if (b.type === "input_image" ) {
const imageUrl = b.image_url;
if (typeof imageUrl === "string" ) {
appendImageBlock(images, { url: imageUrl });
} else if (imageUrl && typeof imageUrl === "object" ) {
const url = (imageUrl as Record<string, unknown>).url;
if (typeof url === "string" ) {
appendImageBlock(images, { url });
}
}
const source = b.source as Record<string, unknown> | undefined;
if (typeof source?.url === "string" ) {
appendImageBlock(images, { url: source.url });
} else if (typeof source?.data === "string" ) {
appendImageBlock(images, {
url: buildBase64ImageUrl({
data: source.data,
mediaType: typeof source.media_type === "string" ? source.media_type : undefined,
}),
});
}
}
}
}
const transcriptMediaPaths = Array.isArray(m.MediaPaths)
? m.MediaPaths.filter((value): value is string => typeof value === "string" )
: typeof m.MediaPath === "string"
? [m.MediaPath]
: [];
const transcriptMediaTypes = Array.isArray(m.MediaTypes)
? m.MediaTypes
: typeof m.MediaType === "string"
? [m.MediaType]
: [];
for (const [index, mediaPath] of transcriptMediaPaths.entries()) {
if (!isImageTranscriptMediaPath(mediaPath, transcriptMediaTypes[index])) {
continue ;
}
appendImageBlock(images, { url: mediaPath });
}
return images;
}
export function renderReadingIndicatorGroup(
assistant?: AssistantIdentity,
basePath?: string,
authToken?: string | null ,
) {
return html`
<div class ="chat-group assistant" >
${renderAvatar("assistant" , assistant, undefined, basePath, authToken)}
<div class ="chat-group-messages" >
<div class ="chat-bubble chat-reading-indicator" aria-hidden="true" >
<span class ="chat-reading-indicator__dots" >
<span></span><span></span><span></span>
</span>
</div>
</div>
</div>
`;
}
export function renderStreamingGroup(
text: string,
startedAt: number,
onOpenSidebar?: (content: SidebarContent) => void ,
assistant?: AssistantIdentity,
basePath?: string,
authToken?: string | null ,
) {
const timestamp = new Date(startedAt).toLocaleTimeString([], {
hour: "numeric" ,
minute: "2-digit" ,
});
const name = assistant?.name ?? "Assistant" ;
return html`
<div class ="chat-group assistant" >
${renderAvatar("assistant" , assistant, undefined, basePath, authToken)}
<div class ="chat-group-messages" >
${renderGroupedMessage(
{
role: "assistant" ,
content: [{ type: "text" , text }],
timestamp: startedAt,
},
`stream:${startedAt}`,
{ isStreaming: true , showReasoning: false },
onOpenSidebar,
)}
<div class ="chat-group-footer" >
<span class ="chat-sender-name" >${name}</span>
<span class ="chat-group-timestamp" >${timestamp}</span>
</div>
</div>
</div>
`;
}
export function renderMessageGroup(
group: MessageGroup,
opts: {
onOpenSidebar?: (content: SidebarContent) => void ;
showReasoning: boolean ;
showToolCalls?: boolean ;
autoExpandToolCalls?: boolean ;
isToolMessageExpanded?: (messageId: string) => boolean ;
onToggleToolMessageExpanded?: (messageId: string) => void ;
isToolExpanded?: (toolCardId: string) => boolean ;
onToggleToolExpanded?: (toolCardId: string) => void ;
onRequestUpdate?: () => void ;
assistantName?: string;
assistantAvatar?: string | null ;
userName?: string | null ;
userAvatar?: string | null ;
basePath?: string;
localMediaPreviewRoots?: readonly string[];
assistantAttachmentAuthToken?: string | null ;
canvasHostUrl?: string | null ;
embedSandboxMode?: EmbedSandboxMode;
allowExternalEmbedUrls?: boolean ;
contextWindow?: number | null ;
onDelete?: () => void ;
},
) {
const normalizedRole = normalizeRoleForGrouping(group.role);
const assistantName = opts.assistantName ?? "Assistant" ;
const resolvedUserName = resolveLocalUserName({
name: opts.userName ?? null ,
avatar: opts.userAvatar ?? null ,
});
const userLabel = group.senderLabel?.trim();
const who =
normalizedRole === "user"
? (userLabel ?? resolvedUserName)
: normalizedRole === "assistant"
? assistantName
: normalizedRole === "tool"
? "Tool"
: normalizedRole;
const roleClass =
normalizedRole === "user"
? "user"
: normalizedRole === "assistant"
? "assistant"
: normalizedRole === "tool"
? "tool"
: "other" ;
const timestamp = new Date(group.timestamp).toLocaleTimeString([], {
hour: "numeric" ,
minute: "2-digit" ,
});
// Aggregate usage/cost/model across all messages in the group
const meta = extractGroupMeta(group, opts.contextWindow ?? null );
return html`
<div class ="chat-group ${roleClass}" >
${renderAvatar(
group.role,
{
name: assistantName,
avatar: opts.assistantAvatar ?? null ,
},
{
name: opts.userName ?? null ,
avatar: opts.userAvatar ?? null ,
},
opts.basePath,
opts.assistantAttachmentAuthToken,
)}
<div class ="chat-group-messages" >
${group.messages.map((item, index) =>
renderGroupedMessage(
item.message,
item.key,
{
isStreaming: group.isStreaming && index === group.messages.length - 1 ,
showReasoning: opts.showReasoning,
showToolCalls: opts.showToolCalls ?? true ,
autoExpandToolCalls: opts.autoExpandToolCalls ?? false ,
isToolMessageExpanded: opts.isToolMessageExpanded,
onToggleToolMessageExpanded: opts.onToggleToolMessageExpanded,
isToolExpanded: opts.isToolExpanded,
onToggleToolExpanded: opts.onToggleToolExpanded,
onRequestUpdate: opts.onRequestUpdate,
canvasHostUrl: opts.canvasHostUrl,
basePath: opts.basePath,
localMediaPreviewRoots: opts.localMediaPreviewRoots,
assistantAttachmentAuthToken: opts.assistantAttachmentAuthToken,
embedSandboxMode: opts.embedSandboxMode,
},
opts.onOpenSidebar,
),
)}
<div class ="chat-group-footer" >
<span class ="chat-sender-name" >${who}</span>
<span class ="chat-group-timestamp" >${timestamp}</span>
${renderMessageMeta(meta)}
${normalizedRole === "assistant" && isTtsSupported() ? renderTtsButton(group) : nothing}
${opts.onDelete
? renderDeleteButton(opts.onDelete, normalizedRole === "user" ? "left" : "right" )
: nothing}
</div>
</div>
</div>
`;
}
// ── Per-message metadata (tokens, cost, model, context %) ──
type GroupMeta = {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cost: number;
model: string | null ;
contextPercent: number | null ;
};
function extractGroupMeta(group: MessageGroup, contextWindow: number | null ): GroupMeta | null {
let input = 0 ;
let output = 0 ;
let cacheRead = 0 ;
let cacheWrite = 0 ;
let cost = 0 ;
let model: string | null = null ;
let hasUsage = false ;
for (const { message } of group.messages) {
const m = message as Record<string, unknown>;
if (m.role !== "assistant" ) {
continue ;
}
const usage = m.usage as Record<string, number> | undefined;
if (usage) {
hasUsage = true ;
input += usage.input ?? usage.inputTokens ?? 0 ;
output += usage.output ?? usage.outputTokens ?? 0 ;
cacheRead += usage.cacheRead ?? usage.cache_read_input_tokens ?? 0 ;
cacheWrite += usage.cacheWrite ?? usage.cache_creation_input_tokens ?? 0 ;
}
const c = m.cost as Record<string, number> | undefined;
if (c?.total) {
cost += c.total;
}
if (typeof m.model === "string" && m.model !== "gateway-injected" ) {
model = m.model;
}
}
if (!hasUsage && !model) {
return null ;
}
const promptTokens = input + cacheRead + cacheWrite;
const contextPercent =
contextWindow && promptTokens > 0
? Math.min(Math.round((promptTokens / contextWindow) * 100 ), 100 )
: null ;
return { input, output, cacheRead, cacheWrite, cost, model, contextPercent };
}
/** Compact token count formatter (e.g. 128000 → "128k"). */
function fmtTokens(n: number): string {
if (n >= 1 _000 _000 ) {
return `${(n / 1 _000 _000 ).toFixed(1 ).replace(/\.0 $/, "" )}M`;
}
if (n >= 1 _000 ) {
return `${(n / 1 _000 ).toFixed(1 ).replace(/\.0 $/, "" )}k`;
}
return String(n);
}
function renderMessageMeta(meta: GroupMeta | null ) {
if (!meta) {
return nothing;
}
const parts: Array<ReturnType<typeof html>> = [];
// Token counts: ↑input ↓output
if (meta.input) {
parts.push(html`<span class ="msg-meta__tokens" >↑${fmtTokens(meta.input)}</span>`);
}
if (meta.output) {
parts.push(html`<span class ="msg-meta__tokens" >↓${fmtTokens(meta.output)}</span>`);
}
// Cache: R/W
if (meta.cacheRead) {
parts.push(html`<span class ="msg-meta__cache" >R${fmtTokens(meta.cacheRead)}</span>`);
}
if (meta.cacheWrite) {
parts.push(html`<span class ="msg-meta__cache" >W${fmtTokens(meta.cacheWrite)}</span>`);
}
// Cost
if (meta.cost > 0 ) {
parts.push(html`<span class ="msg-meta__cost" >$${meta.cost.toFixed(4 )}</span>`);
}
// Context %
if (meta.contextPercent !== null ) {
const pct = meta.contextPercent;
const cls =
pct >= 90
? "msg-meta__ctx msg-meta__ctx--danger"
: pct >= 75
? "msg-meta__ctx msg-meta__ctx--warn"
: "msg-meta__ctx" ;
parts.push(html`<span class ="${cls}" >${pct}% ctx</span>`);
}
// Model
if (meta.model) {
// Shorten model name: strip provider prefix if present (e.g. "anthropic/claude-3.5-sonnet" → "claude-3.5-sonnet")
const shortModel = meta.model.includes("/" ) ? meta.model.split("/" ).pop()! : meta.model;
parts.push(html`<span class ="msg-meta__model" >${shortModel}</span>`);
}
if (parts.length === 0 ) {
return nothing;
}
return html`<span class ="msg-meta" >${parts}</span>`;
}
function extractGroupText(group: MessageGroup): string {
const parts: string[] = [];
for (const { message } of group.messages) {
const text = extractTextCached(message);
if (text?.trim()) {
parts.push(text.trim());
}
}
return parts.join("\n\n" );
}
const SKIP_DELETE_CONFIRM_KEY = "openclaw:skipDeleteConfirm" ;
type DeleteConfirmSide = "left" | "right" ;
function shouldSkipDeleteConfirm(): boolean {
try {
return getSafeLocalStorage()?.getItem(SKIP_DELETE_CONFIRM_KEY) === "1" ;
} catch {
return false ;
}
}
function renderDeleteButton(onDelete: () => void , side: DeleteConfirmSide) {
return html`
<span class ="chat-delete-wrap" >
<button
class ="chat-group-delete"
title="Delete"
aria-label="Delete message"
@click=${(e: Event) => {
if (shouldSkipDeleteConfirm()) {
onDelete();
return ;
}
const btn = e.currentTarget as HTMLElement;
const wrap = btn.closest(".chat-delete-wrap" ) as HTMLElement;
const existing = wrap?.querySelector(".chat-delete-confirm" );
if (existing) {
existing.remove();
return ;
}
const popover = document.createElement("div" );
popover.className = `chat-delete -confirm chat-delete -confirm--${side}`;
popover.innerHTML = `
<p class ="chat-delete-confirm__text" >Delete this message?</p>
<label class ="chat-delete-confirm__remember" >
<input type="checkbox" class ="chat-delete-confirm__check" />
<span>Don't ask again</span>
</label>
<div class ="chat-delete-confirm__actions" >
<button class ="chat-delete-confirm__cancel" type="button" >Cancel</button>
<button class ="chat-delete-confirm__yes" type="button" >Delete </button>
</div>
`;
wrap.appendChild(popover);
const cancel = popover.querySelector(".chat-delete-confirm__cancel" )!;
const yes = popover.querySelector(".chat-delete-confirm__yes" )!;
const check = popover.querySelector(".chat-delete-confirm__check" ) as HTMLInputElement;
cancel.addEventListener("click" , () => popover.remove());
yes.addEventListener("click" , () => {
if (check.checked) {
try {
getSafeLocalStorage()?.setItem(SKIP_DELETE_CONFIRM_KEY, "1" );
} catch {}
}
popover.remove();
onDelete();
});
// Close on click outside
const closeOnOutside = (evt: MouseEvent) => {
if (!popover.contains(evt.target as Node) && evt.target !== btn) {
popover.remove();
document.removeEventListener("click" , closeOnOutside, true );
}
};
requestAnimationFrame(() => document.addEventListener("click" , closeOnOutside, true ));
}}
>
${icons.trash ?? icons.x}
</button>
</span>
`;
}
function renderTtsButton(group: MessageGroup) {
return html`
<button
class ="btn btn--xs chat-tts-btn"
type="button"
title=${isTtsSpeaking() ? "Stop speaking" : "Read aloud" }
aria-label=${isTtsSpeaking() ? "Stop speaking" : "Read aloud" }
@click=${(e: Event) => {
const btn = e.currentTarget as HTMLButtonElement;
if (isTtsSpeaking()) {
stopTts();
btn.classList.remove("chat-tts-btn--active" );
btn.title = "Read aloud" ;
return ;
}
const text = extractGroupText(group);
if (!text) {
return ;
}
btn.classList.add("chat-tts-btn--active" );
btn.title = "Stop speaking" ;
speakText(text, {
onEnd: () => {
if (btn.isConnected) {
btn.classList.remove("chat-tts-btn--active" );
btn.title = "Read aloud" ;
}
},
onError: () => {
if (btn.isConnected) {
btn.classList.remove("chat-tts-btn--active" );
btn.title = "Read aloud" ;
}
},
});
}}
>
${icons.volume2}
</button>
`;
}
function renderAvatar(
role: string,
assistant?: Pick<AssistantIdentity, "name" | "avatar" >,
user?: { name?: string | null ; avatar?: string | null },
basePath?: string,
authToken?: string | null ,
) {
const normalized = normalizeRoleForGrouping(role);
const assistantName = assistant?.name?.trim() || "Assistant" ;
const assistantAvatar = assistant?.avatar?.trim() || "" ;
const assistantAvatarText = resolveAssistantTextAvatar(assistantAvatar);
const userName = resolveLocalUserName(user);
const userAvatarUrl = resolveLocalUserAvatarUrl(user);
const userAvatarText = resolveLocalUserAvatarText(user);
const initial =
normalized === "user"
? html`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18" >
<circle cx="12" cy="8" r="4" />
<path d="M20 21a8 8 0 1 0-16 0" />
</svg>
`
: normalized === "assistant"
? html`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18" >
<path d="M12 2l2.4 7.2H22l-6 4.8 2.4 7.2L12 16l-6.4 5.2L8 14 2 9.2h7.6z" />
</svg>
`
: normalized === "tool"
? html`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18" >
<path
d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.53a7.76 7.76 0 0 0 .07-1 7.76 7.76 0 0 0-.07-.97l2.11-1.63a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.15 7.15 0 0 0-1.69-.98l-.38-2.65A.49.49 0 0 0 14 2h-4a.49.49 0 0 0-.49.42l-.38 2.65a7.15 7.15 0 0 0-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.49.49 0 0 0 .12.64L4.57 11a7.9 7.9 0 0 0 0 1.94l-2.11 1.69a.49.49 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.72 1.69.98l.38 2.65c.05.24.26.42.49.42h4c.23 0 .44-.18.49-.42l.38-2.65a7.15 7.15 0 0 0 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.49.49 0 0 0-.12-.64z"
/>
</svg>
`
: html`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18" >
<circle cx="12" cy="12" r="10" />
<text
x="12"
y="16.5"
text-anchor="middle"
font-size="14"
font-weight="600"
fill="var(--bg, #fff)"
>
?
</text>
</svg>
`;
const className =
normalized === "user"
? "user"
: normalized === "assistant"
? "assistant"
: normalized === "tool"
? "tool"
: "other" ;
if (normalized === "user" && userAvatarUrl) {
return html`<img class ="chat-avatar ${className}" src="${userAvatarUrl}" alt="${userName}" />`;
}
if (normalized === "user" && userAvatarText) {
return html`<div class ="chat-avatar ${className}" aria-label="${userName}" >
${userAvatarText}
</div>`;
}
if (assistantAvatar && normalized === "assistant" ) {
if (isAvatarUrl(assistantAvatar)) {
if (authToken?.trim() && assistantAvatar.startsWith("/" )) {
return html`<img
class ="chat-avatar ${className} chat-avatar--logo"
src="${agentLogoUrl(basePath ?? " ")}"
alt="${assistantName}"
/>`;
}
return html`<img
class ="chat-avatar ${className}"
src="${assistantAvatar}"
alt="${assistantName}"
/>`;
}
if (assistantAvatarText) {
return html`<div class ="chat-avatar ${className}" aria-label="${assistantName}" >
${assistantAvatarText}
</div>`;
}
return html`<img
class ="chat-avatar ${className} chat-avatar--logo"
src="${agentLogoUrl(basePath ?? " ")}"
alt="${assistantName}"
/>`;
}
/* Assistant with no custom avatar: use logo when basePath available */
if (normalized === "assistant" && basePath) {
const logoUrl = agentLogoUrl(basePath);
return html`<img
class ="chat-avatar ${className} chat-avatar--logo"
src="${logoUrl}"
alt="${assistantName}"
/>`;
}
return html`<div class ="chat-avatar ${className}" >${initial}</div>`;
}
function isAvatarUrl(value: string): boolean {
const trimmed = value.trim();
return trimmed.startsWith("blob:" ) || isRenderableControlUiAvatarUrl(trimmed);
}
const UNSAFE_ASSISTANT_TEXT_AVATAR_CHARS = /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u;
export function resolveAssistantTextAvatar(value: string | null | undefined): string | null {
const trimmed = value?.trim();
if (!trimmed || trimmed === DEFAULT_ASSISTANT_AVATAR) {
return null ;
}
if (isAvatarUrl(trimmed)) {
return null ;
}
if (
trimmed.length > 8 ||
/\s/.test(trimmed) ||
/[\\/.:]/.test(trimmed) ||
UNSAFE_ASSISTANT_TEXT_AVATAR_CHARS.test(trimmed)
) {
return null ;
}
return trimmed;
}
function resolveRenderableMessageImages(
images: ImageBlock[],
opts?: ImageRenderOptions,
): RenderableImageBlock[] {
return images.flatMap((img) => {
const isLocalImage = isLocalAssistantAttachmentSource(img.url);
const canProxyLocalImage =
isLocalImage && isLocalAttachmentPreviewAllowed(img.url, opts?.localMediaPreviewRoots ?? []);
if (isLocalImage && !canProxyLocalImage) {
return [];
}
const displayUrl = canProxyLocalImage
? buildAssistantAttachmentUrl(img.url, opts?.basePath, opts?.authToken)
: img.url;
return [{ ...img, displayUrl }];
});
}
function renderMessageImages(images: RenderableImageBlock[], opts?: ImageRenderOptions) {
if (images.length === 0 ) {
return nothing;
}
const openImage = (url: string) => {
openExternalUrlSafe(url, { allowDataImage: true });
};
const renderImageElement = (img: RenderableImageBlock, previewUrl: string) => html`
<img
src=${previewUrl}
alt=${img.alt ?? "Attached image" }
class ="chat-message-image"
width=${img.width ?? nothing}
height=${img.height ?? nothing}
@click=${() => openImage(previewUrl)}
/>
`;
const renderImage = (img: RenderableImageBlock) => {
if (!isManagedOutgoingImageSource(img.displayUrl)) {
return renderImageElement(img, img.displayUrl);
}
const preview = resolveManagedOutgoingImageBlobUrl(img.displayUrl, opts).then((previewUrl) => {
if (!previewUrl) {
return nothing;
}
return renderImageElement(img, previewUrl);
});
return until(preview, nothing);
};
return html` <div class ="chat-message-images" >${images.map((img) => renderImage(img))}</div> `;
}
function renderReplyPill(replyTarget: NormalizedMessage["replyTarget" ]) {
if (!replyTarget) {
return nothing;
}
return html`
<div class ="chat-reply-pill" >
<span class ="chat-reply-pill__icon" >${icons.messageSquare}</span>
<span class ="chat-reply-pill__label" >
${replyTarget.kind === "current"
? "Replying to current message"
: `Replying to ${replyTarget.id}`}
</span>
</div>
`;
}
function isLocalAssistantAttachmentSource(source: string): boolean {
const trimmed = source.trim();
if (/^\/(?:__openclaw__|media|api\/chat\/media\/outgoing)\//.test(trimmed)) {
return false ;
}
return (
trimmed.startsWith("file://") ||
trimmed.startsWith("~" ) ||
trimmed.startsWith("/" ) ||
/^[a-zA-Z]:[\\/]/.test(trimmed)
);
}
function normalizeLocalAttachmentPath(source: string): string | null {
const trimmed = source.trim();
if (!isLocalAssistantAttachmentSource(trimmed)) {
return null ;
}
if (trimmed.startsWith("file://")) {
try {
const url = new URL(trimmed);
const pathname = decodeURIComponent(url.pathname);
if (/^\/[a-zA-Z]:\//.test(pathname)) {
return pathname.slice(1 );
}
return pathname;
} catch {
return null ;
}
}
if (trimmed.startsWith("~" )) {
return null ;
}
return trimmed;
}
function resolveHomeCandidatesFromRoots(localMediaPreviewRoots: readonly string[]): string[] {
const candidates = new Set<string>();
for (const root of localMediaPreviewRoots) {
const normalized = canonicalizeLocalPathForComparison(root.trim());
const unixHome = normalized.match(/^(\/Users\/[^/]+|\/home\/[^/]+)(?:\/|$)/);
if (unixHome?.[1 ]) {
candidates.add(unixHome[1 ]);
continue ;
}
const windowsHome = normalized.match(/^([a-z]:\/Users\/[^/]+)(?:\/|$)/i);
if (windowsHome?.[1 ]) {
candidates.add(windowsHome[1 ]);
}
}
return [...candidates];
}
function canonicalizeLocalPathForComparison(value: string): string {
let slashNormalized = value.replace(/\\/g, "/" ).replace(/\/+$/, "" );
if (/^\/[a-zA-Z]:\//.test(slashNormalized)) {
slashNormalized = slashNormalized.slice(1 );
}
if (/^[a-zA-Z]:\//.test(slashNormalized)) {
return slashNormalized.toLowerCase();
}
return slashNormalized;
}
function isLocalAttachmentPreviewAllowed(
source: string,
localMediaPreviewRoots: readonly string[],
): boolean {
const normalizedSource = normalizeLocalAttachmentPath(source);
const comparableSources = normalizedSource
? [canonicalizeLocalPathForComparison(normalizedSource)]
: source.trim().startsWith("~" )
? resolveHomeCandidatesFromRoots(localMediaPreviewRoots).map((home) =>
canonicalizeLocalPathForComparison(source.trim().replace(/^~(?=$|[\\/])/, home)),
)
: [];
if (comparableSources.length === 0 ) {
return false ;
}
return localMediaPreviewRoots.some((root) => {
const normalizedRoot = canonicalizeLocalPathForComparison(root.trim());
return (
normalizedRoot.length > 0 &&
comparableSources.some(
(comparableSource) =>
comparableSource === normalizedRoot || comparableSource.startsWith(`${normalizedRoot}/`),
)
);
});
}
function buildAssistantAttachmentUrl(
source: string,
basePath?: string,
authToken?: string | null ,
): string {
if (!isLocalAssistantAttachmentSource(source)) {
return source;
}
const normalizedBasePath =
basePath && basePath !== "/" ? (basePath.endsWith("/" ) ? basePath.slice(0 , -1 ) : basePath) : "" ;
const params = new URLSearchParams({ source });
const normalizedToken = authToken?.trim();
if (normalizedToken) {
params.set("token" , normalizedToken);
}
return `${normalizedBasePath}/__openclaw__/assistant-media?${params.toString()}`;
}
function isManagedOutgoingImageSource(source: string): boolean {
const trimmed = source.trim();
if (trimmed.startsWith("/api/chat/media/outgoing/" )) {
return true ;
}
try {
const parsed = new URL(trimmed, window.location.origin);
return (
parsed.origin === window.location.origin &&
parsed.pathname.startsWith("/api/chat/media/outgoing/" )
);
} catch {
return false ;
}
}
function resolveManagedOutgoingImageRequesterSessionKey(source: string): string | null {
try {
const parsed = new URL(source, window.location.origin);
const parts = parsed.pathname.split("/" );
const encodedSessionKey = parts[5 ];
return encodedSessionKey ? decodeURIComponent(encodedSessionKey) : null ;
} catch {
return null ;
}
}
function buildManagedOutgoingImageFetchUrl(source: string, basePath?: string): string {
if (!source.startsWith("/" )) {
return source;
}
const normalizedBasePath =
basePath && basePath !== "/" ? (basePath.endsWith("/" ) ? basePath.slice(0 , -1 ) : basePath) : "" ;
return `${normalizedBasePath}${source}`;
}
async function resolveManagedOutgoingImageBlobUrl(
source: string,
opts?: ImageRenderOptions,
): Promise<string | null > {
const authToken = opts?.authToken?.trim() ?? "" ;
const fetchUrl = buildManagedOutgoingImageFetchUrl(source, opts?.basePath);
const cacheKey = `${fetchUrl}::${authToken}`;
const cached = managedImageBlobUrlResolvedCache.get(cacheKey);
if (cached) {
return cached;
}
const missAt = managedImageBlobUrlMissCache.get(cacheKey);
if (missAt && Date.now() - missAt < MANAGED_IMAGE_BLOB_URL_MISS_RETRY_MS) {
return null ;
}
let pending = managedImageBlobUrlCache.get(cacheKey);
if (!pending) {
pending = (async () => {
const requesterSessionKey = resolveManagedOutgoingImageRequesterSessionKey(source);
const headers = new Headers({ Accept: "image/*" });
if (authToken) {
headers.set("Authorization" , `Bearer ${authToken}`);
}
if (requesterSessionKey) {
headers.set("x-openclaw-requester-session-key" , requesterSessionKey);
}
const res = await fetch(fetchUrl, {
method: "GET" ,
headers,
credentials: "same-origin" ,
});
if (!res.ok) {
managedImageBlobUrlMissCache.set(cacheKey, Date.now());
return null ;
}
const blob = await res.blob();
if (!blob.type.startsWith("image/" )) {
managedImageBlobUrlMissCache.set(cacheKey, Date.now());
return null ;
}
const blobUrl = URL.createObjectURL(blob);
managedImageBlobUrlResolvedCache.set(cacheKey, blobUrl);
managedImageBlobUrlMissCache.delete (cacheKey);
return blobUrl;
})().finally (() => {
managedImageBlobUrlCache.delete (cacheKey);
});
managedImageBlobUrlCache.set(cacheKey, pending);
}
return pending;
}
function buildAssistantAttachmentMetaUrl(
source: string,
basePath?: string,
authToken?: string | null ,
): string {
const attachmentUrl = buildAssistantAttachmentUrl(source, basePath, authToken);
return `${attachmentUrl}${attachmentUrl.includes("?" ) ? "&" : "?" }meta=1 `;
}
function resolveAssistantAttachmentAvailability(
source: string,
localMediaPreviewRoots: readonly string[],
basePath: string | undefined,
authToken: string | null | undefined,
onRequestUpdate: (() => void ) | undefined,
): AssistantAttachmentAvailability {
if (!isLocalAssistantAttachmentSource(source)) {
return { status: "available" };
}
if (!isLocalAttachmentPreviewAllowed(source, localMediaPreviewRoots)) {
return { status: "unavailable" , reason: "Outside allowed folders" , checkedAt: Date.now() };
}
const normalizedAuthToken = authToken?.trim() ?? "" ;
const cacheKey = `${basePath ?? "" }::${normalizedAuthToken}::${source}`;
const cached = assistantAttachmentAvailabilityCache.get(cacheKey);
if (cached) {
if (
cached.status === "unavailable" &&
Date.now() - cached.checkedAt >= ASSISTANT_ATTACHMENT_UNAVAILABLE_RETRY_MS
) {
assistantAttachmentAvailabilityCache.delete (cacheKey);
} else {
return cached;
}
}
assistantAttachmentAvailabilityCache.set(cacheKey, { status: "checking" });
if (typeof fetch === "function" ) {
void fetch(buildAssistantAttachmentMetaUrl(source, basePath, authToken), {
method: "GET" ,
headers: { Accept: "application/json" },
credentials: "same-origin" ,
})
.then(async (res) => {
const payload = (await res.json().catch (() => null )) as {
available?: boolean ;
reason?: string;
} | null ;
if (payload?.available === true ) {
assistantAttachmentAvailabilityCache.set(cacheKey, { status: "available" });
} else {
assistantAttachmentAvailabilityCache.set(cacheKey, {
status: "unavailable" ,
reason: payload?.reason?.trim() || "Attachment unavailable" ,
checkedAt: Date.now(),
});
}
})
.catch (() => {
assistantAttachmentAvailabilityCache.set(cacheKey, {
status: "unavailable" ,
reason: "Attachment unavailable" ,
checkedAt: Date.now(),
});
})
.finally (() => {
onRequestUpdate?.();
});
}
return { status: "checking" };
}
function renderAssistantAttachmentStatusCard(params: {
kind: "image" | "audio" | "video" | "document" ;
label: string;
badge: string;
reason?: string;
}) {
const icon =
params.kind === "image"
? icons.image
: params.kind === "audio"
? icons.mic
: params.kind === "video"
? icons.monitor
: icons.paperclip;
return html`
<div class ="chat-assistant-attachment-card chat-assistant-attachment-card--blocked" >
<div class ="chat-assistant-attachment-card__header" >
<span class ="chat-assistant-attachment-card__icon" >${icon}</span>
<span class ="chat-assistant-attachment-card__title" >${params.label}</span>
<span class ="chat-assistant-attachment-badge chat-assistant-attachment-badge--muted"
>${params.badge}</span
>
</div>
${params.reason
? html`<div class ="chat-assistant-attachment-card__reason" >${params.reason}</div>`
: nothing}
</div>
`;
}
function renderAssistantAttachments(
attachments: Array<Extract<MessageContentItem, { type: "attachment" }>>,
localMediaPreviewRoots: readonly string[],
basePath?: string,
authToken?: string | null ,
onRequestUpdate?: () => void ,
) {
if (attachments.length === 0 ) {
return nothing;
}
return html`
<div class ="chat-assistant-attachments" >
${attachments.map(({ attachment }) => {
const availability = resolveAssistantAttachmentAvailability(
attachment.url,
localMediaPreviewRoots,
basePath,
authToken,
onRequestUpdate,
);
const attachmentUrl =
availability.status === "available"
? buildAssistantAttachmentUrl(attachment.url, basePath, authToken)
: null ;
if (attachment.kind === "image" ) {
if (!attachmentUrl) {
return renderAssistantAttachmentStatusCard({
kind: "image" ,
label: attachment.label,
badge: availability.status === "checking" ? "Checking..." : "Unavailable" ,
reason: availability.status === "unavailable" ? availability.reason : undefined,
});
}
return html`
<img
src=${attachmentUrl}
alt=${attachment.label}
class ="chat-message-image"
@click=${() => openExternalUrlSafe(attachmentUrl, { allowDataImage: true })}
/>
`;
}
if (attachment.kind === "audio" ) {
return html`
<div class ="chat-assistant-attachment-card chat-assistant-attachment-card--audio" >
<div class ="chat-assistant-attachment-card__header" >
<span class ="chat-assistant-attachment-card__title" >${attachment.label}</span>
${!attachmentUrl
? html`<span
class ="chat-assistant-attachment-badge chat-assistant-attachment-badge--muted"
>${availability.status === "checking" ? "Checking..." : "Unavailable" }</span
>`
: attachment.isVoiceNote
? html`<span class ="chat-assistant-attachment-badge" >Voice note</span>`
: nothing}
</div>
${attachmentUrl
? html`<audio controls preload="metadata" src=${attachmentUrl}></audio>`
: availability.status === "unavailable"
? html`<div class ="chat-assistant-attachment-card__reason" >
${availability.reason}
</div>`
: nothing}
</div>
`;
}
if (attachment.kind === "video" ) {
if (!attachmentUrl) {
return renderAssistantAttachmentStatusCard({
kind: "video" ,
label: attachment.label,
badge: availability.status === "checking" ? "Checking..." : "Unavailable" ,
reason: availability.status === "unavailable" ? availability.reason : undefined,
});
}
return html`
<div class ="chat-assistant-attachment-card chat-assistant-attachment-card--video" >
<video controls preload="metadata" src=${attachmentUrl}></video>
<a
class ="chat-assistant-attachment-card__link"
href=${attachmentUrl}
target="_blank"
rel="noreferrer"
>${attachment.label}</a
>
</div>
`;
}
if (!attachmentUrl) {
return renderAssistantAttachmentStatusCard({
kind: "document" ,
label: attachment.label,
badge: availability.status === "checking" ? "Checking..." : "Unavailable" ,
reason: availability.status === "unavailable" ? availability.reason : undefined,
});
}
return html`
<div class ="chat-assistant-attachment-card" >
<span class ="chat-assistant-attachment-card__icon" >${icons.paperclip}</span>
<a
class ="chat-assistant-attachment-card__link"
href=${attachmentUrl}
target="_blank"
rel="noreferrer"
>${attachment.label}</a
>
</div>
`;
})}
</div>
`;
}
function renderInlineToolCards(
toolCards: ToolCard[],
opts: {
messageKey: string;
onOpenSidebar?: (content: SidebarContent) => void ;
isToolExpanded?: (toolCardId: string) => boolean ;
onToggleToolExpanded?: (toolCardId: string) => void ;
canvasHostUrl?: string | null ;
embedSandboxMode?: EmbedSandboxMode;
allowExternalEmbedUrls?: boolean ;
},
) {
return html`
<div class ="chat-tools-inline" >
${toolCards.map((card, index) =>
renderToolCard(card, {
expanded: opts.isToolExpanded?.(`${opts.messageKey}:toolcard:${index}`) ?? false ,
onToggleExpanded: opts.onToggleToolExpanded
? () => opts.onToggleToolExpanded?.(`${opts.messageKey}:toolcard:${index}`)
: () => undefined,
onOpenSidebar: opts.onOpenSidebar,
canvasHostUrl: opts.canvasHostUrl,
embedSandboxMode: opts.embedSandboxMode ?? "scripts" ,
allowExternalEmbedUrls: opts.allowExternalEmbedUrls ?? false ,
}),
)}
</div>
`;
}
/**
* Max characters for auto - detecting and pretty - printing JSON .
* Prevents DoS from large JSON payloads in assistant / tool messages .
*/
const MAX_JSON_AUTOPARSE_CHARS = 20 _000 ;
/**
* Detect whether a trimmed string is a JSON object or array .
* Must start with ` { ` / ` [ ` and end with ` } ` / ` ] ` and parse successfully .
* Size - capped to prevent render - loop DoS from large JSON messages .
*/
function detectJson(text: string): { parsed: unknown; pretty: string } | null {
const t = text.trim();
// Enforce size cap to prevent UI freeze from multi-MB JSON payloads
if (t.length > MAX_JSON_AUTOPARSE_CHARS) {
return null ;
}
if ((t.startsWith("{" ) && t.endsWith("}" )) || (t.startsWith("[" ) && t.endsWith("]" ))) {
try {
const parsed = JSON.parse(t);
return { parsed, pretty: JSON.stringify(parsed, null , 2 ) };
} catch {
return null ;
}
}
return null ;
}
/** Build a short summary label for collapsed JSON (type + key count or array length). */
function jsonSummaryLabel(parsed: unknown): string {
if (Array.isArray(parsed)) {
return `Array (${parsed.length} item${parsed.length === 1 ? "" : "s" })`;
}
if (parsed && typeof parsed === "object" ) {
const keys = Object.keys(parsed as Record<string, unknown>);
if (keys.length <= 4 ) {
return `{ ${keys.join(", " )} }`;
}
return `Object (${keys.length} keys)`;
}
return "JSON" ;
}
function renderExpandButton(markdown: string, onOpenSidebar: (content: SidebarContent) => void ) {
return html`
<button
class ="btn btn--xs chat-expand-btn"
type="button"
title="Open in canvas"
aria-label="Open in canvas"
@click=${() => onOpenSidebar({ kind: "markdown" , content: markdown })}
>
<span class ="chat-expand-btn__icon" aria-hidden="true" >${icons.panelRightOpen}</span>
</button>
`;
}
function renderGroupedMessage(
message: unknown,
messageKey: string,
opts: {
isStreaming: boolean ;
showReasoning: boolean ;
showToolCalls?: boolean ;
autoExpandToolCalls?: boolean ;
isToolMessageExpanded?: (messageId: string) => boolean ;
onToggleToolMessageExpanded?: (messageId: string) => void ;
isToolExpanded?: (toolCardId: string) => boolean ;
onToggleToolExpanded?: (toolCardId: string) => void ;
onRequestUpdate?: () => void ;
canvasHostUrl?: string | null ;
basePath?: string;
localMediaPreviewRoots?: readonly string[];
assistantAttachmentAuthToken?: string | null ;
embedSandboxMode?: EmbedSandboxMode;
allowExternalEmbedUrls?: boolean ;
},
onOpenSidebar?: (content: SidebarContent) => void ,
) {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown" ;
const normalizedRole = normalizeRoleForGrouping(role);
const isToolResult =
isToolResultMessage(message) ||
role.toLowerCase() === "toolresult" ||
role.toLowerCase() === "tool_result" ||
typeof m.toolCallId === "string" ||
typeof m.tool_call_id === "string" ;
const toolCards = (opts.showToolCalls ?? true ) ? extractToolCards(message, messageKey) : [];
const hasToolCards = toolCards.length > 0 ;
const imageRenderOptions = {
localMediaPreviewRoots: opts.localMediaPreviewRoots ?? [],
basePath: opts.basePath,
authToken: opts.assistantAttachmentAuthToken,
};
const images = resolveRenderableMessageImages(extractImages(message), imageRenderOptions);
const hasImages = images.length > 0 ;
const normalizedMessage = normalizeMessage(message);
const extractedText = normalizedMessage.content
.reduce<string[]>((lines, item) => {
if (item.type === "text" && typeof item.text === "string" ) {
lines.push(item.text);
}
return lines;
}, [])
.join("\n" )
.trim();
const assistantAttachments = normalizedMessage.content.filter(
(item): item is Extract<MessageContentItem, { type: "attachment" }> =>
item.type === "attachment" ,
);
const assistantViewBlocks = normalizedMessage.content.filter(
(item): item is Extract<MessageContentItem, { type: "canvas" }> => item.type === "canvas" ,
);
const extractedThinking =
opts.showReasoning && role === "assistant" ? extractThinkingCached(message) : null ;
const markdownBase = extractedText?.trim() ? extractedText : null ;
const reasoningMarkdown = extractedThinking ? formatReasoningMarkdown(extractedThinking) : null ;
const markdown = markdownBase;
const canCopyMarkdown = role === "assistant" && Boolean (markdown?.trim());
const canExpand = role === "assistant" && Boolean (onOpenSidebar && markdown?.trim());
// Detect pure-JSON messages and render as collapsible block
const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null ;
const isToolMessage = normalizedRole === "tool" || isToolResult;
const bubbleClasses = [
"chat-bubble" ,
isToolMessage ? "chat-bubble--tool-shell" : "" ,
opts.isStreaming ? "streaming" : "" ,
"fade-in" ,
]
.filter(Boolean )
.join(" " );
// Suppress empty bubbles when tool cards are the only content and toggle is off
const visibleToolCards = hasToolCards && (opts.showToolCalls ?? true );
if (
!markdown &&
!visibleToolCards &&
!hasImages &&
assistantAttachments.length === 0 &&
assistantViewBlocks.length === 0 &&
!normalizedMessage.replyTarget
) {
return nothing;
}
const toolMessageDisclosureId = `toolmsg:${messageKey}`;
const toolMessageExpanded = opts.isToolMessageExpanded?.(toolMessageDisclosureId) ?? false ;
const toolNames = [...new Set(toolCards.map((c) => c.name))];
const toolSummaryLabel =
toolNames.length <= 3
? toolNames.join(", " )
: `${toolNames.slice(0 , 2 ).join(", " )} +${toolNames.length - 2 } more`;
const toolPreview =
markdown && !toolSummaryLabel ? markdown.trim().replace(/\s+/g, " " ).slice(0 , 120 ) : "" ;
const singleToolCard = toolCards.length === 1 ? toolCards[0 ] : null ;
const toolMessageLabel =
singleToolCard && !markdown && !hasImages
? singleToolCard.outputText?.trim()
? "Tool output"
: "Tool call"
: "Tool output" ;
const hasActions = canCopyMarkdown || canExpand;
return html`
<div class ="${bubbleClasses}" >
${renderReplyPill(normalizedMessage.replyTarget)}
${hasActions
? html`<div class ="chat-bubble-actions" >
${canExpand ? renderExpandButton(markdown!, onOpenSidebar!) : nothing}
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
</div>`
: nothing}
${isToolMessage
? html`
<div
class ="chat-tool-msg-collapse chat-tool-msg-collapse--manual ${toolMessageExpanded
? "is-open"
: "" }"
>
<button
class ="chat-tool-msg-summary"
type="button"
aria-expanded=${String(toolMessageExpanded)}
@click=${() => opts.onToggleToolMessageExpanded?.(toolMessageDisclosureId)}
>
<span class ="chat-tool-msg-summary__icon" >${icons.zap}</span>
<span class ="chat-tool-msg-summary__label" >${toolMessageLabel}</span>
${toolSummaryLabel
? html`<span class ="chat-tool-msg-summary__names" >${toolSummaryLabel}</span>`
: toolPreview
? html`<span class ="chat-tool-msg-summary__preview" >${toolPreview}</span>`
: nothing}
</button>
${toolMessageExpanded
? html`
<div class ="chat-tool-msg-body" >
${renderMessageImages(images, imageRenderOptions)}
${renderAssistantAttachments(
assistantAttachments,
opts.localMediaPreviewRoots ?? [],
opts.basePath,
opts.assistantAttachmentAuthToken,
opts.onRequestUpdate,
)}
${reasoningMarkdown
? html`<div class ="chat-thinking" >
${unsafeHTML(toSanitizedMarkdownHtml(reasoningMarkdown))}
</div>`
: nothing}
${jsonResult
? html`<details
class ="chat-json-collapse"
?open=${Boolean (opts.autoExpandToolCalls)}
>
<summary class ="chat-json-summary" >
<span class ="chat-json-badge" >JSON</span>
<span class ="chat-json-label"
>${jsonSummaryLabel(jsonResult.parsed)}</span
>
</summary>
<pre class ="chat-json-content" ><code>${jsonResult.pretty}</code></pre>
</details>`
: markdown
? html`<div class ="chat-text" dir="${detectTextDirection(markdown)}" >
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
</div>`
: nothing}
${hasToolCards
? singleToolCard && !markdown && !hasImages
? renderExpandedToolCardContent(
singleToolCard,
onOpenSidebar,
opts.canvasHostUrl,
opts.embedSandboxMode ?? "scripts" ,
opts.allowExternalEmbedUrls ?? false ,
)
: renderInlineToolCards(toolCards, {
messageKey,
onOpenSidebar,
isToolExpanded: opts.isToolExpanded,
onToggleToolExpanded: opts.onToggleToolExpanded,
canvasHostUrl: opts.canvasHostUrl,
embedSandboxMode: opts.embedSandboxMode ?? "scripts" ,
allowExternalEmbedUrls: opts.allowExternalEmbedUrls ?? false ,
})
: nothing}
</div>
`
: nothing}
</div>
`
: html`
${renderMessageImages(images, imageRenderOptions)}
${renderAssistantAttachments(
assistantAttachments,
opts.localMediaPreviewRoots ?? [],
opts.basePath,
opts.assistantAttachmentAuthToken,
opts.onRequestUpdate,
)}
${reasoningMarkdown
? html`<div class ="chat-thinking" >
${unsafeHTML(toSanitizedMarkdownHtml(reasoningMarkdown))}
</div>`
: nothing}
${normalizedRole === "assistant" && assistantViewBlocks.length > 0
? html`${assistantViewBlocks.map(
(block) => html`${renderToolPreview(block.preview, "chat_message" , {
onOpenSidebar,
rawText: block.rawText ?? null ,
canvasHostUrl: opts.canvasHostUrl,
embedSandboxMode: opts.embedSandboxMode ?? "scripts" ,
})}
${block.rawText ? renderRawOutputToggle(block.rawText) : nothing}`,
)}`
: nothing}
${jsonResult
? html`<details class ="chat-json-collapse" >
<summary class ="chat-json-summary" >
<span class ="chat-json-badge" >JSON</span>
<span class ="chat-json-label" >${jsonSummaryLabel(jsonResult.parsed)}</span>
</summary>
<pre class ="chat-json-content" ><code>${jsonResult.pretty}</code></pre>
</details>`
: markdown
? html`<div class ="chat-text" dir="${detectTextDirection(markdown)}" >
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
</div>`
: nothing}
${hasToolCards
? renderInlineToolCards(toolCards, {
messageKey,
onOpenSidebar,
isToolExpanded: opts.isToolExpanded,
onToggleToolExpanded: opts.onToggleToolExpanded,
canvasHostUrl: opts.canvasHostUrl,
embedSandboxMode: opts.embedSandboxMode ?? "scripts" ,
allowExternalEmbedUrls: opts.allowExternalEmbedUrls ?? false ,
})
: nothing}
`}
</div>
`;
}
Messung V0.5 in Prozent C=98 H=98 G=97
¤ Dauer der Verarbeitung: 0.25 Sekunden
(vorverarbeitet am 2026-06-10)
¤
*© Formatika GbR, Deutschland