|
|
|
|
Quelle grouped-render.ts
Sprache: JAVA
|
|
Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]
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>
`;
}
¤ Dauer der Verarbeitung: 0.27 Sekunden
(vorverarbeitet am 2026-04-27)
¤
*© Formatika GbR, Deutschland
|
|
2026-05-26
|
|
|
|
|