feat: extend Control UI assistant identity
This commit is contained in:
@@ -11,6 +11,7 @@ Docs: https://docs.clawd.bot
|
||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||
|
||||
@@ -2654,7 +2654,13 @@ If unset, clients fall back to a muted light-blue.
|
||||
```json5
|
||||
{
|
||||
ui: {
|
||||
seamColor: "#FF4500" // hex (RRGGBB or #RRGGBB)
|
||||
seamColor: "#FF4500", // hex (RRGGBB or #RRGGBB)
|
||||
// Optional: Control UI assistant identity override.
|
||||
// If unset, the Control UI uses the active agent identity (config or IDENTITY.md).
|
||||
assistant: {
|
||||
name: "Clawdbot",
|
||||
avatar: "CB" // emoji, short text, or image URL/data URI
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -756,6 +756,8 @@ export function createExecTool(
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (elevatedRequested) {
|
||||
logInfo(`exec: elevated command ${truncateMiddle(params.command, 120)}`);
|
||||
}
|
||||
const configuredHost = defaults?.host ?? "sandbox";
|
||||
|
||||
@@ -261,6 +261,8 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"commands.restart": "Allow Restart",
|
||||
"commands.useAccessGroups": "Use Access Groups",
|
||||
"ui.seamColor": "Accent Color",
|
||||
"ui.assistant.name": "Assistant Name",
|
||||
"ui.assistant.avatar": "Assistant Avatar",
|
||||
"browser.controlUrl": "Browser Control URL",
|
||||
"browser.snapshotDefaults": "Browser Snapshot Defaults",
|
||||
"browser.snapshotDefaults.mode": "Browser Snapshot Mode",
|
||||
|
||||
@@ -65,6 +65,12 @@ export type ClawdbotConfig = {
|
||||
ui?: {
|
||||
/** Accent color for Clawdbot UI chrome (hex). */
|
||||
seamColor?: string;
|
||||
assistant?: {
|
||||
/** Assistant display name for UI surfaces. */
|
||||
name?: string;
|
||||
/** Assistant avatar (emoji, short text, or image URL/data URI). */
|
||||
avatar?: string;
|
||||
};
|
||||
};
|
||||
skills?: SkillsConfig;
|
||||
plugins?: PluginsConfig;
|
||||
|
||||
55
src/gateway/assistant-identity.ts
Normal file
55
src/gateway/assistant-identity.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { resolveAgentIdentity } from "../agents/identity.js";
|
||||
import { loadAgentIdentity } from "../commands/agents.config.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
|
||||
const MAX_ASSISTANT_NAME = 50;
|
||||
const MAX_ASSISTANT_AVATAR = 200;
|
||||
|
||||
export const DEFAULT_ASSISTANT_IDENTITY = {
|
||||
name: "Assistant",
|
||||
avatar: "A",
|
||||
};
|
||||
|
||||
export type AssistantIdentity = {
|
||||
agentId: string;
|
||||
name: string;
|
||||
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 resolveAssistantIdentity(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentId?: string | null;
|
||||
workspaceDir?: string | null;
|
||||
}): AssistantIdentity {
|
||||
const agentId = normalizeAgentId(params.agentId ?? resolveDefaultAgentId(params.cfg));
|
||||
const workspaceDir = params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, agentId);
|
||||
const configAssistant = params.cfg.ui?.assistant;
|
||||
const agentIdentity = resolveAgentIdentity(params.cfg, agentId);
|
||||
const fileIdentity = workspaceDir ? loadAgentIdentity(workspaceDir) : null;
|
||||
|
||||
const name =
|
||||
coerceIdentityValue(configAssistant?.name, MAX_ASSISTANT_NAME) ??
|
||||
coerceIdentityValue(agentIdentity?.name, MAX_ASSISTANT_NAME) ??
|
||||
coerceIdentityValue(fileIdentity?.name, MAX_ASSISTANT_NAME) ??
|
||||
DEFAULT_ASSISTANT_IDENTITY.name;
|
||||
|
||||
const avatar =
|
||||
coerceIdentityValue(configAssistant?.avatar, MAX_ASSISTANT_AVATAR) ??
|
||||
coerceIdentityValue(agentIdentity?.avatar, MAX_ASSISTANT_AVATAR) ??
|
||||
coerceIdentityValue(agentIdentity?.emoji, MAX_ASSISTANT_AVATAR) ??
|
||||
coerceIdentityValue(fileIdentity?.avatar, MAX_ASSISTANT_AVATAR) ??
|
||||
coerceIdentityValue(fileIdentity?.emoji, MAX_ASSISTANT_AVATAR) ??
|
||||
DEFAULT_ASSISTANT_IDENTITY.avatar;
|
||||
|
||||
return { agentId, name, avatar };
|
||||
}
|
||||
@@ -3,84 +3,16 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
|
||||
|
||||
const ROOT_PREFIX = "/";
|
||||
const AVATAR_PREFIX = "/avatar";
|
||||
|
||||
// === Assistant Identity Resolution ===
|
||||
|
||||
const DEFAULT_ASSISTANT_IDENTITY = {
|
||||
name: "Assistant",
|
||||
avatar: "A",
|
||||
};
|
||||
|
||||
interface AssistantIdentity {
|
||||
name: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
interface AssistantConfig {
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
function parseIdentityMd(content: string): Partial<AssistantIdentity> {
|
||||
const result: Partial<AssistantIdentity> = {};
|
||||
const nameMatch = content.match(/^-\s*Name:\s*(.+)$/im);
|
||||
if (nameMatch?.[1]) {
|
||||
const name = nameMatch[1].trim();
|
||||
if (name && name.length <= 50) {
|
||||
result.name = name;
|
||||
}
|
||||
}
|
||||
const emojiMatch = content.match(/^-\s*Emoji:\s*(.+)$/im);
|
||||
if (emojiMatch?.[1]) {
|
||||
const emoji = emojiMatch[1].trim();
|
||||
if (emoji) {
|
||||
result.avatar = emoji;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveAssistantIdentity(opts?: {
|
||||
configAssistant?: AssistantConfig;
|
||||
workspaceDir?: string;
|
||||
}): AssistantIdentity {
|
||||
const { configAssistant, workspaceDir } = opts ?? {};
|
||||
let name = DEFAULT_ASSISTANT_IDENTITY.name;
|
||||
let avatar = DEFAULT_ASSISTANT_IDENTITY.avatar;
|
||||
|
||||
// Try IDENTITY.md from workspace
|
||||
if (workspaceDir) {
|
||||
try {
|
||||
const identityPath = path.join(workspaceDir, "IDENTITY.md");
|
||||
if (fs.existsSync(identityPath)) {
|
||||
const identityMd = parseIdentityMd(fs.readFileSync(identityPath, "utf8"));
|
||||
if (identityMd.name) name = identityMd.name;
|
||||
if (identityMd.avatar) avatar = identityMd.avatar;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors reading IDENTITY.md
|
||||
}
|
||||
}
|
||||
|
||||
// Config overrides IDENTITY.md
|
||||
if (configAssistant?.name?.trim()) name = configAssistant.name.trim();
|
||||
if (configAssistant?.avatar?.trim()) avatar = configAssistant.avatar.trim();
|
||||
|
||||
return { name, avatar };
|
||||
}
|
||||
|
||||
// === End Assistant Identity ===
|
||||
|
||||
export type ControlUiRequestOptions = {
|
||||
basePath?: string;
|
||||
config?: {
|
||||
ui?: {
|
||||
assistant?: AssistantConfig;
|
||||
};
|
||||
};
|
||||
workspaceDir?: string;
|
||||
config?: ClawdbotConfig;
|
||||
agentId?: string;
|
||||
};
|
||||
|
||||
export function normalizeControlUiBasePath(basePath?: string): string {
|
||||
@@ -252,8 +184,12 @@ function injectControlUiConfig(html: string, opts: ControlUiInjectionOpts): stri
|
||||
const script =
|
||||
`<script>` +
|
||||
`window.__CLAWDBOT_CONTROL_UI_BASE_PATH__=${JSON.stringify(basePath)};` +
|
||||
`window.__CLAWDBOT_ASSISTANT_NAME__=${JSON.stringify(assistantName ?? "Assistant")};` +
|
||||
`window.__CLAWDBOT_ASSISTANT_AVATAR__=${JSON.stringify(assistantAvatar ?? "A")};` +
|
||||
`window.__CLAWDBOT_ASSISTANT_NAME__=${JSON.stringify(
|
||||
assistantName ?? DEFAULT_ASSISTANT_IDENTITY.name,
|
||||
)};` +
|
||||
`window.__CLAWDBOT_ASSISTANT_AVATAR__=${JSON.stringify(
|
||||
assistantAvatar ?? DEFAULT_ASSISTANT_IDENTITY.avatar,
|
||||
)};` +
|
||||
`</script>`;
|
||||
// Check if already injected
|
||||
if (html.includes("__CLAWDBOT_ASSISTANT_NAME__")) return html;
|
||||
@@ -266,16 +202,15 @@ function injectControlUiConfig(html: string, opts: ControlUiInjectionOpts): stri
|
||||
|
||||
interface ServeIndexHtmlOpts {
|
||||
basePath: string;
|
||||
config?: ControlUiRequestOptions["config"];
|
||||
workspaceDir?: string;
|
||||
config?: ClawdbotConfig;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) {
|
||||
const { basePath, config, workspaceDir } = opts;
|
||||
const identity = resolveAssistantIdentity({
|
||||
configAssistant: config?.ui?.assistant,
|
||||
workspaceDir,
|
||||
});
|
||||
const { basePath, config, agentId } = opts;
|
||||
const identity = config
|
||||
? resolveAssistantIdentity({ cfg: config, agentId })
|
||||
: DEFAULT_ASSISTANT_IDENTITY;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
const raw = fs.readFileSync(indexPath, "utf8");
|
||||
@@ -364,7 +299,11 @@ export function handleControlUiHttpRequest(
|
||||
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
if (path.basename(filePath) === "index.html") {
|
||||
serveIndexHtml(res, filePath, { basePath, config: opts?.config, workspaceDir: opts?.workspaceDir });
|
||||
serveIndexHtml(res, filePath, {
|
||||
basePath,
|
||||
config: opts?.config,
|
||||
agentId: opts?.agentId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
serveFile(res, filePath);
|
||||
@@ -374,7 +313,11 @@ export function handleControlUiHttpRequest(
|
||||
// SPA fallback (client-side router): serve index.html for unknown paths.
|
||||
const indexPath = path.join(root, "index.html");
|
||||
if (fs.existsSync(indexPath)) {
|
||||
serveIndexHtml(res, indexPath, { basePath, config: opts?.config, workspaceDir: opts?.workspaceDir });
|
||||
serveIndexHtml(res, indexPath, {
|
||||
basePath,
|
||||
config: opts?.config,
|
||||
agentId: opts?.agentId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ import AjvPkg, { type ErrorObject } from "ajv";
|
||||
import {
|
||||
type AgentEvent,
|
||||
AgentEventSchema,
|
||||
type AgentIdentityParams,
|
||||
AgentIdentityParamsSchema,
|
||||
type AgentIdentityResult,
|
||||
AgentIdentityResultSchema,
|
||||
AgentParamsSchema,
|
||||
type AgentSummary,
|
||||
AgentSummarySchema,
|
||||
@@ -198,6 +202,8 @@ export const validateEventFrame = ajv.compile<EventFrame>(EventFrameSchema);
|
||||
export const validateSendParams = ajv.compile(SendParamsSchema);
|
||||
export const validatePollParams = ajv.compile<PollParams>(PollParamsSchema);
|
||||
export const validateAgentParams = ajv.compile(AgentParamsSchema);
|
||||
export const validateAgentIdentityParams =
|
||||
ajv.compile<AgentIdentityParams>(AgentIdentityParamsSchema);
|
||||
export const validateAgentWaitParams = ajv.compile<AgentWaitParams>(AgentWaitParamsSchema);
|
||||
export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema);
|
||||
export const validateAgentsListParams = ajv.compile<AgentsListParams>(AgentsListParamsSchema);
|
||||
@@ -359,6 +365,8 @@ export {
|
||||
SendParamsSchema,
|
||||
PollParamsSchema,
|
||||
AgentParamsSchema,
|
||||
AgentIdentityParamsSchema,
|
||||
AgentIdentityResultSchema,
|
||||
WakeParamsSchema,
|
||||
NodePairRequestParamsSchema,
|
||||
NodePairListParamsSchema,
|
||||
@@ -432,6 +440,8 @@ export type {
|
||||
ErrorShape,
|
||||
StateVersion,
|
||||
AgentEvent,
|
||||
AgentIdentityParams,
|
||||
AgentIdentityResult,
|
||||
AgentWaitParams,
|
||||
ChatEvent,
|
||||
TickEvent,
|
||||
|
||||
@@ -68,6 +68,23 @@ export const AgentParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const AgentIdentityParamsSchema = Type.Object(
|
||||
{
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
sessionKey: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const AgentIdentityResultSchema = Type.Object(
|
||||
{
|
||||
agentId: NonEmptyString,
|
||||
name: Type.Optional(NonEmptyString),
|
||||
avatar: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const AgentWaitParamsSchema = Type.Object(
|
||||
{
|
||||
runId: NonEmptyString,
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { TSchema } from "@sinclair/typebox";
|
||||
|
||||
import {
|
||||
AgentEventSchema,
|
||||
AgentIdentityParamsSchema,
|
||||
AgentIdentityResultSchema,
|
||||
AgentParamsSchema,
|
||||
AgentWaitParamsSchema,
|
||||
PollParamsSchema,
|
||||
@@ -136,6 +138,8 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
SendParams: SendParamsSchema,
|
||||
PollParams: PollParamsSchema,
|
||||
AgentParams: AgentParamsSchema,
|
||||
AgentIdentityParams: AgentIdentityParamsSchema,
|
||||
AgentIdentityResult: AgentIdentityResultSchema,
|
||||
AgentWaitParams: AgentWaitParamsSchema,
|
||||
WakeParams: WakeParamsSchema,
|
||||
NodePairRequestParams: NodePairRequestParamsSchema,
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { Static } from "@sinclair/typebox";
|
||||
|
||||
import type {
|
||||
AgentEventSchema,
|
||||
AgentIdentityParamsSchema,
|
||||
AgentIdentityResultSchema,
|
||||
AgentWaitParamsSchema,
|
||||
PollParamsSchema,
|
||||
WakeParamsSchema,
|
||||
@@ -125,6 +127,8 @@ export type PresenceEntry = Static<typeof PresenceEntrySchema>;
|
||||
export type ErrorShape = Static<typeof ErrorShapeSchema>;
|
||||
export type StateVersion = Static<typeof StateVersionSchema>;
|
||||
export type AgentEvent = Static<typeof AgentEventSchema>;
|
||||
export type AgentIdentityParams = Static<typeof AgentIdentityParamsSchema>;
|
||||
export type AgentIdentityResult = Static<typeof AgentIdentityResultSchema>;
|
||||
export type PollParams = Static<typeof PollParamsSchema>;
|
||||
export type AgentWaitParams = Static<typeof AgentWaitParamsSchema>;
|
||||
export type WakeParams = Static<typeof WakeParamsSchema>;
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { TlsOptions } from "node:tls";
|
||||
import type { WebSocketServer } from "ws";
|
||||
import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
|
||||
import type { CanvasHostHandler } from "../canvas-host/server.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import type { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { handleSlackHttpRequest } from "../slack/http/index.js";
|
||||
import { resolveAgentAvatar } from "../agents/identity-avatar.js";
|
||||
@@ -256,6 +257,7 @@ export function createGatewayHttpServer(opts: {
|
||||
if (
|
||||
handleControlUiHttpRequest(req, res, {
|
||||
basePath: controlUiBasePath,
|
||||
config: loadConfig(),
|
||||
})
|
||||
)
|
||||
return;
|
||||
|
||||
@@ -68,6 +68,7 @@ const BASE_METHODS = [
|
||||
"system-event",
|
||||
"send",
|
||||
"agent",
|
||||
"agent.identity.get",
|
||||
"agent.wait",
|
||||
// WebChat WebSocket-native chat methods
|
||||
"chat.history",
|
||||
|
||||
@@ -55,6 +55,7 @@ const READ_METHODS = new Set([
|
||||
"usage.cost",
|
||||
"models.list",
|
||||
"agents.list",
|
||||
"agent.identity.get",
|
||||
"skills.status",
|
||||
"voicewake.get",
|
||||
"sessions.list",
|
||||
|
||||
@@ -26,15 +26,18 @@ import {
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { parseMessageWithAttachments } from "../chat-attachments.js";
|
||||
import {
|
||||
type AgentIdentityParams,
|
||||
type AgentWaitParams,
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateAgentIdentityParams,
|
||||
validateAgentParams,
|
||||
validateAgentWaitParams,
|
||||
} from "../protocol/index.js";
|
||||
import { loadSessionEntry } from "../session-utils.js";
|
||||
import { formatForLog } from "../ws-log.js";
|
||||
import { resolveAssistantIdentity } from "../assistant-identity.js";
|
||||
import { waitForAgentJob } from "./agent-job.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
@@ -369,6 +372,43 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
});
|
||||
});
|
||||
},
|
||||
"agent.identity.get": ({ params, respond }) => {
|
||||
if (!validateAgentIdentityParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid agent.identity.get params: ${formatValidationErrors(
|
||||
validateAgentIdentityParams.errors,
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const p = params as AgentIdentityParams;
|
||||
const agentIdRaw = typeof p.agentId === "string" ? p.agentId.trim() : "";
|
||||
const sessionKeyRaw = typeof p.sessionKey === "string" ? p.sessionKey.trim() : "";
|
||||
let agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : undefined;
|
||||
if (sessionKeyRaw) {
|
||||
const resolved = resolveAgentIdFromSessionKey(sessionKeyRaw);
|
||||
if (agentId && resolved !== agentId) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid agent.identity.get params: agent "${agentIdRaw}" does not match session key agent "${resolved}"`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
agentId = resolved;
|
||||
}
|
||||
const cfg = loadConfig();
|
||||
const identity = resolveAssistantIdentity({ cfg, agentId });
|
||||
respond(true, identity, undefined);
|
||||
},
|
||||
"agent.wait": async ({ params, respond }) => {
|
||||
if (!validateAgentWaitParams(params)) {
|
||||
respond(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -62,6 +62,7 @@ export function renderChatControls(state: AppViewState) {
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
syncUrlWithSessionKey(state, next, true);
|
||||
void loadChatHistory(state);
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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],
|
||||
|
||||
49
ui/src/ui/assistant-identity.ts
Normal file
49
ui/src/ui/assistant-identity.ts
Normal 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__,
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
35
ui/src/ui/controllers/assistant-identity.ts
Normal file
35
ui/src/ui/controllers/assistant-identity.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user