feat(web): letter avatars for custom model groups and agents (#31)
Co-authored-by: Alice <alice@prismshadow.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -417,7 +417,12 @@ export function Sidebar({
|
||||
aria-label={collapsed ? S.nav.expandGroup : S.nav.collapseGroup}
|
||||
className="flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<AgentAvatar id={agent.agentId} size={18} className="shrink-0 rounded" />
|
||||
<AgentAvatar
|
||||
id={agent.agentId}
|
||||
name={agentDisplayName(agent)}
|
||||
size={18}
|
||||
className="shrink-0 rounded"
|
||||
/>
|
||||
<span className="min-w-0 truncate text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{agentDisplayName(agent)}
|
||||
</span>
|
||||
|
||||
@@ -1,90 +1,53 @@
|
||||
/**
|
||||
* Agent avatar: a pixel identicon deterministically generated from agentId (the same approach
|
||||
* GitHub's default avatars use).
|
||||
* Agent avatar: the Agent's initial as colored ink on a light tinted tile
|
||||
* (the same letter-tile style ProviderLogo uses for user-defined model groups,
|
||||
* and the same soft 14%-alpha background the old pixel identicon used).
|
||||
*
|
||||
* 5x5 grid with left-right mirror symmetry (only the left 3 columns are randomized, the right 2
|
||||
* columns mirror back), a single soft-hue foreground, and a very light background of the same
|
||||
* color. No external dependencies — seeds mulberry32 with an FNV-1a hash.
|
||||
* The color hashes the agentId — not the display name — so it survives
|
||||
* renames; the initial comes from the display name when the caller has one,
|
||||
* falling back to the id. The ink switches shade with the theme via the
|
||||
* --tile-fg / --tile-fg-dark custom properties, keeping ≥ 4.5:1 contrast on
|
||||
* the tile for every hue in both themes (see lib/avatar.ts).
|
||||
*/
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
/** Grid side length (must be odd for left-right symmetry). */
|
||||
const N = 5;
|
||||
/** Number of columns that need randomizing (including the center column). */
|
||||
const HALF = Math.ceil(N / 2);
|
||||
|
||||
function hashStr(s: string): number {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
function mulberry32(a: number): () => number {
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** Randomly samples points on the left half + center column, mirrored into a full 5x5 boolean grid. */
|
||||
function buildGrid(rnd: () => number): boolean[][] {
|
||||
const grid: boolean[][] = Array.from({ length: N }, () => Array.from({ length: N }, () => false));
|
||||
for (let col = 0; col < HALF; col++) {
|
||||
for (let row = 0; row < N; row++) {
|
||||
// The center column has a slightly lower fill rate, to avoid a solid vertical line.
|
||||
const on = rnd() < (col === HALF - 1 ? 0.4 : 0.55);
|
||||
grid[row]![col] = on;
|
||||
grid[row]![N - 1 - col] = on;
|
||||
}
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
import { avatarInitial, avatarTile } from "../../lib/avatar";
|
||||
|
||||
export function AgentAvatar({
|
||||
id,
|
||||
name,
|
||||
size = 18,
|
||||
className,
|
||||
}: {
|
||||
id: string;
|
||||
/** Display name supplying the initial; omitted, the id's initial is used. */
|
||||
name?: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const rnd = mulberry32(hashStr(id || "agent"));
|
||||
const hue = Math.floor(rnd() * 360);
|
||||
const grid = buildGrid(rnd);
|
||||
// 24x24 viewport: 2px margin on each side, 4x4 cells.
|
||||
const cell = 4;
|
||||
const pad = 2;
|
||||
const fg = `hsl(${hue} 52% 46%)`;
|
||||
const tile = avatarTile(id);
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
style={{ "--tile-fg": tile.fg, "--tile-fg-dark": tile.fgDark } as CSSProperties}
|
||||
aria-hidden
|
||||
role="img"
|
||||
>
|
||||
<rect x="0" y="0" width="24" height="24" rx="5" fill={`hsl(${hue} 55% 50% / 0.14)`} />
|
||||
{grid.map((cols, row) =>
|
||||
cols.map((on, col) =>
|
||||
on ? (
|
||||
<rect
|
||||
key={`${row}-${col}`}
|
||||
x={pad + col * cell}
|
||||
y={pad + row * cell}
|
||||
width={cell}
|
||||
height={cell}
|
||||
fill={fg}
|
||||
/>
|
||||
) : null,
|
||||
),
|
||||
)}
|
||||
<rect x="0" y="0" width="24" height="24" rx="5" fill={tile.bg} />
|
||||
<text
|
||||
x="12"
|
||||
y="12"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize="13"
|
||||
fontWeight="700"
|
||||
className="[fill:var(--tile-fg)] dark:[fill:var(--tile-fg-dark)]"
|
||||
>
|
||||
{avatarInitial(name ?? "", id)}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,19 @@
|
||||
* vendor's brand mark (for recognition purposes, not under trademark license;
|
||||
* Qwen's official gradient wordmark is flattened to currentColor monochrome);
|
||||
* Z.AI uses a simplified geometric approximation of its branded glyph (not an
|
||||
* exact reproduction of the trademark); unknown vendors and custom models use
|
||||
* a generic cube. All are pure paths, no external image assets.
|
||||
* exact reproduction of the trademark); custom models use a generic cube. All
|
||||
* are pure paths, no external image assets.
|
||||
*
|
||||
* Vendor ids outside the preset table are user-defined groups: instead of all
|
||||
* sharing the cube (which made same-named models across groups
|
||||
* indistinguishable) each renders a letter tile — the group id's initial as
|
||||
* colored ink on a light tint hashed from the id (the soft tinted-tile style
|
||||
* AgentAvatar and the Skill tiles use, keeping the row's visual weight close
|
||||
* to the flat preset glyphs).
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
import { avatarInitial, avatarTile } from "../../lib/avatar";
|
||||
|
||||
interface Glyph {
|
||||
/** Solid marks (brand mark) use fill; line art uses stroke. */
|
||||
@@ -106,7 +115,35 @@ const GLYPHS: Record<string, Glyph> = {
|
||||
};
|
||||
|
||||
export function ProviderLogo({ provider, className }: { provider: string; className?: string }) {
|
||||
const glyph = GLYPHS[provider] ?? GLYPHS.custom!;
|
||||
const glyph = GLYPHS[provider];
|
||||
// User-defined group (only the preset `custom` id keeps the generic cube): letter tile.
|
||||
// SVG text scales with the viewBox, staying crisp at every call-site size; the ink is
|
||||
// theme-switched via --tile-fg / --tile-fg-dark (≥ 4.5:1 on the tile for every hue,
|
||||
// see lib/avatar.ts), so the surrounding text-color class only affects preset glyphs.
|
||||
if (!glyph) {
|
||||
const tile = avatarTile(provider);
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
style={{ "--tile-fg": tile.fg, "--tile-fg-dark": tile.fgDark } as CSSProperties}
|
||||
aria-hidden
|
||||
>
|
||||
<rect x="0" y="0" width="24" height="24" rx="5" fill={tile.bg} />
|
||||
<text
|
||||
x="12"
|
||||
y="12"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize="13"
|
||||
fontWeight="700"
|
||||
className="[fill:var(--tile-fg)] dark:[fill:var(--tile-fg-dark)]"
|
||||
>
|
||||
{avatarInitial(provider)}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
viewBox={glyph.viewBox ?? "0 0 24 24"}
|
||||
|
||||
@@ -197,7 +197,12 @@ export function AgentsPage() {
|
||||
<div className="min-w-[14rem] flex-1">
|
||||
{/* Title line: small avatar + name + agentId + active badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentAvatar id={a.agentId} size={18} className="shrink-0 rounded" />
|
||||
<AgentAvatar
|
||||
id={a.agentId}
|
||||
name={agentDisplayName(a)}
|
||||
size={18}
|
||||
className="shrink-0 rounded"
|
||||
/>
|
||||
{/* min-w-0: flex children don't shrink below their content by default; needed here to truncate overly long names */}
|
||||
<span className="min-w-0 truncate text-base font-bold">
|
||||
{agentDisplayName(a)}
|
||||
|
||||
@@ -87,7 +87,7 @@ function AgentNode({
|
||||
aria-label={open ? S.nav.collapseGroup : S.nav.expandGroup}
|
||||
className="flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<AgentAvatar id={agentId} size={18} className="shrink-0 rounded" />
|
||||
<AgentAvatar id={agentId} name={name} size={18} className="shrink-0 rounded" />
|
||||
<span className="min-w-0 truncate text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{name}
|
||||
</span>
|
||||
|
||||
@@ -587,7 +587,12 @@ function AgentSelect({
|
||||
className={pillClass}
|
||||
>
|
||||
{selected ? (
|
||||
<AgentAvatar id={selected.agentId} size={16} className="shrink-0 rounded" />
|
||||
<AgentAvatar
|
||||
id={selected.agentId}
|
||||
name={agentDisplayName(selected)}
|
||||
size={16}
|
||||
className="shrink-0 rounded"
|
||||
/>
|
||||
) : null}
|
||||
<span className="min-w-0 truncate">
|
||||
{selected ? agentDisplayName(selected) : S.common.loading}
|
||||
@@ -613,7 +618,12 @@ function AgentSelect({
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-1.5 text-left transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<AgentAvatar id={a.agentId} size={20} className="shrink-0 rounded" />
|
||||
<AgentAvatar
|
||||
id={a.agentId}
|
||||
name={agentDisplayName(a)}
|
||||
size={20}
|
||||
className="shrink-0 rounded"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={`block truncate text-xs ${
|
||||
|
||||
@@ -493,7 +493,7 @@ function InstallRow({
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md px-1.5 py-1.5 transition-colors duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/60">
|
||||
<AgentAvatar id={agentId} size={22} className="shrink-0 rounded" />
|
||||
<AgentAvatar id={agentId} name={name} size={22} className="shrink-0 rounded" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm" title={agentId}>
|
||||
{name}
|
||||
</span>
|
||||
|
||||
@@ -118,7 +118,7 @@ function AgentNode({
|
||||
aria-label={open ? S.nav.collapseGroup : S.nav.expandGroup}
|
||||
className="flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<AgentAvatar id={agentId} size={18} className="shrink-0 rounded" />
|
||||
<AgentAvatar id={agentId} name={name} size={18} className="shrink-0 rounded" />
|
||||
<span className="min-w-0 truncate text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{name}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Letter-avatar helpers shared by AgentAvatar and ProviderLogo's user-defined
|
||||
* groups: a deterministic tile color family hashed from a stable key (FNV-1a →
|
||||
* hue) plus the first user-perceived character of a display name.
|
||||
*
|
||||
* The color keys off an id rather than a name so it survives renames. The tile
|
||||
* is a light 14%-alpha tint (the same soft background the old pixel identicon
|
||||
* used) with the initial as colored ink rather than white-on-solid: hsl(h 55%
|
||||
* 28%) in light / hsl(h 55% 73%) in dark, lightness picked so the worst-case
|
||||
* hue keeps ≥ 4.5:1 (WCAG AA) contrast against the tinted tile on every app
|
||||
* surface — verified exhaustively over all 360 hues in test/avatar.test.ts.
|
||||
* Components apply the two inks via the --tile-fg / --tile-fg-dark custom
|
||||
* properties (theme toggled by html.dark).
|
||||
*/
|
||||
|
||||
/** FNV-1a 32-bit string hash (moved here from agent-avatar.tsx). */
|
||||
function hashStr(s: string): number {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/** Deterministic hue (0-359) for a stable key (agentId / provider id). */
|
||||
export function avatarHue(key: string): number {
|
||||
return hashStr(key) % 360;
|
||||
}
|
||||
|
||||
/** Tile colors for a stable key: translucent tint background + per-theme initial inks. */
|
||||
export function avatarTile(key: string): { bg: string; fg: string; fgDark: string } {
|
||||
const h = avatarHue(key);
|
||||
return {
|
||||
bg: `hsl(${h} 55% 50% / 0.14)`,
|
||||
fg: `hsl(${h} 55% 28%)`,
|
||||
fgDark: `hsl(${h} 55% 73%)`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Grapheme segmenter (granularity is locale-independent); code-point fallback on very old engines. */
|
||||
const graphemes =
|
||||
typeof Intl !== "undefined" && typeof Intl.Segmenter === "function"
|
||||
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
: undefined;
|
||||
|
||||
function firstGrapheme(s: string): string | undefined {
|
||||
if (!s) return undefined;
|
||||
if (!graphemes) return Array.from(s)[0];
|
||||
for (const g of graphemes.segment(s)) return g.segment;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* First user-perceived character of `text` — a full grapheme cluster, so CJK,
|
||||
* ZWJ emoji (👩💻) and flags (🇨🇦) stay whole — uppercased when it has a case;
|
||||
* empty/whitespace falls back to `fallback`'s initial, then "?".
|
||||
*/
|
||||
export function avatarInitial(text: string, fallback?: string): string {
|
||||
const ch = firstGrapheme(text.trim()) ?? firstGrapheme((fallback ?? "").trim());
|
||||
return ch ? ch.toUpperCase() : "?";
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Letter-avatar helper unit tests: hue/tile determinism and spread across
|
||||
* names; initial extraction — grapheme based (CJK / ZWJ emoji / flags stay
|
||||
* whole), uppercasing, and the empty/whitespace → fallback → "?" chain; and
|
||||
* an exhaustive WCAG check that both theme inks keep ≥ 4.5:1 contrast against
|
||||
* the tinted tile over every app surface, for all 360 hues.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { avatarHue, avatarInitial, avatarTile } from "../src/lib/avatar";
|
||||
|
||||
describe("avatarHue / avatarTile", () => {
|
||||
it("is deterministic for the same key", () => {
|
||||
expect(avatarHue("my-provider")).toBe(avatarHue("my-provider"));
|
||||
expect(avatarTile("agent-1")).toEqual(avatarTile("agent-1"));
|
||||
});
|
||||
|
||||
it("stays inside the 0-359 hue range", () => {
|
||||
for (const key of ["", "a", "my-provider", "深度求索", "🐧"]) {
|
||||
const hue = avatarHue(key);
|
||||
expect(Number.isInteger(hue)).toBe(true);
|
||||
expect(hue).toBeGreaterThanOrEqual(0);
|
||||
expect(hue).toBeLessThan(360);
|
||||
}
|
||||
});
|
||||
|
||||
it("spreads distinct hues across realistic group names", () => {
|
||||
const names = ["my-llm", "local-vllm", "team-proxy", "backup"];
|
||||
expect(new Set(names.map(avatarHue)).size).toBe(names.length);
|
||||
});
|
||||
|
||||
it("formats the tint background and the two theme inks from the key's hue", () => {
|
||||
const h = avatarHue("x");
|
||||
expect(avatarTile("x")).toEqual({
|
||||
bg: `hsl(${h} 55% 50% / 0.14)`,
|
||||
fg: `hsl(${h} 55% 28%)`,
|
||||
fgDark: `hsl(${h} 55% 73%)`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("avatarInitial", () => {
|
||||
it("takes the first character, uppercased", () => {
|
||||
expect(avatarInitial("my-provider")).toBe("M");
|
||||
expect(avatarInitial("Zeta")).toBe("Z");
|
||||
});
|
||||
|
||||
it("keeps case-less characters (CJK) as-is", () => {
|
||||
expect(avatarInitial("深度求索")).toBe("深");
|
||||
});
|
||||
|
||||
it("treats a surrogate-pair character as one initial", () => {
|
||||
expect(avatarInitial("🐧 harness")).toBe("🐧");
|
||||
});
|
||||
|
||||
it("keeps multi-code-point grapheme clusters whole (ZWJ emoji, flags)", () => {
|
||||
expect(avatarInitial("👩💻 dev tools")).toBe("👩💻");
|
||||
expect(avatarInitial("🇨🇦 north")).toBe("🇨🇦");
|
||||
});
|
||||
|
||||
it("trims whitespace before picking the initial", () => {
|
||||
expect(avatarInitial(" agent")).toBe("A");
|
||||
});
|
||||
|
||||
it("falls back to the fallback's initial, then ?", () => {
|
||||
expect(avatarInitial("", "agent-1")).toBe("A");
|
||||
expect(avatarInitial(" ", " x")).toBe("X");
|
||||
expect(avatarInitial("")).toBe("?");
|
||||
expect(avatarInitial(" ", " ")).toBe("?");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- WCAG contrast of the initial ink on the tinted tile (both themes, all hues) ----
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
const [r, g, b] =
|
||||
h < 60
|
||||
? [c, x, 0]
|
||||
: h < 120
|
||||
? [x, c, 0]
|
||||
: h < 180
|
||||
? [0, c, x]
|
||||
: h < 240
|
||||
? [0, x, c]
|
||||
: h < 300
|
||||
? [x, 0, c]
|
||||
: [c, 0, x];
|
||||
return [r + m, g + m, b + m];
|
||||
}
|
||||
|
||||
const lin = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
||||
const luminance = ([r, g, b]: [number, number, number]) =>
|
||||
0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||
|
||||
function contrast(a: [number, number, number], b: [number, number, number]): number {
|
||||
const la = luminance(a);
|
||||
const lb = luminance(b);
|
||||
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
}
|
||||
|
||||
/** The 14%-alpha tile tint composited over a surface (sRGB blend, like the browser). */
|
||||
function tileOver(h: number, surface: [number, number, number]): [number, number, number] {
|
||||
const tint = hslToRgb(h, 55, 50);
|
||||
return [0, 1, 2].map((i) => tint[i]! * 0.14 + surface[i]! * 0.86) as [number, number, number];
|
||||
}
|
||||
|
||||
const hexRgb = (s: string) =>
|
||||
[1, 3, 5].map((i) => parseInt(s.slice(i, i + 2), 16) / 255) as [number, number, number];
|
||||
|
||||
// App surfaces the tile sits on: light = white / gray-50 rows / gray-100 hover;
|
||||
// dark = the true-neutral overrides in styles.css (gray-900/800/700).
|
||||
const LIGHT_SURFACES = ["#ffffff", "#f9fafb", "#f3f4f6"].map(hexRgb);
|
||||
const DARK_SURFACES = ["#0d0d0d", "#1f1f1f", "#303030"].map(hexRgb);
|
||||
|
||||
/** Saturation/lightness actually emitted by avatarTile (parsed so this test can't drift from the implementation). */
|
||||
function parseSL(color: string): [number, number] {
|
||||
const m = /^hsl\(\d+ (\d+)% (\d+)%\)$/.exec(color);
|
||||
expect(m, `unexpected ink format: ${color}`).toBeTruthy();
|
||||
return [Number(m![1]), Number(m![2])];
|
||||
}
|
||||
|
||||
describe("avatarTile contrast (WCAG AA)", () => {
|
||||
it("keeps ≥ 4.5:1 for every hue: light ink on light surfaces, dark ink on dark surfaces", () => {
|
||||
const sample = avatarTile("x");
|
||||
const [fgS, fgL] = parseSL(sample.fg);
|
||||
const [fgDarkS, fgDarkL] = parseSL(sample.fgDark);
|
||||
for (let h = 0; h < 360; h++) {
|
||||
const fg = hslToRgb(h, fgS, fgL);
|
||||
const fgDark = hslToRgb(h, fgDarkS, fgDarkL);
|
||||
for (const surface of LIGHT_SURFACES) {
|
||||
expect(contrast(fg, tileOver(h, surface))).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
for (const surface of DARK_SURFACES) {
|
||||
expect(contrast(fgDark, tileOver(h, surface))).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user