feat: extend Control UI assistant identity

This commit is contained in:
Peter Steinberger
2026-01-22 06:47:37 +00:00
parent 3125637ad6
commit 8544df36b8
24 changed files with 340 additions and 104 deletions

View File

@@ -25,6 +25,7 @@ import {
} from "./controllers/exec-approval";
import type { ClawdbotApp } from "./app";
import type { ExecApprovalRequest } from "./controllers/exec-approval";
import { loadAssistantIdentity } from "./controllers/assistant-identity";
type GatewayHost = {
settings: UiSettings;
@@ -43,6 +44,9 @@ type GatewayHost = {
agentsList: AgentsListResult | null;
agentsError: string | null;
debugHealth: HealthSnapshot | null;
assistantName: string;
assistantAvatar: string | null;
assistantAgentId: string | null;
sessionKey: string;
chatRunId: string | null;
execApprovalQueue: ExecApprovalRequest[];
@@ -121,6 +125,7 @@ export function connectGateway(host: GatewayHost) {
host.connected = true;
host.hello = hello;
applySnapshot(host, hello);
void loadAssistantIdentity(host as unknown as ClawdbotApp);
void loadAgents(host as unknown as ClawdbotApp);
void loadNodes(host as unknown as ClawdbotApp, { quiet: true });
void loadDevices(host as unknown as ClawdbotApp, { quiet: true });

View File

@@ -62,6 +62,7 @@ export function renderChatControls(state: AppViewState) {
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
syncUrlWithSessionKey(state, next, true);
void loadChatHistory(state);
}}

View File

@@ -221,6 +221,7 @@ export function renderApp(state: AppViewState) {
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
},
onConnect: () => state.connect(),
onRefresh: () => state.loadOverview(),
@@ -434,6 +435,7 @@ export function renderApp(state: AppViewState) {
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
void loadChatHistory(state);
void refreshChatAvatar(state);
},
@@ -479,6 +481,8 @@ export function renderApp(state: AppViewState) {
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
onCloseSidebar: () => state.handleCloseSidebar(),
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
assistantName: state.assistantName,
assistantAvatar: state.assistantAvatar,
})
: nothing}

View File

@@ -41,6 +41,9 @@ export type AppViewState = {
hello: GatewayHelloOk | null;
lastError: string | null;
eventLog: EventLogEntry[];
assistantName: string;
assistantAvatar: string | null;
assistantAgentId: string | null;
sessionKey: string;
chatLoading: boolean;
chatSending: boolean;
@@ -144,6 +147,7 @@ export type AppViewState = {
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
applySettings: (next: UiSettings) => void;
loadOverview: () => Promise<void>;
loadAssistantIdentity: () => Promise<void>;
loadCron: () => Promise<void>;
handleWhatsAppStart: (force: boolean) => Promise<void>;
handleWhatsAppWait: () => Promise<void>;

View File

@@ -2,6 +2,7 @@ import { LitElement, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
import { resolveInjectedAssistantIdentity } from "./assistant-identity";
import { loadSettings, type UiSettings } from "./storage";
import { renderApp } from "./app-render";
import type { Tab } from "./navigation";
@@ -76,6 +77,7 @@ import {
handleWhatsAppWait as handleWhatsAppWaitInternal,
} from "./app-channels";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form";
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity";
declare global {
interface Window {
@@ -83,6 +85,8 @@ declare global {
}
}
const injectedAssistantIdentity = resolveInjectedAssistantIdentity();
@customElement("clawdbot-app")
export class ClawdbotApp extends LitElement {
@state() settings: UiSettings = loadSettings();
@@ -98,6 +102,10 @@ export class ClawdbotApp extends LitElement {
private toolStreamSyncTimer: number | null = null;
private sidebarCloseTimer: number | null = null;
@state() assistantName = injectedAssistantIdentity.name;
@state() assistantAvatar = injectedAssistantIdentity.avatar;
@state() assistantAgentId = injectedAssistantIdentity.agentId ?? null;
@state() sessionKey = this.settings.sessionKey;
@state() chatLoading = false;
@state() chatSending = false;
@@ -306,6 +314,10 @@ export class ClawdbotApp extends LitElement {
);
}
async loadAssistantIdentity() {
await loadAssistantIdentityInternal(this);
}
applySettings(next: UiSettings) {
applySettingsInternal(
this as unknown as Parameters<typeof applySettingsInternal>[0],

View File

@@ -0,0 +1,49 @@
const MAX_ASSISTANT_NAME = 50;
const MAX_ASSISTANT_AVATAR = 200;
export const DEFAULT_ASSISTANT_NAME = "Assistant";
export const DEFAULT_ASSISTANT_AVATAR = "A";
export type AssistantIdentity = {
agentId?: string | null;
name: string;
avatar: string | null;
};
declare global {
interface Window {
__CLAWDBOT_ASSISTANT_NAME__?: string;
__CLAWDBOT_ASSISTANT_AVATAR__?: string;
}
}
function coerceIdentityValue(value: string | undefined, maxLength: number): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (trimmed.length <= maxLength) return trimmed;
return trimmed.slice(0, maxLength);
}
export function normalizeAssistantIdentity(
input?: Partial<AssistantIdentity> | null,
): AssistantIdentity {
const name =
coerceIdentityValue(input?.name, MAX_ASSISTANT_NAME) ?? DEFAULT_ASSISTANT_NAME;
const avatar = coerceIdentityValue(input?.avatar ?? undefined, MAX_ASSISTANT_AVATAR) ?? null;
const agentId =
typeof input?.agentId === "string" && input.agentId.trim()
? input.agentId.trim()
: null;
return { agentId, name, avatar };
}
export function resolveInjectedAssistantIdentity(): AssistantIdentity {
if (typeof window === "undefined") {
return normalizeAssistantIdentity({});
}
return normalizeAssistantIdentity({
name: window.__CLAWDBOT_ASSISTANT_NAME__,
avatar: window.__CLAWDBOT_ASSISTANT_AVATAR__,
});
}

View File

@@ -1,6 +1,7 @@
import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import type { AssistantIdentity } from "../assistant-identity";
import { toSanitizedMarkdownHtml } from "../markdown";
import type { MessageGroup } from "../types/chat-types";
import { renderCopyAsMarkdownButton } from "./copy-as-markdown";
@@ -12,10 +13,10 @@ import {
} from "./message-extract";
import { extractToolCards, renderToolCardSidebar } from "./tool-cards";
export function renderReadingIndicatorGroup(assistantAvatarUrl?: string | null) {
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) {
return html`
<div class="chat-group assistant">
${renderAvatar("assistant", assistantAvatarUrl ?? undefined)}
${renderAvatar("assistant", assistant)}
<div class="chat-group-messages">
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
<span class="chat-reading-indicator__dots">
@@ -30,17 +31,18 @@ export function renderReadingIndicatorGroup(assistantAvatarUrl?: string | null)
export function renderStreamingGroup(
text: string,
startedAt: number,
assistantAvatarUrl?: string | null,
onOpenSidebar?: (content: string) => void,
assistant?: AssistantIdentity,
) {
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", assistantAvatarUrl ?? undefined)}
${renderAvatar("assistant", assistant)}
<div class="chat-group-messages">
${renderGroupedMessage(
{
@@ -52,7 +54,7 @@ export function renderStreamingGroup(
onOpenSidebar,
)}
<div class="chat-group-footer">
<span class="chat-sender-name">Assistant</span>
<span class="chat-sender-name">${name}</span>
<span class="chat-group-timestamp">${timestamp}</span>
</div>
</div>
@@ -65,15 +67,17 @@ export function renderMessageGroup(
opts: {
onOpenSidebar?: (content: string) => void;
showReasoning: boolean;
assistantAvatarUrl?: string | null;
assistantName?: string;
assistantAvatar?: string | null;
},
) {
const normalizedRole = normalizeRoleForGrouping(group.role);
const assistantName = opts.assistantName ?? "Assistant";
const who =
normalizedRole === "user"
? "You"
: normalizedRole === "assistant"
? "Assistant"
? assistantName
: normalizedRole;
const roleClass =
normalizedRole === "user"
@@ -88,7 +92,10 @@ export function renderMessageGroup(
return html`
<div class="chat-group ${roleClass}">
${renderAvatar(group.role, opts.assistantAvatarUrl ?? undefined)}
${renderAvatar(group.role, {
name: assistantName,
avatar: opts.assistantAvatar ?? null,
})}
<div class="chat-group-messages">
${group.messages.map((item, index) =>
renderGroupedMessage(
@@ -110,13 +117,18 @@ export function renderMessageGroup(
`;
}
function renderAvatar(role: string, avatarUrl?: string) {
function renderAvatar(
role: string,
assistant?: Pick<AssistantIdentity, "name" | "avatar">,
) {
const normalized = normalizeRoleForGrouping(role);
const assistantName = assistant?.name?.trim() || "Assistant";
const assistantAvatar = assistant?.avatar?.trim() || "";
const initial =
normalized === "user"
? "U"
: normalized === "assistant"
? "A"
? assistantName.charAt(0).toUpperCase() || "A"
: normalized === "tool"
? "⚙"
: "?";
@@ -125,18 +137,31 @@ function renderAvatar(role: string, avatarUrl?: string) {
? "user"
: normalized === "assistant"
? "assistant"
: normalized === "tool"
: normalized === "tool"
? "tool"
: "other";
// If avatar URL is provided for assistant, show image
if (avatarUrl && normalized === "assistant") {
return html`<img class="chat-avatar ${className}" src="${avatarUrl}" alt="Assistant" />`;
if (assistantAvatar && normalized === "assistant") {
if (isAvatarUrl(assistantAvatar)) {
return html`<img
class="chat-avatar ${className}"
src="${assistantAvatar}"
alt="${assistantName}"
/>`;
}
return html`<div class="chat-avatar ${className}">${assistantAvatar}</div>`;
}
return html`<div class="chat-avatar ${className}">${initial}</div>`;
}
function isAvatarUrl(value: string): boolean {
return (
/^https?:\/\//i.test(value) ||
/^data:image\//i.test(value)
);
}
function renderGroupedMessage(
message: unknown,
opts: { isStreaming: boolean; showReasoning: boolean },

View File

@@ -0,0 +1,35 @@
import type { GatewayBrowserClient } from "../gateway";
import {
normalizeAssistantIdentity,
type AssistantIdentity,
} from "../assistant-identity";
export type AssistantIdentityState = {
client: GatewayBrowserClient | null;
connected: boolean;
sessionKey: string;
assistantName: string;
assistantAvatar: string | null;
assistantAgentId: string | null;
};
export async function loadAssistantIdentity(
state: AssistantIdentityState,
opts?: { sessionKey?: string },
) {
if (!state.client || !state.connected) return;
const sessionKey = opts?.sessionKey?.trim() || state.sessionKey.trim();
const params = sessionKey ? { sessionKey } : {};
try {
const res = (await state.client.request("agent.identity.get", params)) as
| Partial<AssistantIdentity>
| undefined;
if (!res) return;
const normalized = normalizeAssistantIdentity(res);
state.assistantName = normalized.name;
state.assistantAvatar = normalized.avatar;
state.assistantAgentId = normalized.agentId ?? null;
} catch {
// Ignore errors; keep last known identity.
}
}

View File

@@ -43,6 +43,8 @@ export type ChatProps = {
sidebarContent?: string | null;
sidebarError?: string | null;
splitRatio?: number;
assistantName: string;
assistantAvatar: string | null;
// Event handlers
onRefresh: () => void;
onToggleFocusMode: () => void;
@@ -65,6 +67,10 @@ export function renderChat(props: ChatProps) {
);
const reasoningLevel = activeSession?.reasoningLevel ?? "off";
const showReasoning = props.showThinking && reasoningLevel !== "off";
const assistantIdentity = {
name: props.assistantName,
avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null,
};
const composePlaceholder = props.connected
? "Message (↩ to send, Shift+↩ for line breaks)"
@@ -115,15 +121,15 @@ export function renderChat(props: ChatProps) {
: nothing}
${repeat(buildChatItems(props), (item) => item.key, (item) => {
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(props.assistantAvatarUrl ?? null);
return renderReadingIndicatorGroup(assistantIdentity);
}
if (item.kind === "stream") {
return renderStreamingGroup(
item.text,
item.startedAt,
props.assistantAvatarUrl ?? null,
props.onOpenSidebar,
assistantIdentity,
);
}
@@ -131,7 +137,8 @@ export function renderChat(props: ChatProps) {
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
assistantAvatarUrl: props.assistantAvatarUrl ?? null,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
});
}