feat(webui): add custom assistant identity support
Adds the ability to customize the assistant's name and avatar in the Web UI. Configuration options: - config.ui.assistant.name: Custom name (replaces 'Assistant') - config.ui.assistant.avatar: Emoji or letter for avatar (replaces 'A') Also reads from workspace IDENTITY.md as fallback: - Name: field sets the assistant name - Emoji: field sets the avatar Priority: config > IDENTITY.md > defaults Closes #1383
This commit is contained in:
@@ -155,6 +155,13 @@ export const ClawdbotSchema = z
|
|||||||
ui: z
|
ui: z
|
||||||
.object({
|
.object({
|
||||||
seamColor: HexColorSchema.optional(),
|
seamColor: HexColorSchema.optional(),
|
||||||
|
assistant: z
|
||||||
|
.object({
|
||||||
|
name: z.string().max(50).optional(),
|
||||||
|
avatar: z.string().max(200).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -6,8 +6,81 @@ import { fileURLToPath } from "node:url";
|
|||||||
const ROOT_PREFIX = "/";
|
const ROOT_PREFIX = "/";
|
||||||
const AVATAR_PREFIX = "/avatar";
|
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 = {
|
export type ControlUiRequestOptions = {
|
||||||
basePath?: string;
|
basePath?: string;
|
||||||
|
config?: {
|
||||||
|
ui?: {
|
||||||
|
assistant?: AssistantConfig;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
workspaceDir?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function normalizeControlUiBasePath(basePath?: string): string {
|
export function normalizeControlUiBasePath(basePath?: string): string {
|
||||||
@@ -168,11 +241,22 @@ function serveFile(res: ServerResponse, filePath: string) {
|
|||||||
res.end(fs.readFileSync(filePath));
|
res.end(fs.readFileSync(filePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
function injectControlUiBasePath(html: string, basePath: string): string {
|
interface ControlUiInjectionOpts {
|
||||||
const script = `<script>window.__CLAWDBOT_CONTROL_UI_BASE_PATH__=${JSON.stringify(
|
basePath: string;
|
||||||
basePath,
|
assistantName?: string;
|
||||||
)};</script>`;
|
assistantAvatar?: string;
|
||||||
if (html.includes("__CLAWDBOT_CONTROL_UI_BASE_PATH__")) return html;
|
}
|
||||||
|
|
||||||
|
function injectControlUiConfig(html: string, opts: ControlUiInjectionOpts): string {
|
||||||
|
const { basePath, assistantName, assistantAvatar } = opts;
|
||||||
|
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")};` +
|
||||||
|
`</script>`;
|
||||||
|
// Check if already injected
|
||||||
|
if (html.includes("__CLAWDBOT_ASSISTANT_NAME__")) return html;
|
||||||
const headClose = html.indexOf("</head>");
|
const headClose = html.indexOf("</head>");
|
||||||
if (headClose !== -1) {
|
if (headClose !== -1) {
|
||||||
return `${html.slice(0, headClose)}${script}${html.slice(headClose)}`;
|
return `${html.slice(0, headClose)}${script}${html.slice(headClose)}`;
|
||||||
@@ -180,11 +264,28 @@ function injectControlUiBasePath(html: string, basePath: string): string {
|
|||||||
return `${script}${html}`;
|
return `${script}${html}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function serveIndexHtml(res: ServerResponse, indexPath: string, basePath: string) {
|
interface ServeIndexHtmlOpts {
|
||||||
|
basePath: string;
|
||||||
|
config?: ControlUiRequestOptions["config"];
|
||||||
|
workspaceDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) {
|
||||||
|
const { basePath, config, workspaceDir } = opts;
|
||||||
|
const identity = resolveAssistantIdentity({
|
||||||
|
configAssistant: config?.ui?.assistant,
|
||||||
|
workspaceDir,
|
||||||
|
});
|
||||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||||
res.setHeader("Cache-Control", "no-cache");
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
const raw = fs.readFileSync(indexPath, "utf8");
|
const raw = fs.readFileSync(indexPath, "utf8");
|
||||||
res.end(injectControlUiBasePath(raw, basePath));
|
res.end(
|
||||||
|
injectControlUiConfig(raw, {
|
||||||
|
basePath,
|
||||||
|
assistantName: identity.name,
|
||||||
|
assistantAvatar: identity.avatar,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSafeRelativePath(relPath: string) {
|
function isSafeRelativePath(relPath: string) {
|
||||||
@@ -263,7 +364,7 @@ export function handleControlUiHttpRequest(
|
|||||||
|
|
||||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||||
if (path.basename(filePath) === "index.html") {
|
if (path.basename(filePath) === "index.html") {
|
||||||
serveIndexHtml(res, filePath, basePath);
|
serveIndexHtml(res, filePath, { basePath, config: opts?.config, workspaceDir: opts?.workspaceDir });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
serveFile(res, filePath);
|
serveFile(res, filePath);
|
||||||
@@ -273,7 +374,7 @@ export function handleControlUiHttpRequest(
|
|||||||
// SPA fallback (client-side router): serve index.html for unknown paths.
|
// SPA fallback (client-side router): serve index.html for unknown paths.
|
||||||
const indexPath = path.join(root, "index.html");
|
const indexPath = path.join(root, "index.html");
|
||||||
if (fs.existsSync(indexPath)) {
|
if (fs.existsSync(indexPath)) {
|
||||||
serveIndexHtml(res, indexPath, basePath);
|
serveIndexHtml(res, indexPath, { basePath, config: opts?.config, workspaceDir: opts?.workspaceDir });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user