|
|
|
|
Quelle chat.ts
Sprache: JAVA
|
|
Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]
import { html, nothing, type TemplateResult } from "lit";
import { ref } from "lit/directives/ref.js";
import { repeat } from "lit/directives/repeat.js";
import type { CompactionStatus, FallbackStatus } from "../app-tool-stream.ts";
import {
CHAT_ATTACHMENT_ACCEPT,
isSupportedChatAttachmentMimeType,
} from "../chat/attachment-support.ts";
import { buildChatItems } from "../chat/build-chat-items.ts";
import { renderContextNotice } from "../chat/context-notice.ts";
import { DeletedMessages } from "../chat/deleted-messages.ts";
import { exportChatMarkdown } from "../chat/export.ts";
import {
renderMessageGroup,
renderReadingIndicatorGroup,
renderStreamingGroup,
resolveAssistantTextAvatar,
} from "../chat/grouped-render.ts";
import { InputHistory } from "../chat/input-history.ts";
import { PinnedMessages } from "../chat/pinned-messages.ts";
import { getPinnedMessageSummary } from "../chat/pinned-summary.ts";
import type { RealtimeTalkStatus } from "../chat/realtime-talk.ts";
import { renderChatRunControls } from "../chat/run-controls.ts";
import { getOrCreateSessionCacheValue } from "../chat/session-cache.ts";
import { renderSideResult } from "../chat/side-result-render.ts";
import type { ChatSideResult } from "../chat/side-result.ts";
import {
CATEGORY_LABELS,
SLASH_COMMANDS,
getHiddenCommandCount,
getSlashCommandCompletions,
type SlashCommandCategory,
type SlashCommandDef,
} from "../chat/slash-commands.ts";
import { isSttSupported, startStt, stopStt } from "../chat/speech.ts";
import { renderCompactionIndicator, renderFallbackIndicator } from "../chat/status-indicators.ts";
import { buildSidebarContent } from "../chat/tool-cards.ts";
import { getExpandedToolCards, syncToolCardExpansionState } from "../chat/tool-expansion-state.ts";
import type { EmbedSandboxMode } from "../embed-sandbox.ts";
import { icons } from "../icons.ts";
import type { SidebarContent } from "../sidebar-content.ts";
import { detectTextDirection } from "../text-direction.ts";
import type { SessionsListResult } from "../types.ts";
import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts";
import { resolveLocalUserName } from "../user-identity.ts";
import { agentLogoUrl, resolveChatAvatarRenderUrl } from "./agents-utils.ts";
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
import "../components/resizable-divider.ts";
export type ChatProps = {
sessionKey: string;
onSessionKeyChange: (next: string) => void;
thinkingLevel: string | null;
showThinking: boolean;
showToolCalls: boolean;
loading: boolean;
sending: boolean;
canAbort?: boolean;
compactionStatus?: CompactionStatus | null;
fallbackStatus?: FallbackStatus | null;
messages: unknown[];
sideResult?: ChatSideResult | null;
toolMessages: unknown[];
streamSegments: Array<{ text: string; ts: number }>;
stream: string | null;
streamStartedAt: number | null;
assistantAvatarUrl?: string | null;
draft: string;
queue: ChatQueueItem[];
realtimeTalkActive?: boolean;
realtimeTalkStatus?: RealtimeTalkStatus;
realtimeTalkDetail?: string | null;
realtimeTalkTranscript?: string | null;
connected: boolean;
canSend: boolean;
disabledReason: string | null;
error: string | null;
sessions: SessionsListResult | null;
focusMode: boolean;
sidebarOpen?: boolean;
sidebarContent?: SidebarContent | null;
sidebarError?: string | null;
splitRatio?: number;
canvasHostUrl?: string | null;
embedSandboxMode?: EmbedSandboxMode;
allowExternalEmbedUrls?: boolean;
assistantName: string;
assistantAvatar: string | null;
userName?: string | null;
userAvatar?: string | null;
localMediaPreviewRoots?: string[];
assistantAttachmentAuthToken?: string | null;
autoExpandToolCalls?: boolean;
attachments?: ChatAttachment[];
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
showNewMessages?: boolean;
onScrollToBottom?: () => void;
onRefresh: () => void;
onToggleFocusMode: () => void;
getDraft?: () => string;
onDraftChange: (next: string) => void;
onRequestUpdate?: () => void;
onSend: () => void;
onCompact?: () => void | Promise<void>;
onToggleRealtimeTalk?: () => void;
onAbort?: () => void;
onQueueRemove: (id: string) => void;
onQueueSteer?: (id: string) => void;
onDismissSideResult?: () => void;
onNewSession: () => void;
onClearHistory?: () => void;
agentsList: {
agents: Array<{ id: string; name?: string; identity?: { name?: string; avatarUrl?: string } }>;
defaultId?: string;
} | null;
currentAgentId: string;
onAgentChange: (agentId: string) => void;
onNavigateToAgent?: () => void;
onSessionSelect?: (sessionKey: string) => void;
onOpenSidebar?: (content: SidebarContent) => void;
onCloseSidebar?: () => void;
onSplitRatioChange?: (ratio: number) => void;
onChatScroll?: (event: Event) => void;
basePath?: string;
};
// Persistent instances keyed by session
const inputHistories = new Map<string, InputHistory>();
const pinnedMessagesMap = new Map<string, PinnedMessages>();
const deletedMessagesMap = new Map<string, DeletedMessages>();
function getInputHistory(sessionKey: string): InputHistory {
return getOrCreateSessionCacheValue(inputHistories, sessionKey, () => new InputHistory());
}
function getPinnedMessages(sessionKey: string): PinnedMessages {
return getOrCreateSessionCacheValue(
pinnedMessagesMap,
sessionKey,
() => new PinnedMessages(sessionKey),
);
}
function getDeletedMessages(sessionKey: string): DeletedMessages {
return getOrCreateSessionCacheValue(
deletedMessagesMap,
sessionKey,
() => new DeletedMessages(sessionKey),
);
}
interface ChatEphemeralState {
sttRecording: boolean;
sttInterimText: string;
slashMenuOpen: boolean;
slashMenuItems: SlashCommandDef[];
slashMenuIndex: number;
slashMenuMode: "command" | "args";
slashMenuCommand: SlashCommandDef | null;
slashMenuArgItems: string[];
slashMenuExpanded: boolean;
searchOpen: boolean;
searchQuery: string;
pinnedExpanded: boolean;
}
function createChatEphemeralState(): ChatEphemeralState {
return {
sttRecording: false,
sttInterimText: "",
slashMenuOpen: false,
slashMenuItems: [],
slashMenuIndex: 0,
slashMenuMode: "command",
slashMenuCommand: null,
slashMenuArgItems: [],
slashMenuExpanded: false,
searchOpen: false,
searchQuery: "",
pinnedExpanded: false,
};
}
const vs = createChatEphemeralState();
/**
* Reset chat view ephemeral state when navigating away.
* Stops STT recording and clears search/slash UI that should not survive navigation.
*/
export function resetChatViewState() {
if (vs.sttRecording) {
stopStt();
}
Object.assign(vs, createChatEphemeralState());
}
export const cleanupChatModuleState = resetChatViewState;
function adjustTextareaHeight(el: HTMLTextAreaElement) {
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 150)}px`;
}
function generateAttachmentId(): string {
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function handlePaste(e: ClipboardEvent, props: ChatProps) {
const items = e.clipboardData?.items;
if (!items || !props.onAttachmentsChange) {
return;
}
const imageItems: DataTransferItem[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith("image/")) {
imageItems.push(item);
}
}
if (imageItems.length === 0) {
return;
}
e.preventDefault();
for (const item of imageItems) {
const file = item.getAsFile();
if (!file) {
continue;
}
const reader = new FileReader();
reader.addEventListener("load", () => {
const dataUrl = reader.result as string;
const newAttachment: ChatAttachment = {
id: generateAttachmentId(),
dataUrl,
mimeType: file.type,
};
const current = props.attachments ?? [];
props.onAttachmentsChange?.([...current, newAttachment]);
});
reader.readAsDataURL(file);
}
}
function handleFileSelect(e: Event, props: ChatProps) {
const input = e.target as HTMLInputElement;
if (!input.files || !props.onAttachmentsChange) {
return;
}
const current = props.attachments ?? [];
const additions: ChatAttachment[] = [];
let pending = 0;
for (const file of input.files) {
if (!isSupportedChatAttachmentMimeType(file.type)) {
continue;
}
pending++;
const reader = new FileReader();
reader.addEventListener("load", () => {
additions.push({
id: generateAttachmentId(),
dataUrl: reader.result as string,
mimeType: file.type,
});
pending--;
if (pending === 0) {
props.onAttachmentsChange?.([...current, ...additions]);
}
});
reader.readAsDataURL(file);
}
input.value = "";
}
function handleDrop(e: DragEvent, props: ChatProps) {
e.preventDefault();
const files = e.dataTransfer?.files;
if (!files || !props.onAttachmentsChange) {
return;
}
const current = props.attachments ?? [];
const additions: ChatAttachment[] = [];
let pending = 0;
for (const file of files) {
if (!isSupportedChatAttachmentMimeType(file.type)) {
continue;
}
pending++;
const reader = new FileReader();
reader.addEventListener("load", () => {
additions.push({
id: generateAttachmentId(),
dataUrl: reader.result as string,
mimeType: file.type,
});
pending--;
if (pending === 0) {
props.onAttachmentsChange?.([...current, ...additions]);
}
});
reader.readAsDataURL(file);
}
}
function renderAttachmentPreview(props: ChatProps): TemplateResult | typeof nothing {
const attachments = props.attachments ?? [];
if (attachments.length === 0) {
return nothing;
}
return html`
<div class="chat-attachments-preview">
${attachments.map(
(att) => html`
<div class="chat-attachment-thumb">
<img src=${att.dataUrl} alt="Attachment preview" />
<button
class="chat-attachment-remove"
type="button"
aria-label="Remove attachment"
@click=${() => {
const next = (props.attachments ?? []).filter((a) => a.id !== att.id);
props.onAttachmentsChange?.(next);
}}
>
×
</button>
</div>
`,
)}
</div>
`;
}
function resetSlashMenuState(): void {
vs.slashMenuMode = "command";
vs.slashMenuCommand = null;
vs.slashMenuArgItems = [];
vs.slashMenuItems = [];
vs.slashMenuExpanded = false;
}
function updateSlashMenu(value: string, requestUpdate: () => void): void {
// Arg mode: /command <partial-arg>
const argMatch = value.match(/^\/(\S+)\s(.*)$/);
if (argMatch) {
const cmdName = argMatch[1].toLowerCase();
const argFilter = argMatch[2].toLowerCase();
const cmd = SLASH_COMMANDS.find((c) => c.name === cmdName);
if (cmd?.argOptions?.length) {
const filtered = argFilter
? cmd.argOptions.filter((opt) => opt.toLowerCase().startsWith(argFilter))
: cmd.argOptions;
if (filtered.length > 0) {
vs.slashMenuMode = "args";
vs.slashMenuCommand = cmd;
vs.slashMenuArgItems = filtered;
vs.slashMenuOpen = true;
vs.slashMenuIndex = 0;
vs.slashMenuItems = [];
requestUpdate();
return;
}
}
vs.slashMenuOpen = false;
resetSlashMenuState();
requestUpdate();
return;
}
// Command mode: /partial-command
const match = value.match(/^\/(\S*)$/);
if (match) {
const items = getSlashCommandCompletions(match[1], { showAll: vs.slashMenuExpanded });
vs.slashMenuItems = items;
vs.slashMenuOpen = items.length > 0;
vs.slashMenuIndex = 0;
vs.slashMenuMode = "command";
vs.slashMenuCommand = null;
vs.slashMenuArgItems = [];
} else {
vs.slashMenuOpen = false;
resetSlashMenuState();
}
requestUpdate();
}
function selectSlashCommand(
cmd: SlashCommandDef,
props: ChatProps,
requestUpdate: () => void,
): void {
// Transition to arg picker when the command has fixed options
if (cmd.argOptions?.length) {
props.onDraftChange(`/${cmd.name} `);
vs.slashMenuMode = "args";
vs.slashMenuCommand = cmd;
vs.slashMenuArgItems = cmd.argOptions;
vs.slashMenuOpen = true;
vs.slashMenuIndex = 0;
vs.slashMenuItems = [];
requestUpdate();
return;
}
vs.slashMenuOpen = false;
resetSlashMenuState();
if (cmd.executeLocal && !cmd.args) {
props.onDraftChange(`/${cmd.name}`);
requestUpdate();
props.onSend();
} else {
props.onDraftChange(`/${cmd.name} `);
requestUpdate();
}
}
function tabCompleteSlashCommand(
cmd: SlashCommandDef,
props: ChatProps,
requestUpdate: () => void,
): void {
// Tab: fill in the command text without executing
if (cmd.argOptions?.length) {
props.onDraftChange(`/${cmd.name} `);
vs.slashMenuMode = "args";
vs.slashMenuCommand = cmd;
vs.slashMenuArgItems = cmd.argOptions;
vs.slashMenuOpen = true;
vs.slashMenuIndex = 0;
vs.slashMenuItems = [];
requestUpdate();
return;
}
vs.slashMenuOpen = false;
resetSlashMenuState();
props.onDraftChange(cmd.args ? `/${cmd.name} ` : `/${cmd.name}`);
requestUpdate();
}
function selectSlashArg(
arg: string,
props: ChatProps,
requestUpdate: () => void,
execute: boolean,
): void {
const cmdName = vs.slashMenuCommand?.name ?? "";
vs.slashMenuOpen = false;
resetSlashMenuState();
props.onDraftChange(`/${cmdName} ${arg}`);
requestUpdate();
if (execute) {
props.onSend();
}
}
function tokenEstimate(draft: string): string | null {
if (draft.length < 100) {
return null;
}
return `~${Math.ceil(draft.length / 4)} tokens`;
}
/**
* Export chat markdown - delegates to shared utility.
*/
function exportMarkdown(props: ChatProps): void {
exportChatMarkdown(props.messages, props.assistantName);
}
const WELCOME_SUGGESTIONS = [
"What can you do?",
"Summarize my recent sessions",
"Help me configure a channel",
"Check system health",
];
function renderWelcomeState(props: ChatProps): TemplateResult {
const name = props.assistantName || "Assistant";
const avatar = resolveAssistantAvatarUrl(props);
const avatarText = avatar ? null : resolveAssistantTextAvatar(props.assistantAvatar);
const logoUrl = agentLogoUrl(props.basePath ?? "");
return html`
<div class="agent-chat__welcome" style="--agent-color: var(--accent)">
<div class="agent-chat__welcome-glow"></div>
${avatar
? html`<img
src=${avatar}
alt=${name}
style="width:56px; height:56px; border-radius:50%; object-fit:cover;"
/>`
: avatarText
? html`<div class="agent-chat__avatar agent-chat__avatar--text" aria-label=${name}>
${avatarText}
</div>`
: html`<div class="agent-chat__avatar agent-chat__avatar--logo">
<img src=${logoUrl} alt="OpenClaw" />
</div>`}
<h2>${name}</h2>
<div class="agent-chat__badges">
<span class="agent-chat__badge"><img src=${logoUrl} alt="" /> Ready to chat</span>
</div>
<p class="agent-chat__hint">Type a message below · <kbd>/</kbd> for commands</p>
<div class="agent-chat__suggestions">
${WELCOME_SUGGESTIONS.map(
(text) => html`
<button
type="button"
class="agent-chat__suggestion"
@click=${() => {
props.onDraftChange(text);
props.onSend();
}}
>
${text}
</button>
`,
)}
</div>
</div>
`;
}
function resolveAssistantAvatarUrl(
props: Pick<ChatProps, "assistantAvatar" | "assistantAvatarUrl">,
): string | null {
return resolveChatAvatarRenderUrl(props.assistantAvatarUrl, {
identity: {
avatar: props.assistantAvatar ?? undefined,
avatarUrl: props.assistantAvatarUrl ?? undefined,
},
});
}
function resolveAssistantDisplayAvatar(
props: Pick<ChatProps, "assistantAvatar" | "assistantAvatarUrl">,
): string | null {
return resolveAssistantAvatarUrl(props) ?? resolveAssistantTextAvatar(props.assistantAvatar);
}
function renderSearchBar(requestUpdate: () => void): TemplateResult | typeof nothing {
if (!vs.searchOpen) {
return nothing;
}
return html`
<div class="agent-chat__search-bar">
${icons.search}
<input
type="text"
placeholder="Search messages..."
aria-label="Search messages"
.value=${vs.searchQuery}
@input=${(e: Event) => {
vs.searchQuery = (e.target as HTMLInputElement).value;
requestUpdate();
}}
/>
<button
class="btn btn--ghost"
aria-label="Close search"
@click=${() => {
vs.searchOpen = false;
vs.searchQuery = "";
requestUpdate();
}}
>
${icons.x}
</button>
</div>
`;
}
function renderPinnedSection(
props: ChatProps,
pinned: PinnedMessages,
requestUpdate: () => void,
): TemplateResult | typeof nothing {
const userRoleLabel = resolveLocalUserName({
name: props.userName ?? null,
avatar: props.userAvatar ?? null,
});
const messages = Array.isArray(props.messages) ? props.messages : [];
const entries: Array<{ index: number; text: string; role: string }> = [];
for (const idx of pinned.indices) {
const msg = messages[idx] as Record<string, unknown> | undefined;
if (!msg) {
continue;
}
const text = getPinnedMessageSummary(msg);
const role = typeof msg.role === "string" ? msg.role : "unknown";
entries.push({ index: idx, text, role });
}
if (entries.length === 0) {
return nothing;
}
return html`
<div class="agent-chat__pinned">
<button
class="agent-chat__pinned-toggle"
aria-expanded=${vs.pinnedExpanded}
@click=${() => {
vs.pinnedExpanded = !vs.pinnedExpanded;
requestUpdate();
}}
>
${icons.bookmark} ${entries.length} pinned
<span class="collapse-chevron ${vs.pinnedExpanded ? "" : "collapse-chevron--collapsed"}"
>${icons.chevronDown}</span
>
</button>
${vs.pinnedExpanded
? html`
<div class="agent-chat__pinned-list">
${entries.map(
({ index, text, role }) => html`
<div class="agent-chat__pinned-item">
<span class="agent-chat__pinned-role"
>${role === "user" ? userRoleLabel : "Assistant"}</span
>
<span class="agent-chat__pinned-text"
>${text.slice(0, 100)}${text.length > 100 ? "..." : ""}</span
>
<button
class="btn btn--ghost"
@click=${() => {
pinned.unpin(index);
requestUpdate();
}}
title="Unpin"
>
${icons.x}
</button>
</div>
`,
)}
</div>
`
: nothing}
</div>
`;
}
function renderSlashMenu(
requestUpdate: () => void,
props: ChatProps,
): TemplateResult | typeof nothing {
if (!vs.slashMenuOpen) {
return nothing;
}
// Arg-picker mode: show options for the selected command
if (vs.slashMenuMode === "args" && vs.slashMenuCommand && vs.slashMenuArgItems.length > 0) {
return html`
<div class="slash-menu" role="listbox" aria-label="Command arguments">
<div class="slash-menu-group">
<div class="slash-menu-group__label">
/${vs.slashMenuCommand.name} ${vs.slashMenuCommand.description}
</div>
${vs.slashMenuArgItems.map(
(arg, i) => html`
<div
class="slash-menu-item ${i === vs.slashMenuIndex ? "slash-menu-item--active" : ""}"
role="option"
aria-selected=${i === vs.slashMenuIndex}
@click=${() => selectSlashArg(arg, props, requestUpdate, true)}
@mouseenter=${() => {
vs.slashMenuIndex = i;
requestUpdate();
}}
>
${vs.slashMenuCommand?.icon
? html`<span class="slash-menu-icon">${icons[vs.slashMenuCommand.icon]}</span>`
: nothing}
<span class="slash-menu-name">${arg}</span>
<span class="slash-menu-desc">/${vs.slashMenuCommand?.name} ${arg}</span>
</div>
`,
)}
</div>
<div class="slash-menu-footer">
<kbd>↑↓</kbd> navigate <kbd>Tab</kbd> fill <kbd>Enter</kbd> run <kbd>Esc</kbd> close
</div>
</div>
`;
}
// Command mode: show grouped commands
if (vs.slashMenuItems.length === 0) {
return nothing;
}
const grouped = new Map<
SlashCommandCategory,
Array<{ cmd: SlashCommandDef; globalIdx: number }>
>();
for (let i = 0; i < vs.slashMenuItems.length; i++) {
const cmd = vs.slashMenuItems[i];
const cat = cmd.category ?? "session";
let list = grouped.get(cat);
if (!list) {
list = [];
grouped.set(cat, list);
}
list.push({ cmd, globalIdx: i });
}
const sections: TemplateResult[] = [];
for (const [cat, entries] of grouped) {
sections.push(html`
<div class="slash-menu-group">
<div class="slash-menu-group__label">${CATEGORY_LABELS[cat]}</div>
${entries.map(
({ cmd, globalIdx }) => html`
<div
class="slash-menu-item ${globalIdx === vs.slashMenuIndex
? "slash-menu-item--active"
: ""}"
role="option"
aria-selected=${globalIdx === vs.slashMenuIndex}
@click=${() => selectSlashCommand(cmd, props, requestUpdate)}
@mouseenter=${() => {
vs.slashMenuIndex = globalIdx;
requestUpdate();
}}
>
${cmd.icon ? html`<span class="slash-menu-icon">${icons[cmd.icon]}</span>` : nothing}
<span class="slash-menu-name">/${cmd.name}</span>
${cmd.args ? html`<span class="slash-menu-args">${cmd.args}</span>` : nothing}
<span class="slash-menu-desc">${cmd.description}</span>
${cmd.argOptions?.length
? html`<span class="slash-menu-badge">${cmd.argOptions.length} options</span>`
: cmd.executeLocal && !cmd.args
? html` <span class="slash-menu-badge">instant</span> `
: nothing}
</div>
`,
)}
</div>
`);
}
const hiddenCount = vs.slashMenuExpanded ? 0 : getHiddenCommandCount();
return html`
<div class="slash-menu" role="listbox" aria-label="Slash commands">
${sections}
${hiddenCount > 0
? html`<button
class="slash-menu-show-more"
@click=${(e: Event) => {
e.preventDefault();
e.stopPropagation();
vs.slashMenuExpanded = true;
updateSlashMenu(props.draft, requestUpdate);
}}
>
Show ${hiddenCount} more command${hiddenCount !== 1 ? "s" : ""}
</button>`
: nothing}
<div class="slash-menu-footer">
<kbd>↑↓</kbd> navigate <kbd>Tab</kbd> fill <kbd>Enter</kbd> select <kbd>Esc</kbd> close
</div>
</div>
`;
}
export function renderChat(props: ChatProps) {
const canCompose = props.connected;
const isBusy = props.sending || props.stream !== null;
const canAbort = Boolean(props.canAbort && props.onAbort);
const compactBusy =
props.compactionStatus?.phase === "active" || props.compactionStatus?.phase === "retrying";
const activeSession = props.sessions?.sessions?.find((row) => row.key === props.sessionKey);
const reasoningLevel = activeSession?.reasoningLevel ?? "off";
const showReasoning = props.showThinking && reasoningLevel !== "off";
const assistantIdentity = {
name: props.assistantName,
avatar: resolveAssistantDisplayAvatar(props),
};
const pinned = getPinnedMessages(props.sessionKey);
const deleted = getDeletedMessages(props.sessionKey);
const inputHistory = getInputHistory(props.sessionKey);
const hasAttachments = (props.attachments?.length ?? 0) > 0;
const tokens = tokenEstimate(props.draft);
const placeholder = props.connected
? hasAttachments
? "Add a message or paste more images..."
: `Message ${props.assistantName || "agent"} (Enter to send)`
: "Connect to the gateway to start chatting...";
const requestUpdate = props.onRequestUpdate ?? (() => {});
const getDraft = props.getDraft ?? (() => props.draft);
const splitRatio = props.splitRatio ?? 0.6;
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
const handleCodeBlockCopy = (e: Event) => {
const btn = (e.target as HTMLElement).closest(".code-block-copy");
if (!btn) {
return;
}
const code = (btn as HTMLElement).dataset.code ?? "";
navigator.clipboard.writeText(code).then(
() => {
btn.classList.add("copied");
setTimeout(() => btn.classList.remove("copied"), 1500);
},
() => {},
);
};
const chatItems = buildChatItems({
sessionKey: props.sessionKey,
messages: props.messages,
toolMessages: props.toolMessages,
streamSegments: props.streamSegments,
stream: props.stream,
streamStartedAt: props.streamStartedAt,
showToolCalls: props.showToolCalls,
searchOpen: vs.searchOpen,
searchQuery: vs.searchQuery,
});
syncToolCardExpansionState(props.sessionKey, chatItems, Boolean(props.autoExpandToolCalls));
const expandedToolCards = getExpandedToolCards(props.sessionKey);
const toggleToolCardExpanded = (toolCardId: string) => {
expandedToolCards.set(toolCardId, !expandedToolCards.get(toolCardId));
requestUpdate();
};
const isEmpty = chatItems.length === 0 && !props.loading;
const thread = html`
<div
class="chat-thread"
role="log"
aria-live="polite"
@scroll=${props.onChatScroll}
@click=${handleCodeBlockCopy}
>
<div class="chat-thread-inner">
${props.loading
? html`
<div class="chat-loading-skeleton" aria-label="Loading chat">
<div class="chat-line assistant">
<div class="chat-msg">
<div class="chat-bubble">
<div
class="skeleton skeleton-line skeleton-line--long"
style="margin-bottom: 8px"
></div>
<div
class="skeleton skeleton-line skeleton-line--medium"
style="margin-bottom: 8px"
></div>
<div class="skeleton skeleton-line skeleton-line--short"></div>
</div>
</div>
</div>
<div class="chat-line user" style="margin-top: 12px">
<div class="chat-msg">
<div class="chat-bubble">
<div class="skeleton skeleton-line skeleton-line--medium"></div>
</div>
</div>
</div>
<div class="chat-line assistant" style="margin-top: 12px">
<div class="chat-msg">
<div class="chat-bubble">
<div
class="skeleton skeleton-line skeleton-line--long"
style="margin-bottom: 8px"
></div>
<div class="skeleton skeleton-line skeleton-line--short"></div>
</div>
</div>
</div>
</div>
`
: nothing}
${isEmpty && !vs.searchOpen ? renderWelcomeState(props) : nothing}
${isEmpty && vs.searchOpen
? html` <div class="agent-chat__empty">No matching messages</div> `
: nothing}
${repeat(
chatItems,
(item) => item.key,
(item) => {
if (item.kind === "divider") {
return html`
<div class="chat-divider" role="separator" data-ts=${String(item.timestamp)}>
<span class="chat-divider__line"></span>
<span class="chat-divider__label">${item.label}</span>
<span class="chat-divider__line"></span>
</div>
`;
}
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(
assistantIdentity,
props.basePath,
props.assistantAttachmentAuthToken ?? null,
);
}
if (item.kind === "stream") {
return renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
assistantIdentity,
props.basePath,
props.assistantAttachmentAuthToken ?? null,
);
}
if (item.kind === "group") {
if (deleted.has(item.key)) {
return nothing;
}
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
showToolCalls: props.showToolCalls,
autoExpandToolCalls: Boolean(props.autoExpandToolCalls),
isToolMessageExpanded: (messageId: string) =>
expandedToolCards.get(messageId) ?? false,
onToggleToolMessageExpanded: (messageId: string) => {
expandedToolCards.set(messageId, !expandedToolCards.get(messageId));
requestUpdate();
},
isToolExpanded: (toolCardId: string) => expandedToolCards.get(toolCardId) ?? false,
onToggleToolExpanded: toggleToolCardExpanded,
onRequestUpdate: requestUpdate,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
userName: props.userName ?? null,
userAvatar: props.userAvatar ?? null,
basePath: props.basePath,
localMediaPreviewRoots: props.localMediaPreviewRoots ?? [],
assistantAttachmentAuthToken: props.assistantAttachmentAuthToken ?? null,
canvasHostUrl: props.canvasHostUrl,
embedSandboxMode: props.embedSandboxMode ?? "scripts",
allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false,
contextWindow:
activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null,
onDelete: () => {
deleted.delete(item.key);
requestUpdate();
},
});
}
return nothing;
},
)}
</div>
</div>
`;
const handleKeyDown = (e: KeyboardEvent) => {
// Slash menu navigation — arg mode
if (vs.slashMenuOpen && vs.slashMenuMode === "args" && vs.slashMenuArgItems.length > 0) {
const len = vs.slashMenuArgItems.length;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
vs.slashMenuIndex = (vs.slashMenuIndex + 1) % len;
requestUpdate();
return;
case "ArrowUp":
e.preventDefault();
vs.slashMenuIndex = (vs.slashMenuIndex - 1 + len) % len;
requestUpdate();
return;
case "Tab":
e.preventDefault();
selectSlashArg(vs.slashMenuArgItems[vs.slashMenuIndex], props, requestUpdate, false);
return;
case "Enter":
e.preventDefault();
selectSlashArg(vs.slashMenuArgItems[vs.slashMenuIndex], props, requestUpdate, true);
return;
case "Escape":
e.preventDefault();
vs.slashMenuOpen = false;
resetSlashMenuState();
requestUpdate();
return;
}
}
// Slash menu navigation — command mode
if (vs.slashMenuOpen && vs.slashMenuItems.length > 0) {
const len = vs.slashMenuItems.length;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
vs.slashMenuIndex = (vs.slashMenuIndex + 1) % len;
requestUpdate();
return;
case "ArrowUp":
e.preventDefault();
vs.slashMenuIndex = (vs.slashMenuIndex - 1 + len) % len;
requestUpdate();
return;
case "Tab":
e.preventDefault();
tabCompleteSlashCommand(vs.slashMenuItems[vs.slashMenuIndex], props, requestUpdate);
return;
case "Enter":
e.preventDefault();
selectSlashCommand(vs.slashMenuItems[vs.slashMenuIndex], props, requestUpdate);
return;
case "Escape":
e.preventDefault();
vs.slashMenuOpen = false;
resetSlashMenuState();
requestUpdate();
return;
}
}
if (e.key === "Escape" && props.sideResult && !vs.searchOpen) {
e.preventDefault();
props.onDismissSideResult?.();
return;
}
// Input history (only when input is empty)
if (!props.draft.trim()) {
if (e.key === "ArrowUp") {
const prev = inputHistory.up();
if (prev !== null) {
e.preventDefault();
props.onDraftChange(prev);
}
return;
}
if (e.key === "ArrowDown") {
const next = inputHistory.down();
e.preventDefault();
props.onDraftChange(next ?? "");
return;
}
}
// Cmd+F for search
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === "f") {
e.preventDefault();
vs.searchOpen = !vs.searchOpen;
if (!vs.searchOpen) {
vs.searchQuery = "";
}
requestUpdate();
return;
}
// Send on Enter (without shift)
if (e.key === "Enter" && !e.shiftKey) {
if (e.isComposing || e.keyCode === 229) {
return;
}
if (!props.connected) {
return;
}
e.preventDefault();
if (canCompose) {
if (props.draft.trim()) {
inputHistory.push(props.draft);
}
props.onSend();
}
}
};
const handleInput = (e: Event) => {
const target = e.target as HTMLTextAreaElement;
adjustTextareaHeight(target);
updateSlashMenu(target.value, requestUpdate);
inputHistory.reset();
props.onDraftChange(target.value);
};
return html`
<section
class="card chat"
@drop=${(e: DragEvent) => handleDrop(e, props)}
@dragover=${(e: DragEvent) => e.preventDefault()}
>
${props.disabledReason ? html`<div class="callout">${props.disabledReason}</div>` : nothing}
${props.error ? html`<div class="callout danger">${props.error}</div>` : nothing}
${props.focusMode
? html`
<button
class="chat-focus-exit"
type="button"
@click=${props.onToggleFocusMode}
aria-label="Exit focus mode"
title="Exit focus mode"
>
${icons.x}
</button>
`
: nothing}
${renderSearchBar(requestUpdate)} ${renderPinnedSection(props, pinned, requestUpdate)}
<div class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}">
<div
class="chat-main"
style="flex: ${sidebarOpen ? `0 0 ${splitRatio * 100}%` : "1 1 100%"}"
>
${thread}
</div>
${sidebarOpen
? html`
<resizable-divider
.splitRatio=${splitRatio}
@resize=${(e: CustomEvent) => props.onSplitRatioChange?.(e.detail.splitRatio)}
></resizable-divider>
<div class="chat-sidebar">
${renderMarkdownSidebar({
content: props.sidebarContent ?? null,
error: props.sidebarError ?? null,
canvasHostUrl: props.canvasHostUrl,
embedSandboxMode: props.embedSandboxMode ?? "scripts",
allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false,
onClose: props.onCloseSidebar!,
onViewRawText: () => {
if (!props.sidebarContent || !props.onOpenSidebar) {
return;
}
if (props.sidebarContent.kind === "markdown") {
props.onOpenSidebar(
buildSidebarContent(`\`\`\`\n${props.sidebarContent.content}\n\`\`\``),
);
return;
}
if (props.sidebarContent.rawText?.trim()) {
props.onOpenSidebar(
buildSidebarContent(`\`\`\`json\n${props.sidebarContent.rawText}\n\`\`\``),
);
}
},
})}
</div>
`
: nothing}
</div>
${props.queue.length
? html`
<div class="chat-queue" role="status" aria-live="polite">
<div class="chat-queue__title">Queued (${props.queue.length})</div>
<div class="chat-queue__list">
${props.queue.map(
(item) => html`
<div
class="chat-queue__item ${item.kind === "steered"
? "chat-queue__item--steered"
: ""}"
>
<div class="chat-queue__main">
${item.kind === "steered"
? html`<span class="chat-queue__badge">Steered</span>`
: nothing}
<div class="chat-queue__text">
${item.text ||
(item.attachments?.length ? `Image (${item.attachments.length})` : "")}
</div>
</div>
<div class="chat-queue__actions">
${props.canAbort &&
props.onQueueSteer &&
item.kind !== "steered" &&
!item.localCommandName
? html`
<button
class="btn chat-queue__steer"
type="button"
title="Steer now"
aria-label="Steer queued message"
@click=${() => props.onQueueSteer?.(item.id)}
>
${icons.cornerDownRight}
<span>Steer</span>
</button>
`
: nothing}
<button
class="btn chat-queue__remove"
type="button"
aria-label="Remove queued message"
@click=${() => props.onQueueRemove(item.id)}
>
${icons.x}
</button>
</div>
</div>
`,
)}
</div>
</div>
`
: nothing}
${renderSideResult(props.sideResult, props.onDismissSideResult)}
${renderFallbackIndicator(props.fallbackStatus)}
${renderCompactionIndicator(props.compactionStatus)}
${renderContextNotice(activeSession, props.sessions?.defaults?.contextTokens ?? null, {
compactBusy,
compactDisabled: !props.connected || isBusy || Boolean(props.canAbort),
onCompact: props.onCompact,
})}
${props.showNewMessages
? html`
<button class="chat-new-messages" type="button" @click=${props.onScrollToBottom}>
${icons.arrowDown} New messages
</button>
`
: nothing}
<!-- Input bar -->
<div class="agent-chat__input">
${renderSlashMenu(requestUpdate, props)} ${renderAttachmentPreview(props)}
<input
type="file"
accept=${CHAT_ATTACHMENT_ACCEPT}
multiple
class="agent-chat__file-input"
@change=${(e: Event) => handleFileSelect(e, props)}
/>
${vs.sttRecording && vs.sttInterimText
? html`<div class="agent-chat__stt-interim">${vs.sttInterimText}</div>`
: nothing}
${props.realtimeTalkActive || props.realtimeTalkDetail || props.realtimeTalkTranscript
? html`
<div class="agent-chat__stt-interim agent-chat__talk-status">
${props.realtimeTalkDetail ??
props.realtimeTalkTranscript ??
(props.realtimeTalkStatus === "thinking"
? "Asking OpenClaw..."
: props.realtimeTalkStatus === "connecting"
? "Connecting Talk..."
: "Talk live")}
</div>
`
: nothing}
<textarea
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
.value=${props.draft}
dir=${detectTextDirection(props.draft)}
?disabled=${!props.connected}
@keydown=${handleKeyDown}
@input=${handleInput}
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
placeholder=${vs.sttRecording ? "Listening..." : placeholder}
rows="1"
></textarea>
<div class="agent-chat__toolbar">
<div class="agent-chat__toolbar-left">
<button
class="agent-chat__input-btn"
@click=${() => {
document.querySelector<HTMLInputElement>(".agent-chat__file-input")?.click();
}}
title="Attach file"
aria-label="Attach file"
?disabled=${!props.connected}
>
${icons.paperclip}
</button>
${isSttSupported()
? html`
<button
class="agent-chat__input-btn ${vs.sttRecording
? "agent-chat__input-btn--recording"
: ""}"
@click=${() => {
if (vs.sttRecording) {
stopStt();
vs.sttRecording = false;
vs.sttInterimText = "";
requestUpdate();
} else {
const started = startStt({
onTranscript: (text, isFinal) => {
if (isFinal) {
const current = getDraft();
const sep = current && !current.endsWith(" ") ? " " : "";
props.onDraftChange(current + sep + text);
vs.sttInterimText = "";
} else {
vs.sttInterimText = text;
}
requestUpdate();
},
onStart: () => {
vs.sttRecording = true;
requestUpdate();
},
onEnd: () => {
vs.sttRecording = false;
vs.sttInterimText = "";
requestUpdate();
},
onError: () => {
vs.sttRecording = false;
vs.sttInterimText = "";
requestUpdate();
},
});
if (started) {
vs.sttRecording = true;
requestUpdate();
}
}
}}
title=${vs.sttRecording ? "Stop recording" : "Voice input"}
aria-label=${vs.sttRecording ? "Stop recording" : "Voice input"}
?disabled=${!props.connected}
>
${vs.sttRecording ? icons.micOff : icons.mic}
</button>
`
: nothing}
${props.onToggleRealtimeTalk
? html`
<button
class="agent-chat__input-btn ${props.realtimeTalkActive
? "agent-chat__input-btn--talk"
: ""}"
@click=${props.onToggleRealtimeTalk}
title=${props.realtimeTalkActive ? "Stop Talk" : "Start Talk"}
aria-label=${props.realtimeTalkActive ? "Stop Talk" : "Start Talk"}
?disabled=${!props.connected}
>
${props.realtimeTalkActive ? icons.volume2 : icons.radio}
</button>
`
: nothing}
${tokens ? html`<span class="agent-chat__token-count">${tokens}</span>` : nothing}
</div>
${renderChatRunControls({
canAbort,
connected: props.connected,
draft: props.draft,
hasMessages: props.messages.length > 0,
isBusy,
sending: props.sending,
onAbort: props.onAbort,
onExport: () => exportMarkdown(props),
onNewSession: props.onNewSession,
onSend: props.onSend,
onStoreDraft: (draft) => inputHistory.push(draft),
})}
</div>
</div>
</section>
`;
}
¤ Dauer der Verarbeitung: 0.5 Sekunden
(vorverarbeitet am 2026-04-27)
¤
*© Formatika GbR, Deutschland
|
|
2026-05-26
|
|
|
|
|