feat: add agent avatar support (#1329) (thanks @dlauer)

This commit is contained in:
Peter Steinberger
2026-01-22 03:54:31 +00:00
parent 7edc464b82
commit a2bea8e366
25 changed files with 547 additions and 84 deletions

View File

@@ -91,6 +91,7 @@
/* Image avatar support */
img.chat-avatar {
display: block;
object-fit: cover;
object-position: center;
}

View File

@@ -4,6 +4,9 @@ import { generateUUID } from "./uuid";
import { resetToolStream } from "./app-tool-stream";
import { scheduleChatScroll } from "./app-scroll";
import { setLastActiveSessionKey } from "./app-settings";
import { normalizeBasePath } from "./navigation";
import type { GatewayHelloOk } from "./gateway";
import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js";
import type { ClawdbotApp } from "./app";
type ChatHost = {
@@ -13,6 +16,9 @@ type ChatHost = {
chatRunId: string | null;
chatSending: boolean;
sessionKey: string;
basePath: string;
hello: GatewayHelloOk | null;
chatAvatarUrl: string | null;
};
export function isChatBusy(host: ChatHost) {
@@ -124,8 +130,53 @@ export async function refreshChat(host: ChatHost) {
await Promise.all([
loadChatHistory(host as unknown as ClawdbotApp),
loadSessions(host as unknown as ClawdbotApp),
refreshChatAvatar(host),
]);
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0], true);
}
export const flushChatQueueForEvent = flushChatQueue;
type SessionDefaultsSnapshot = {
defaultAgentId?: string;
};
function resolveAgentIdForSession(host: ChatHost): string | null {
const parsed = parseAgentSessionKey(host.sessionKey);
if (parsed?.agentId) return parsed.agentId;
const snapshot = host.hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined;
const fallback = snapshot?.sessionDefaults?.defaultAgentId?.trim();
return fallback || "main";
}
function buildAvatarMetaUrl(basePath: string, agentId: string): string {
const base = normalizeBasePath(basePath);
const encoded = encodeURIComponent(agentId);
return base ? `${base}/avatar/${encoded}?meta=1` : `/avatar/${encoded}?meta=1`;
}
export async function refreshChatAvatar(host: ChatHost) {
if (!host.connected) {
host.chatAvatarUrl = null;
return;
}
const agentId = resolveAgentIdForSession(host);
if (!agentId) {
host.chatAvatarUrl = null;
return;
}
host.chatAvatarUrl = null;
const url = buildAvatarMetaUrl(host.basePath, agentId);
try {
const res = await fetch(url, { method: "GET" });
if (!res.ok) {
host.chatAvatarUrl = null;
return;
}
const data = (await res.json()) as { avatarUrl?: unknown };
const avatarUrl = typeof data.avatarUrl === "string" ? data.avatarUrl.trim() : "";
host.chatAvatarUrl = avatarUrl || null;
} catch {
host.chatAvatarUrl = null;
}
}

View File

@@ -28,6 +28,7 @@ import type {
StatusSummary,
} from "./types";
import type { ChatQueueItem, CronFormState } from "./ui-types";
import { refreshChatAvatar } from "./app-chat";
import { renderChat } from "./views/chat";
import { renderConfig } from "./views/config";
import { renderChannels } from "./views/channels";
@@ -413,6 +414,7 @@ export function renderApp(state: AppViewState) {
lastActiveSessionKey: next,
});
void loadChatHistory(state);
void refreshChatAvatar(state);
},
thinkingLevel: state.chatThinkingLevel,
showThinking: state.settings.chatShowThinking,
@@ -422,6 +424,7 @@ export function renderApp(state: AppViewState) {
toolMessages: state.chatToolMessages,
stream: state.chatStream,
streamStartedAt: state.chatStreamStartedAt,
assistantAvatarUrl: state.chatAvatarUrl,
draft: state.chatMessage,
queue: state.chatQueue,
connected: state.connected,
@@ -432,7 +435,7 @@ export function renderApp(state: AppViewState) {
focusMode: state.settings.chatFocusMode,
onRefresh: () => {
state.resetToolStream();
return loadChatHistory(state);
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
},
onToggleFocusMode: () =>
state.applySettings({

View File

@@ -48,6 +48,7 @@ export type AppViewState = {
chatToolMessages: unknown[];
chatStream: string | null;
chatRunId: string | null;
chatAvatarUrl: string | null;
chatThinkingLevel: string | null;
chatQueue: ChatQueueItem[];
nodesLoading: boolean;

View File

@@ -106,6 +106,7 @@ export class ClawdbotApp extends LitElement {
@state() chatStream: string | null = null;
@state() chatStreamStartedAt: number | null = null;
@state() chatRunId: string | null = null;
@state() chatAvatarUrl: string | null = null;
@state() chatThinkingLevel: string | null = null;
@state() chatQueue: ChatQueueItem[] = [];
// Sidebar state for tool output viewing

View File

@@ -12,10 +12,10 @@ import {
} from "./message-extract";
import { extractToolCards, renderToolCardSidebar } from "./tool-cards";
export function renderReadingIndicatorGroup() {
export function renderReadingIndicatorGroup(assistantAvatarUrl?: string | null) {
return html`
<div class="chat-group assistant">
${renderAvatar("assistant")}
${renderAvatar("assistant", assistantAvatarUrl ?? undefined)}
<div class="chat-group-messages">
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
<span class="chat-reading-indicator__dots">
@@ -31,6 +31,7 @@ export function renderStreamingGroup(
text: string,
startedAt: number,
onOpenSidebar?: (content: string) => void,
assistantAvatarUrl?: string | null,
) {
const timestamp = new Date(startedAt).toLocaleTimeString([], {
hour: "numeric",
@@ -39,7 +40,7 @@ export function renderStreamingGroup(
return html`
<div class="chat-group assistant">
${renderAvatar("assistant")}
${renderAvatar("assistant", assistantAvatarUrl ?? undefined)}
<div class="chat-group-messages">
${renderGroupedMessage(
{
@@ -61,7 +62,11 @@ export function renderStreamingGroup(
export function renderMessageGroup(
group: MessageGroup,
opts: { onOpenSidebar?: (content: string) => void; showReasoning: boolean },
opts: {
onOpenSidebar?: (content: string) => void;
showReasoning: boolean;
assistantAvatarUrl?: string | null;
},
) {
const normalizedRole = normalizeRoleForGrouping(group.role);
const who =
@@ -83,7 +88,7 @@ export function renderMessageGroup(
return html`
<div class="chat-group ${roleClass}">
${renderAvatar(group.role)}
${renderAvatar(group.role, opts.assistantAvatarUrl ?? undefined)}
<div class="chat-group-messages">
${group.messages.map((item, index) =>
renderGroupedMessage(

View File

@@ -28,6 +28,7 @@ export type ChatProps = {
toolMessages: unknown[];
stream: string | null;
streamStartedAt: number | null;
assistantAvatarUrl?: string | null;
draft: string;
queue: ChatQueueItem[];
connected: boolean;
@@ -114,7 +115,7 @@ export function renderChat(props: ChatProps) {
: nothing}
${repeat(buildChatItems(props), (item) => item.key, (item) => {
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup();
return renderReadingIndicatorGroup(props.assistantAvatarUrl ?? null);
}
if (item.kind === "stream") {
@@ -122,6 +123,7 @@ export function renderChat(props: ChatProps) {
item.text,
item.startedAt,
props.onOpenSidebar,
props.assistantAvatarUrl ?? null,
);
}
@@ -129,6 +131,7 @@ export function renderChat(props: ChatProps) {
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
assistantAvatarUrl: props.assistantAvatarUrl ?? null,
});
}