diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3c222af926..4d152c7a52 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -113,6 +113,7 @@ "@xterm/addon-web-links": "0.12.0", "@xterm/addon-webgl": "0.19.0", "@xterm/xterm": "6.0.0", + "blobatar": "0.2.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 4787883a22..20c6f3b7eb 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -75,6 +75,9 @@ const SkillsView = typeof sdk === 'undefined' ? undefined : sdk.SkillsView // so those builds keep the staged checklists for remote targets. const skillsViewRoutesConnections = Boolean(SkillsView && SkillsView.supportsFixedConnection) const Streamdown = typeof sdk === 'undefined' ? undefined : sdk.Streamdown +// Deterministic blob avatars (name → face). Feature-detected: older SDKs +// without the export fall back to the legacy math-face shapes below. +const blobatarSvg = typeof sdk === 'undefined' ? undefined : sdk.blobatarSvg // Budgeted render loop (fps cap + observability pause + dormancy + teardown). // Feature-detected: older desktops fall back to the hand-rolled clock below. const createBudgetedLoop = typeof sdk === 'undefined' ? undefined : sdk.createBudgetedLoop @@ -834,6 +837,62 @@ function defaultShapeFor(name) { return AVATAR_SHAPES[hash % AVATAR_SHAPES.length] } +// ── blobatar shapes mode (default for new agents) ─────────────────────────── +// Deterministic soft-body faces drawn from a string. Shape strings: +// 'blobatar' — the face follows the bot's NAME (renaming the +// bot re-rolls the face, live in the dialog) +// 'blobatar:' — seed locked (the 🔒 lock / 🎲 randomize picks) +// 'blobatar::' — plus one of the six silhouettes pinned +// 'blobatar::' — silhouette pinned, seed still follows the name +// Bot names are slugs (NAME_RE) and generated seeds are base36, so ':' never +// appears inside a segment. Colors come from the library's own name-derived +// palette (contrast-guaranteed) — the classic color swatches don't apply. + +const BLOB_KINDS = ['round', 'organic', 'boxy', 'nub', 'cloud', 'sun'] + +// Trait positions at the center of each silhouette band. Band thresholds are +// frozen per blobatar major (0.28 / 0.58 / 0.72 / 0.84 / 0.93). +const BLOB_KIND_TRAIT = { round: 0.14, organic: 0.43, boxy: 0.65, nub: 0.78, cloud: 0.885, sun: 0.965 } + +function isBlobShape(shape) { + return shape === 'blobatar' || (typeof shape === 'string' && shape.startsWith('blobatar:')) +} + +function parseBlobShape(shape, name) { + const parts = typeof shape === 'string' ? shape.split(':') : [] + const seedPart = parts[1] || '' + const kind = BLOB_KINDS.includes(parts[2]) ? parts[2] : '' + return { seed: seedPart || name || 'agent', seedPart, kind } +} + +function blobShapeString(seedPart, kind) { + if (kind) { + return `blobatar:${seedPart}:${kind}` + } + return seedPart ? `blobatar:${seedPart}` : 'blobatar' +} + +/** Static SVG markup for a blob face, tagged data-bot-face so the roster's + * PNG backfill (pushLocalAvatars → rasterizeSvgToPng) still finds it. */ +function blobMarkup(shape, name, size) { + if (!blobatarSvg) { + return null + } + + const { seed, kind } = parseBlobShape(shape, name) + const opts = { size } + + if (kind) { + opts.traits = { shape: BLOB_KIND_TRAIT[kind] } + } + + try { + return blobatarSvg(seed, opts).replace(' { + const { seedPart, kind } = parseBlobShape(shape, pickerName) + const locked = Boolean(seedPart) + return jsxs('div', { + className: 'grid justify-items-center gap-3', + children: [ + // Silhouette pins: Auto (name decides) + the six blob kinds. + jsx('div', { + style: { + display: 'grid', + gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', + gap: '6px', + justifyItems: 'center' + }, + children: ['', ...BLOB_KINDS].map(k => + jsx( + 'button', + { + type: 'button', + title: k || 'Auto — the name decides', + className: cn( + 'flex items-center justify-center rounded-md transition-colors hover:bg-(--chrome-action-hover)', + k === kind && !image && 'ring-1 ring-(--ui-accent)' + ), + style: { width: 44, height: 44 }, + onClick: () => { + onImage(null) + onShape(blobShapeString(seedPart, k)) + }, + children: k + ? jsx(BotFace, { shape: blobShapeString(seedPart, k), color, size: 32, name: pickerName }) + : jsx('span', { className: 'text-[0.6rem] text-(--ui-text-tertiary)', children: 'Auto' }) + }, + k || 'auto' + ) + ) + }), + jsxs('div', { + className: 'flex items-center gap-1', + children: [ + jsxs(Button, { + type: 'button', + variant: 'ghost', + size: 'sm', + onClick: () => { + onImage(null) + onShape(blobShapeString(Math.random().toString(36).slice(2, 10), kind)) + }, + children: [jsx(Codicon, { name: 'refresh', className: 'mr-1 text-[0.8rem]' }), 'Randomize'] + }), + jsxs(Button, { + type: 'button', + variant: 'ghost', + size: 'sm', + title: locked + ? 'Unlock — the face follows the agent\u2019s name again' + : 'Keep this exact face even if the name changes', + onClick: () => onShape(blobShapeString(locked ? '' : pickerName, kind)), + children: [ + jsx(Codicon, { name: locked ? 'unlock' : 'lock', className: 'mr-1 text-[0.8rem]' }), + locked ? 'Unlock' : 'Lock face' + ] + }) + ] + }), + jsx('div', { + className: 'text-center text-[0.65rem] text-(--ui-text-quaternary)', + children: locked ? 'Face locked — renaming won\u2019t change it.' : 'Face follows the name.' + }), + jsx(Button, { + type: 'button', + variant: 'ghost', + size: 'sm', + className: 'text-(--ui-text-tertiary)', + onClick: () => onShape(defaultShapeFor(pickerName)), + children: 'Classic shapes' + }) + ] + }) + })() + : jsxs('div', { className: 'grid justify-items-center gap-3', children: [ jsx('div', { @@ -2081,11 +2241,12 @@ function AvatarPicker({ shape, color, image, onShape, onColor, onImage, generate gap: '6px', justifyItems: 'center' }, - children: AVATAR_PICKER_SHAPES.map(s => + children: (blobatarSvg ? ['blobatar', ...AVATAR_PICKER_SHAPES] : AVATAR_PICKER_SHAPES).map(s => jsx( 'button', { type: 'button', + title: s === 'blobatar' ? 'Blob face — drawn from the agent\u2019s name' : undefined, className: cn( 'flex items-center justify-center rounded-md transition-colors hover:bg-(--chrome-action-hover)', s === shape && !image && 'ring-1 ring-(--ui-accent)' @@ -5764,7 +5925,9 @@ function CreateAgentDialog({ open, onClose, roster }) { const flightRef = useRef(null) const [title, setTitle] = useState('') const [description, setDescription] = useState('') - const [shape, setShape] = useState('circle') + // Default shapes mode: deterministic blob face drawn from the agent's name + // (falls back to the legacy shape vocabulary on older SDKs). + const [shape, setShape] = useState(blobatarSvg ? 'blobatar' : 'circle') const [color, setColor] = useState(AVATAR_COLORS[3]) const [image, setImage] = useState(null) const [advanced, setAdvanced] = useState(false) @@ -5861,7 +6024,7 @@ function CreateAgentDialog({ open, onClose, roster }) { setName('') setTitle('') setDescription('') - setShape('circle') + setShape(blobatarSvg ? 'blobatar' : 'circle') setColor(AVATAR_COLORS[3]) setImage(null) setAdvanced(false) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/blobatar-shapes.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/blobatar-shapes.test.mjs new file mode 100644 index 0000000000..2806c52d21 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/tests/blobatar-shapes.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import vm from 'node:vm' + +const pluginSource = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') + +function load({ blobatarSvg } = {}) { + const values = new Map() + const atom = initial => { + const slot = { get: () => values.get(slot), set: value => values.set(slot, value) } + values.set(slot, initial) + return slot + } + const sdkStub = new Proxy( + { blobatarSvg }, + { get: (target, key) => (key in target ? target[key] : undefined) } + ) + const context = { + atom, + PALETTE_AREA: 'palette', + COMPOSER_AREAS: { middleware: 'middleware' }, + document: { getElementById: () => null, createElement: () => ({}), head: { appendChild: () => undefined } }, + host: { state: { profile: { listen: () => undefined }, gateway: { listen: () => undefined } } }, + sdk: sdkStub + } + const source = pluginSource + .replace(/^import\s+\*\s+as\s+sdk\s+from '@hermes\/plugin-sdk'\r?\n/m, '') + .replace(/^import\s+\{[\s\S]*?\}\s+from '@hermes\/plugin-sdk'\r?\n/m, '') + .replace(/^const \{ McpTab, ToolsetConfigPanel \} = sdk\r?\n/m, '') + .replace(/^import .* from 'react'\r?\n/m, '') + .replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '') + .replace('export default {', 'globalThis.plugin = {') + .concat(` +globalThis.__blob = { isBlobShape, parseBlobShape, blobShapeString, blobMarkup, BLOB_KINDS, BLOB_KIND_TRAIT }; +`) + vm.runInNewContext(source, context, { filename: 'plugin.js' }) + return context.__blob +} + +test('blob shape strings round-trip through parse/build', () => { + const b = load() + + assert.equal(b.isBlobShape('blobatar'), true) + assert.equal(b.isBlobShape('blobatar:seed123'), true) + assert.equal(b.isBlobShape('blobatar::sun'), true) + assert.equal(b.isBlobShape('circle'), false) + assert.equal(b.isBlobShape(undefined), false) + + // Unlocked: seed follows the name. + assert.equal(JSON.stringify(b.parseBlobShape('blobatar', 'inbox-triage')), JSON.stringify({ seed: 'inbox-triage', seedPart: '', kind: '' })) + // Locked seed. + assert.equal(JSON.stringify(b.parseBlobShape('blobatar:abc123', 'inbox-triage')), JSON.stringify({ seed: 'abc123', seedPart: 'abc123', kind: '' })) + // Pinned silhouette, unlocked seed. + assert.equal(JSON.stringify(b.parseBlobShape('blobatar::cloud', 'inbox-triage')), JSON.stringify({ seed: 'inbox-triage', seedPart: '', kind: 'cloud' })) + // Unknown silhouette is ignored, never trusted. + assert.equal(b.parseBlobShape('blobatar:abc:mystery', 'x').kind, '') + + assert.equal(b.blobShapeString('', ''), 'blobatar') + assert.equal(b.blobShapeString('abc', ''), 'blobatar:abc') + assert.equal(b.blobShapeString('abc', 'sun'), 'blobatar:abc:sun') + assert.equal(b.blobShapeString('', 'sun'), 'blobatar::sun') +}) + +test('every silhouette has a trait position inside its frozen band', () => { + const b = load() + const bands = { round: [0, 0.28], organic: [0.28, 0.58], boxy: [0.58, 0.72], nub: [0.72, 0.84], cloud: [0.84, 0.93], sun: [0.93, 1] } + + for (const kind of b.BLOB_KINDS) { + const v = b.BLOB_KIND_TRAIT[kind] + assert.equal(typeof v, 'number', kind) + assert.ok(v >= bands[kind][0] && v < bands[kind][1], `${kind} trait ${v} outside band`) + } +}) + +test('blobMarkup renders via the SDK export, tags data-bot-face, pins traits', () => { + const calls = [] + const b = load({ + blobatarSvg: (seed, opts) => { + calls.push({ seed, opts }) + return '' + } + }) + + const markup = b.blobMarkup('blobatar', 'inbox-triage', 56) + assert.ok(markup.startsWith(' { + const withoutSdk = load() + assert.equal(withoutSdk.blobMarkup('blobatar', 'x', 32), null) + + const throwing = load({ + blobatarSvg: () => { + throw new Error('boom') + } + }) + assert.equal(throwing.blobMarkup('blobatar', 'x', 32), null) +}) diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 7243a2973d..6ffdd1b297 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -915,6 +915,10 @@ export { useStore as useValue } from '@nanostores/react' * the app root, so their queries cache, dedupe, poll (`refetchInterval`), and * invalidate exactly like core screens — no hand-rolled atoms or polls. */ export { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +/** Deterministic soft-body avatars from any string (name → face). String + * renderer for rasterization; React component for live rendering. */ +export { blobatar as blobatarSvg } from 'blobatar/blob' +export { Blobatar } from 'blobatar/react' /** Plugin-local reactive state (share between a trigger and its panel, poll * loops, cross-component signals) — the same primitive `host.state` uses. */ export { atom, computed } from 'nanostores' diff --git a/package-lock.json b/package-lock.json index 451f5a10df..dcb2b160f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,6 +99,7 @@ "@xterm/addon-web-links": "0.12.0", "@xterm/addon-webgl": "0.19.0", "@xterm/xterm": "6.0.0", + "blobatar": "0.2.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", @@ -1834,6 +1835,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -1850,6 +1852,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1866,6 +1869,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1882,6 +1886,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1898,6 +1903,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -1914,6 +1920,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -1930,6 +1937,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1946,6 +1954,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1962,6 +1971,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1978,6 +1988,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1994,6 +2005,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2010,6 +2022,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2026,6 +2039,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2042,6 +2056,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2058,6 +2073,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2074,6 +2090,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2090,6 +2107,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2106,6 +2124,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2122,6 +2141,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2138,6 +2158,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2154,6 +2175,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2170,6 +2192,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -2186,6 +2209,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -2202,6 +2226,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -2218,6 +2243,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -2234,6 +2260,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -7664,6 +7691,20 @@ "react": ">=17.0.1" } }, + "node_modules/blobatar": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/blobatar/-/blobatar-0.2.0.tgz", + "integrity": "sha512-TL4blQf9fu+EzsFEKMjh7PVORnf42G9HdpaXzdLR9dTHcMSYJWEj5fPeLhsXTN5YFCNstZaRAQRAWyhW8lRAdw==", + "license": "MIT", + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -17633,6 +17674,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } diff --git a/website/docs/user-guide/bot-mode.md b/website/docs/user-guide/bot-mode.md index 3fcdd03019..c1204dfead 100644 --- a/website/docs/user-guide/bot-mode.md +++ b/website/docs/user-guide/bot-mode.md @@ -60,7 +60,8 @@ Remote-creation notes: Every Bot gets a face: -- **Geometric faces** — 7 shapes × 10 colors, with blinking eyes that scan while the Bot works. +- **Blob faces** (default) — a deterministic soft-body face drawn from the Bot's name: same name, same face, forever. While you type a name in New Agent the face follows it live; hit **Randomize** to re-roll, **Lock face** to keep the one you like even if the name changes, or pin one of the six silhouettes (round, organic, boxy, nub, cloud, sun) while everything else still comes from the name. +- **Geometric faces** — the classic 7 shapes × 10 colors, with blinking eyes that scan while the Bot works. - **An uploaded image** — any picture you like. - **An AI-generated portrait** — when an image backend is configured, generated in place (this rides the standard `image.generate` RPC and works over both local and remote gateways). - **A pixel pet** — a companion from the [petdex gallery](./features/pets.md) that bounces beside the avatar while the Bot is busy. Run `hermes pets` in a terminal to explore the gallery.