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:
Yaowei Zheng
2026-07-22 22:04:34 +08:00
committed by GitHub
parent c9cd1f9dc5
commit 6d25adfef4
10 changed files with 300 additions and 75 deletions
@@ -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>
+27 -64
View File
@@ -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"}