Avatar Customization + Update Agent Brain (#23)

Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
Luke The Dev
2026-03-20 23:05:14 -05:00
committed by GitHub
parent a5b0895dd8
commit 65c2b9cf85
39 changed files with 2803 additions and 551 deletions
+272
View File
@@ -0,0 +1,272 @@
export type AgentAvatarHairStyle = "short" | "parted" | "spiky" | "bun";
export type AgentAvatarTopStyle = "tee" | "hoodie" | "jacket";
export type AgentAvatarBottomStyle = "pants" | "shorts" | "cuffed";
export type AgentAvatarHatStyle = "none" | "cap" | "beanie";
export type AgentAvatarProfile = {
version: 1;
seed: string;
body: {
skinTone: string;
};
hair: {
style: AgentAvatarHairStyle;
color: string;
};
clothing: {
topStyle: AgentAvatarTopStyle;
topColor: string;
bottomStyle: AgentAvatarBottomStyle;
bottomColor: string;
shoesColor: string;
};
accessories: {
glasses: boolean;
headset: boolean;
hatStyle: AgentAvatarHatStyle;
backpack: boolean;
};
};
type ColorOption = {
id: string;
label: string;
color: string;
};
type EnumOption<T extends string> = {
id: T;
label: string;
};
export const AGENT_AVATAR_SKIN_TONE_OPTIONS: ColorOption[] = [
{ id: "fair", label: "Fair", color: "#f7d7c2" },
{ id: "light", label: "Light", color: "#f4c58a" },
{ id: "warm", label: "Warm", color: "#d8a06e" },
{ id: "tan", label: "Tan", color: "#b7794e" },
{ id: "deep", label: "Deep", color: "#8a5a3b" },
{ id: "rich", label: "Rich", color: "#5d3a24" },
];
export const AGENT_AVATAR_HAIR_STYLE_OPTIONS: EnumOption<AgentAvatarHairStyle>[] = [
{ id: "short", label: "Short" },
{ id: "parted", label: "Parted" },
{ id: "spiky", label: "Spiky" },
{ id: "bun", label: "Bun" },
];
export const AGENT_AVATAR_HAIR_COLOR_OPTIONS: ColorOption[] = [
{ id: "ink", label: "Ink", color: "#151515" },
{ id: "espresso", label: "Espresso", color: "#3e2723" },
{ id: "walnut", label: "Walnut", color: "#6b4f3a" },
{ id: "auburn", label: "Auburn", color: "#7b341e" },
{ id: "blonde", label: "Blonde", color: "#d6b56c" },
{ id: "violet", label: "Violet", color: "#7c3aed" },
{ id: "cyan", label: "Cyan", color: "#0891b2" },
{ id: "pink", label: "Pink", color: "#db2777" },
];
export const AGENT_AVATAR_TOP_STYLE_OPTIONS: EnumOption<AgentAvatarTopStyle>[] = [
{ id: "tee", label: "Tee" },
{ id: "hoodie", label: "Hoodie" },
{ id: "jacket", label: "Jacket" },
];
export const AGENT_AVATAR_BOTTOM_STYLE_OPTIONS: EnumOption<AgentAvatarBottomStyle>[] = [
{ id: "pants", label: "Pants" },
{ id: "shorts", label: "Shorts" },
{ id: "cuffed", label: "Cuffed" },
];
export const AGENT_AVATAR_HAT_STYLE_OPTIONS: EnumOption<AgentAvatarHatStyle>[] = [
{ id: "none", label: "None" },
{ id: "cap", label: "Cap" },
{ id: "beanie", label: "Beanie" },
];
export const AGENT_AVATAR_CLOTHING_COLOR_OPTIONS: ColorOption[] = [
{ id: "graphite", label: "Graphite", color: "#2d3748" },
{ id: "sky", label: "Sky", color: "#7090ff" },
{ id: "mint", label: "Mint", color: "#34d399" },
{ id: "amber", label: "Amber", color: "#f59e0b" },
{ id: "rose", label: "Rose", color: "#f43f5e" },
{ id: "violet", label: "Violet", color: "#8b5cf6" },
{ id: "cream", label: "Cream", color: "#f5f5f4" },
{ id: "slate", label: "Slate", color: "#64748b" },
];
export const AGENT_AVATAR_SHOE_COLOR_OPTIONS: ColorOption[] = [
{ id: "black", label: "Black", color: "#1a1a1a" },
{ id: "navy", label: "Navy", color: "#1e3a8a" },
{ id: "brown", label: "Brown", color: "#7c4a2d" },
{ id: "white", label: "White", color: "#e5e7eb" },
];
const AGENT_AVATAR_VERSION = 1 as const;
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const coerceString = (value: unknown) => (typeof value === "string" ? value.trim() : "");
const hashSeed = (seed: string) => {
let hash = 2166136261;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
};
const pick = <T,>(values: readonly T[], index: number) => values[index % values.length];
const resolveColor = (value: unknown, options: ColorOption[], fallback: string) => {
const color = coerceString(value).toLowerCase();
if (!color) return fallback;
const option =
options.find((entry) => entry.id === color) ??
options.find((entry) => entry.color.toLowerCase() === color);
return option?.color ?? fallback;
};
const resolveEnumOption = <T extends string>(
value: unknown,
options: EnumOption<T>[],
fallback: T,
): T => {
const normalized = coerceString(value).toLowerCase();
const match = options.find((entry) => entry.id === normalized);
return match?.id ?? fallback;
};
export const createAgentAvatarProfileFromSeed = (seed: string): AgentAvatarProfile => {
const normalizedSeed = seed.trim() || "agent";
const hash = hashSeed(normalizedSeed);
const skinTone = pick(AGENT_AVATAR_SKIN_TONE_OPTIONS, hash).color;
const hairStyle = pick(AGENT_AVATAR_HAIR_STYLE_OPTIONS, hash >>> 3).id;
const hairColor = pick(AGENT_AVATAR_HAIR_COLOR_OPTIONS, hash >>> 5).color;
const topStyle = pick(AGENT_AVATAR_TOP_STYLE_OPTIONS, hash >>> 7).id;
const topColor = pick(AGENT_AVATAR_CLOTHING_COLOR_OPTIONS, hash >>> 9).color;
const bottomStyle = pick(AGENT_AVATAR_BOTTOM_STYLE_OPTIONS, hash >>> 11).id;
const bottomColor = pick(AGENT_AVATAR_CLOTHING_COLOR_OPTIONS, hash >>> 13).color;
const shoesColor = pick(AGENT_AVATAR_SHOE_COLOR_OPTIONS, hash >>> 15).color;
const hatStyle = pick(AGENT_AVATAR_HAT_STYLE_OPTIONS, hash >>> 17).id;
return {
version: AGENT_AVATAR_VERSION,
seed: normalizedSeed,
body: {
skinTone,
},
hair: {
style: hairStyle,
color: hairColor,
},
clothing: {
topStyle,
topColor,
bottomStyle,
bottomColor,
shoesColor,
},
accessories: {
glasses: Boolean((hash >>> 19) % 2),
headset: Boolean((hash >>> 20) % 2),
hatStyle,
backpack: Boolean((hash >>> 21) % 2),
},
};
};
export const createDefaultAgentAvatarProfile = (seed: string): AgentAvatarProfile =>
createAgentAvatarProfileFromSeed(seed);
export const normalizeAgentAvatarProfile = (
value: unknown,
fallbackSeed: string,
): AgentAvatarProfile => {
if (typeof value === "string") {
return createAgentAvatarProfileFromSeed(value);
}
const baseProfile = createAgentAvatarProfileFromSeed(fallbackSeed);
if (!isRecord(value)) {
return baseProfile;
}
const body = isRecord(value.body) ? value.body : {};
const hair = isRecord(value.hair) ? value.hair : {};
const clothing = isRecord(value.clothing) ? value.clothing : {};
const accessories = isRecord(value.accessories) ? value.accessories : {};
const normalizedSeed = coerceString(value.seed) || baseProfile.seed;
return {
version: AGENT_AVATAR_VERSION,
seed: normalizedSeed,
body: {
skinTone: resolveColor(
body.skinTone,
AGENT_AVATAR_SKIN_TONE_OPTIONS,
baseProfile.body.skinTone,
),
},
hair: {
style: resolveEnumOption(
hair.style,
AGENT_AVATAR_HAIR_STYLE_OPTIONS,
baseProfile.hair.style,
),
color: resolveColor(
hair.color,
AGENT_AVATAR_HAIR_COLOR_OPTIONS,
baseProfile.hair.color,
),
},
clothing: {
topStyle: resolveEnumOption(
clothing.topStyle,
AGENT_AVATAR_TOP_STYLE_OPTIONS,
baseProfile.clothing.topStyle,
),
topColor: resolveColor(
clothing.topColor,
AGENT_AVATAR_CLOTHING_COLOR_OPTIONS,
baseProfile.clothing.topColor,
),
bottomStyle: resolveEnumOption(
clothing.bottomStyle,
AGENT_AVATAR_BOTTOM_STYLE_OPTIONS,
baseProfile.clothing.bottomStyle,
),
bottomColor: resolveColor(
clothing.bottomColor,
AGENT_AVATAR_CLOTHING_COLOR_OPTIONS,
baseProfile.clothing.bottomColor,
),
shoesColor: resolveColor(
clothing.shoesColor,
AGENT_AVATAR_SHOE_COLOR_OPTIONS,
baseProfile.clothing.shoesColor,
),
},
accessories: {
glasses:
typeof accessories.glasses === "boolean"
? accessories.glasses
: baseProfile.accessories.glasses,
headset:
typeof accessories.headset === "boolean"
? accessories.headset
: baseProfile.accessories.headset,
hatStyle: resolveEnumOption(
accessories.hatStyle,
AGENT_AVATAR_HAT_STYLE_OPTIONS,
baseProfile.accessories.hatStyle,
),
backpack:
typeof accessories.backpack === "boolean"
? accessories.backpack
: baseProfile.accessories.backpack,
},
};
};
+31 -1
View File
@@ -1,8 +1,10 @@
import { fetchJson } from "@/lib/http";
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
import type {
StudioAnalyticsPreferencePatch,
StudioFocusedPreference,
StudioGatewaySettingsPublic,
StudioOfficePreferencePatch,
StudioSettingsPublic,
StudioSettingsPatch,
StudioStandupPreferencePatch,
@@ -20,10 +22,11 @@ export type StudioSettingsLoadOptions = {
};
type FocusedPatch = Record<string, Partial<StudioFocusedPreference> | null>;
type AvatarsPatch = Record<string, Record<string, string | null> | null>;
type AvatarsPatch = Record<string, Record<string, AgentAvatarProfile | null> | null>;
type DeskAssignmentsPatch = Record<string, Record<string, string | null> | null>;
type AnalyticsPatch = Record<string, StudioAnalyticsPreferencePatch | null>;
type VoiceRepliesPatch = Record<string, StudioVoiceRepliesPreferencePatch | null>;
type OfficePatch = Record<string, StudioOfficePreferencePatch | null>;
type StandupPatch = Record<string, StudioStandupPreferencePatch | null>;
export type StudioSettingsCoordinatorTransport = {
@@ -143,6 +146,30 @@ const mergeVoiceRepliesPatch = (
return merged;
};
const mergeOfficePatch = (
current: OfficePatch | undefined,
next: OfficePatch | undefined
): OfficePatch | undefined => {
if (!current && !next) return undefined;
const merged: OfficePatch = { ...(current ?? {}) };
for (const [gatewayKey, value] of Object.entries(next ?? {})) {
if (value === null) {
merged[gatewayKey] = null;
continue;
}
const existing = merged[gatewayKey];
if (existing && existing !== null) {
merged[gatewayKey] = {
...existing,
...value,
};
continue;
}
merged[gatewayKey] = { ...value };
}
return merged;
};
const mergeStandupPatch = (
current: StandupPatch | undefined,
next: StandupPatch | undefined
@@ -210,6 +237,7 @@ const mergeStudioPatch = (
...(next.deskAssignments ? { deskAssignments: { ...next.deskAssignments } } : {}),
...(next.analytics ? { analytics: { ...next.analytics } } : {}),
...(next.voiceReplies ? { voiceReplies: { ...next.voiceReplies } } : {}),
...(next.office ? { office: { ...next.office } } : {}),
...(next.standup ? { standup: { ...next.standup } } : {}),
};
}
@@ -221,6 +249,7 @@ const mergeStudioPatch = (
);
const analytics = mergeAnalyticsPatch(current.analytics, next.analytics);
const voiceReplies = mergeVoiceRepliesPatch(current.voiceReplies, next.voiceReplies);
const office = mergeOfficePatch(current.office, next.office);
const standup = mergeStandupPatch(current.standup, next.standup);
return {
...(next.gateway !== undefined
@@ -233,6 +262,7 @@ const mergeStudioPatch = (
...(deskAssignments ? { deskAssignments } : {}),
...(analytics ? { analytics } : {}),
...(voiceReplies ? { voiceReplies } : {}),
...(office ? { office } : {}),
...(standup ? { standup } : {}),
};
};
+99 -17
View File
@@ -4,6 +4,8 @@ import type {
StandupManualEntry,
StandupScheduleConfig,
} from "@/lib/office/standup/types";
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
import { normalizeAgentAvatarProfile } from "@/lib/avatars/profile";
export type StudioGatewaySettings = {
url: string;
@@ -60,7 +62,16 @@ export type StudioVoiceRepliesPreferencePatch = {
speed?: number;
};
export type StudioOfficePreference = {
title: string;
};
export type StudioOfficePreferencePatch = {
title?: string | null;
};
export type StudioDeskAssignments = Record<string, string>;
export type StudioAgentAvatars = Record<string, AgentAvatarProfile>;
export type StudioStandupPreference = StandupConfig;
@@ -83,10 +94,11 @@ export type StudioSettings = {
version: 1;
gateway: StudioGatewaySettings | null;
focused: Record<string, StudioFocusedPreference>;
avatars: Record<string, Record<string, string>>;
avatars: Record<string, StudioAgentAvatars>;
deskAssignments: Record<string, StudioDeskAssignments>;
analytics: Record<string, StudioAnalyticsPreference>;
voiceReplies: Record<string, StudioVoiceRepliesPreference>;
office: Record<string, StudioOfficePreference>;
standup?: Record<string, StudioStandupPreference>;
};
@@ -98,10 +110,11 @@ export type StudioSettingsPublic = Omit<StudioSettings, "gateway" | "standup"> &
export type StudioSettingsPatch = {
gateway?: StudioGatewaySettingsPatch | null;
focused?: Record<string, Partial<StudioFocusedPreference> | null>;
avatars?: Record<string, Record<string, string | null> | null>;
avatars?: Record<string, Record<string, AgentAvatarProfile | null> | null>;
deskAssignments?: Record<string, Record<string, string | null> | null>;
analytics?: Record<string, StudioAnalyticsPreferencePatch | null>;
voiceReplies?: Record<string, StudioVoiceRepliesPreferencePatch | null>;
office?: Record<string, StudioOfficePreferencePatch | null>;
standup?: Record<string, StudioStandupPreferencePatch | null>;
};
@@ -257,6 +270,20 @@ const normalizeOptionalIsoString = (
return trimmed ? trimmed : null;
};
const DEFAULT_OFFICE_TITLE = "Luke Headquarters";
const normalizeOfficeTitle = (
value: unknown,
fallback: string = DEFAULT_OFFICE_TITLE
) => {
const title = coerceString(value);
return (title || fallback).slice(0, 48);
};
export const defaultStudioOfficePreference = (): StudioOfficePreference => ({
title: DEFAULT_OFFICE_TITLE,
});
const normalizeStandupScheduleConfig = (
value: unknown,
fallback: StandupScheduleConfig = defaultStudioStandupScheduleConfig()
@@ -400,20 +427,18 @@ const normalizeFocused = (value: unknown): Record<string, StudioFocusedPreferenc
return focused;
};
const normalizeAvatars = (value: unknown): Record<string, Record<string, string>> => {
const normalizeAvatars = (value: unknown): Record<string, StudioAgentAvatars> => {
if (!isRecord(value)) return {};
const avatars: Record<string, Record<string, string>> = {};
const avatars: Record<string, StudioAgentAvatars> = {};
for (const [gatewayKeyRaw, gatewayRaw] of Object.entries(value)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
if (!gatewayKey) continue;
if (!isRecord(gatewayRaw)) continue;
const entries: Record<string, string> = {};
for (const [agentIdRaw, seedRaw] of Object.entries(gatewayRaw)) {
const entries: StudioAgentAvatars = {};
for (const [agentIdRaw, avatarRaw] of Object.entries(gatewayRaw)) {
const agentId = coerceString(agentIdRaw);
if (!agentId) continue;
const seed = coerceString(seedRaw);
if (!seed) continue;
entries[agentId] = seed;
entries[agentId] = normalizeAgentAvatarProfile(avatarRaw, agentId);
}
avatars[gatewayKey] = entries;
}
@@ -522,6 +547,27 @@ const normalizeVoiceReplies = (
return voiceReplies;
};
const normalizeOfficePreference = (
value: unknown,
fallback: StudioOfficePreference = defaultStudioOfficePreference()
): StudioOfficePreference => {
if (!isRecord(value)) return fallback;
return {
title: normalizeOfficeTitle(value.title, fallback.title),
};
};
const normalizeOffice = (value: unknown): Record<string, StudioOfficePreference> => {
if (!isRecord(value)) return {};
const office: Record<string, StudioOfficePreference> = {};
for (const [gatewayKeyRaw, officeRaw] of Object.entries(value)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
if (!gatewayKey) continue;
office[gatewayKey] = normalizeOfficePreference(officeRaw);
}
return office;
};
export const defaultStudioSettings = (): StudioSettings => ({
version: SETTINGS_VERSION,
gateway: null,
@@ -530,6 +576,7 @@ export const defaultStudioSettings = (): StudioSettings => ({
deskAssignments: {},
analytics: {},
voiceReplies: {},
office: {},
standup: {},
});
@@ -579,6 +626,7 @@ export const normalizeStudioSettings = (raw: unknown): StudioSettings => {
const deskAssignments = normalizeDeskAssignments(raw.deskAssignments);
const analytics = normalizeAnalytics(raw.analytics);
const voiceReplies = normalizeVoiceReplies(raw.voiceReplies);
const office = normalizeOffice(raw.office);
const standup = normalizeStandup(raw.standup);
return {
version: SETTINGS_VERSION,
@@ -588,6 +636,7 @@ export const normalizeStudioSettings = (raw: unknown): StudioSettings => {
deskAssignments,
analytics,
voiceReplies,
office,
standup,
};
};
@@ -603,6 +652,7 @@ export const mergeStudioSettings = (
const nextDeskAssignments = { ...current.deskAssignments };
const nextAnalytics = { ...current.analytics };
const nextVoiceReplies = { ...current.voiceReplies };
const nextOffice = { ...current.office };
const nextStandup = { ...(current.standup ?? {}) };
if (patch.focused) {
for (const [keyRaw, value] of Object.entries(patch.focused)) {
@@ -626,19 +676,14 @@ export const mergeStudioSettings = (
}
if (!isRecord(gatewayPatch)) continue;
const existing = nextAvatars[gatewayKey] ? { ...nextAvatars[gatewayKey] } : {};
for (const [agentIdRaw, seedPatchRaw] of Object.entries(gatewayPatch)) {
for (const [agentIdRaw, avatarPatchRaw] of Object.entries(gatewayPatch)) {
const agentId = coerceString(agentIdRaw);
if (!agentId) continue;
if (seedPatchRaw === null) {
if (avatarPatchRaw === null) {
delete existing[agentId];
continue;
}
const seed = coerceString(seedPatchRaw);
if (!seed) {
delete existing[agentId];
continue;
}
existing[agentId] = seed;
existing[agentId] = normalizeAgentAvatarProfile(avatarPatchRaw, agentId);
}
nextAvatars[gatewayKey] = existing;
}
@@ -713,6 +758,24 @@ export const mergeStudioSettings = (
);
}
}
if (patch.office) {
for (const [gatewayKeyRaw, officePatch] of Object.entries(patch.office)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
if (!gatewayKey) continue;
if (officePatch === null) {
delete nextOffice[gatewayKey];
continue;
}
const fallback = nextOffice[gatewayKey] ?? defaultStudioOfficePreference();
nextOffice[gatewayKey] = normalizeOfficePreference(
{
...fallback,
...officePatch,
},
fallback
);
}
}
if (patch.standup) {
for (const [gatewayKeyRaw, standupPatch] of Object.entries(patch.standup)) {
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
@@ -769,6 +832,7 @@ export const mergeStudioSettings = (
deskAssignments: nextDeskAssignments,
analytics: nextAnalytics,
voiceReplies: nextVoiceReplies,
office: nextOffice,
standup: nextStandup,
};
};
@@ -787,6 +851,15 @@ export const resolveAgentAvatarSeed = (
gatewayUrl: string,
agentId: string
): string | null => {
const profile = resolveAgentAvatarProfile(settings, gatewayUrl, agentId);
return profile?.seed ?? null;
};
export const resolveAgentAvatarProfile = (
settings: StudioSettings | StudioSettingsPublic,
gatewayUrl: string,
agentId: string
): AgentAvatarProfile | null => {
const gatewayKey = normalizeGatewayKey(gatewayUrl);
if (!gatewayKey) return null;
const agentKey = coerceString(agentId);
@@ -821,6 +894,15 @@ export const resolveVoiceRepliesPreference = (
return settings.voiceReplies[gatewayKey] ?? defaultStudioVoiceRepliesPreference();
};
export const resolveOfficePreference = (
settings: StudioSettings | StudioSettingsPublic,
gatewayUrl: string
): StudioOfficePreference => {
const gatewayKey = normalizeGatewayKey(gatewayUrl);
if (!gatewayKey) return defaultStudioOfficePreference();
return settings.office[gatewayKey] ?? defaultStudioOfficePreference();
};
export const resolveStandupPreference = (
settings: StudioSettings | StudioSettingsPublic,
gatewayUrl: string