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

@@ -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 };
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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>;

View File

@@ -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;

View File

@@ -68,6 +68,7 @@ const BASE_METHODS = [
"system-event",
"send",
"agent",
"agent.identity.get",
"agent.wait",
// WebChat WebSocket-native chat methods
"chat.history",

View File

@@ -55,6 +55,7 @@ const READ_METHODS = new Set([
"usage.cost",
"models.list",
"agents.list",
"agent.identity.get",
"skills.status",
"voicewake.get",
"sessions.list",

View File

@@ -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(