From 5afa487e935535b8e262c7ccfcf49c30b34d9097 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 27 Aug 2026 18:40:45 -0500 Subject: [PATCH] refactor(desktop): rebuild Bot Mode on the app's design system Bot Mode arrived as a 16,933-line plugin.js that reimplemented most of the app: its own scroll container, color palette, status dots, empty states, buttons, time formatting and cron surface, none of which could follow the theme. Split it into 41 focused modules and route every one of those through the primitives core already ships, so a Bot row now renders the same SessionStatusDot, swatches and age labels as the session row beside it. User-facing strings move into a plugin locale bundle instead of sitting inline, and the UI settles on "bot" as the noun (model-facing prompt text still says "agent"). The codemod scaffolding that drove the jsx() -> TSX conversion retires with the conversion. --- apps/desktop/scripts/codemod/dejsx.mjs | 251 - apps/desktop/scripts/codemod/verify.mjs | 267 - .../src/plugins/hermes-bots/avatar-image.ts | 124 + .../src/plugins/hermes-bots/avatar-picker.tsx | 282 + .../src/plugins/hermes-bots/avatar.tsx | 1006 + .../src/plugins/hermes-bots/bot-row.tsx | 480 + .../src/plugins/hermes-bots/bot-state.ts | 158 + .../src/plugins/hermes-bots/canonical-chat.ts | 494 + .../src/plugins/hermes-bots/create-dialog.tsx | 1293 ++ apps/desktop/src/plugins/hermes-bots/cron.tsx | 1214 ++ apps/desktop/src/plugins/hermes-bots/data.ts | 1218 ++ .../src/plugins/hermes-bots/dialog-parts.tsx | 19 + .../hermes-bots/edit-profile-dialog.tsx | 240 + .../src/plugins/hermes-bots/group-activity.ts | 115 + .../plugins/hermes-bots/group-attachments.ts | 100 + .../plugins/hermes-bots/group-chat-parts.tsx | 503 + .../plugins/hermes-bots/group-chat-view.tsx | 1302 ++ .../src/plugins/hermes-bots/group-chat.ts | 1376 ++ .../plugins/hermes-bots/group-membership.ts | 321 + .../src/plugins/hermes-bots/group-panes.ts | 144 + .../src/plugins/hermes-bots/group-rounds.ts | 933 + .../src/plugins/hermes-bots/group-turns.ts | 719 + .../src/plugins/hermes-bots/hidden-bots.ts | 50 + .../src/plugins/hermes-bots/hygiene.ts | 87 + apps/desktop/src/plugins/hermes-bots/i18n.ts | 270 + .../desktop/src/plugins/hermes-bots/labels.ts | 85 + .../src/plugins/hermes-bots/mcp-setup.tsx | 366 + .../src/plugins/hermes-bots/model-picker.tsx | 283 + apps/desktop/src/plugins/hermes-bots/pet.tsx | 253 + .../src/plugins/hermes-bots/plugin.tsx | 15656 +--------------- .../plugins/hermes-bots/profile-config.tsx | 623 + .../src/plugins/hermes-bots/profile-ops.ts | 410 + apps/desktop/src/plugins/hermes-bots/relay.ts | 413 + .../src/plugins/hermes-bots/roster-actions.ts | 225 + .../src/plugins/hermes-bots/roster-pane.tsx | 869 + .../plugins/hermes-bots/roster-sections.tsx | 288 + .../src/plugins/hermes-bots/routing.ts | 358 + .../src/plugins/hermes-bots/row-helpers.ts | 164 + .../src/plugins/hermes-bots/session-sweep.ts | 263 + .../desktop/src/plugins/hermes-bots/shared.ts | 38 + .../src/plugins/hermes-bots/skills-hub.tsx | 255 + apps/desktop/src/plugins/hermes-bots/soul.ts | 167 + .../tests/active-now-strip.test.mjs | 33 +- .../tests/bots-home-refront-loop.test.mjs | 218 - .../hermes-bots/tests/bots-home.test.mjs | 1107 -- .../tests/focused-bot-highlight.test.mjs | 2 +- .../tests/hide-bot-chats.runtime.test.ts | 1 - .../tests/pane-dock-layout.test.mjs | 2 +- .../tests/profile-prewarm.test.mjs | 2 - .../tests/routines-pane-owner.test.mjs | 4 +- apps/desktop/src/plugins/hermes-bots/types.ts | 298 + 51 files changed, 17946 insertions(+), 17403 deletions(-) delete mode 100644 apps/desktop/scripts/codemod/dejsx.mjs delete mode 100644 apps/desktop/scripts/codemod/verify.mjs create mode 100644 apps/desktop/src/plugins/hermes-bots/avatar-image.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/avatar-picker.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/avatar.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/bot-row.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/bot-state.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/canonical-chat.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/create-dialog.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/cron.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/data.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/dialog-parts.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/edit-profile-dialog.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/group-activity.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/group-attachments.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/group-chat-parts.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/group-chat-view.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/group-chat.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/group-membership.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/group-panes.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/group-rounds.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/group-turns.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/hidden-bots.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/hygiene.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/i18n.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/labels.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/mcp-setup.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/model-picker.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/pet.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/profile-config.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/profile-ops.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/relay.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/roster-actions.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/roster-pane.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/roster-sections.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/routing.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/row-helpers.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/session-sweep.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/shared.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/skills-hub.tsx create mode 100644 apps/desktop/src/plugins/hermes-bots/soul.ts delete mode 100644 apps/desktop/src/plugins/hermes-bots/tests/bots-home-refront-loop.test.mjs delete mode 100644 apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs create mode 100644 apps/desktop/src/plugins/hermes-bots/types.ts diff --git a/apps/desktop/scripts/codemod/dejsx.mjs b/apps/desktop/scripts/codemod/dejsx.mjs deleted file mode 100644 index cb0b75b208..0000000000 --- a/apps/desktop/scripts/codemod/dejsx.mjs +++ /dev/null @@ -1,251 +0,0 @@ -/** - * Inverse-compile `jsx()` / `jsxs()` runtime calls back into JSX syntax. - * - * hermes-bots/plugin.js is hand-written JSX compiler output: it imports - * { jsx, jsxs } from 'react/jsx-runtime' and calls them directly. That makes - * the conversion to real JSX a deterministic AST transform rather than a - * rewrite. - * - * Usage: node dejsx.mjs - */ -import { readFileSync, writeFileSync } from 'node:fs' - -import generate from '@babel/generator' -import { parse } from '@babel/parser' -import traverse from '@babel/traverse' -import * as t from '@babel/types' - -const gen = generate.default ?? generate -const walk = traverse.default ?? traverse - -const [, , inPath, outPath] = process.argv - -const source = readFileSync(inPath, 'utf8') -const ast = parse(source, { - sourceType: 'module', - plugins: ['jsx'], - attachComment: true -}) - -const stats = { jsx: 0, jsxs: 0, skipped: [] } - -/** `'div'` → intrinsic; `Foo` → component; `Foo.Bar` → member. */ -function toJsxName(node) { - if (t.isStringLiteral(node)) { - // Only bare tag names are expressible as JSX intrinsics. - return /^[a-z][a-z0-9]*$/i.test(node.value) ? t.jsxIdentifier(node.value) : null - } - - if (t.isIdentifier(node)) { - return t.jsxIdentifier(node.name) - } - - if (t.isMemberExpression(node) && !node.computed) { - const object = toJsxName(node.object) - const property = t.isIdentifier(node.property) ? t.jsxIdentifier(node.property.name) : null - - return object && property ? t.jsxMemberExpression(object, property) : null - } - - return null -} - -/** JSX attribute names permit dashes and colons, so most keys pass through. */ -function toAttrName(key, computed) { - if (computed) { - return null - } - - const raw = t.isIdentifier(key) ? key.name : t.isStringLiteral(key) ? key.value : null - - return raw && /^[A-Za-z_$][-:A-Za-z0-9_$]*$/.test(raw) ? t.jsxIdentifier(raw) : null -} - -/** - * A string child is safe as bare JSXText only when it survives a round trip: - * no JSX metacharacters, and no leading/trailing whitespace (which JSX trims). - */ -function textIsSafe(value) { - return value.length > 0 && !/[{}<>]/.test(value) && value === value.trim() && !/\n/.test(value) -} - -function toChild(node) { - if (t.isJSXElement(node) || t.isJSXFragment(node)) { - return node - } - - if (t.isStringLiteral(node) && textIsSafe(node.value)) { - return t.jsxText(node.value) - } - - // `null` / `false` children are pure noise once inlined, but they carry - // meaning inside conditionals, so only drop the standalone literals. - if (t.isNullLiteral(node) || (t.isBooleanLiteral(node) && node.value === false)) { - return null - } - - return t.jsxExpressionContainer(node) -} - -/** - * Comments between children have to become `{/* … *␟/}` — a bare `//` in JSX - * children position is literal text, not a comment. Line comments are - * rewritten as block comments so they survive the move. - */ -function commentChild(comments) { - const empty = t.jsxEmptyExpression() - - empty.innerComments = comments.map(comment => ({ - type: 'CommentBlock', - value: comment.type === 'CommentLine' ? ` ${comment.value.trim()} ` : comment.value - })) - - return t.jsxExpressionContainer(empty) -} - -function childrenFrom(node) { - const items = t.isArrayExpression(node) ? node.elements : [node] - const children = [] - - for (const item of items) { - if (!item) { - continue - } - - if (item.leadingComments?.length) { - children.push(commentChild(item.leadingComments)) - item.leadingComments = null - } - - const child = t.isSpreadElement(item) ? t.jsxExpressionContainer(item.argument) : toChild(item) - - if (child) { - children.push(child) - } - - if (item.trailingComments?.length) { - children.push(commentChild(item.trailingComments)) - item.trailingComments = null - } - } - - return children -} - -function convert(path) { - const { node } = path - const [type, props, key] = node.arguments - - const name = toJsxName(type) - - if (!name) { - stats.skipped.push(`${node.loc?.start.line}: dynamic element type`) - - return - } - - if (!t.isObjectExpression(props)) { - stats.skipped.push(`${node.loc?.start.line}: non-literal props`) - - return - } - - const attributes = [] - let children = [] - - if (key) { - attributes.push(t.jsxAttribute(t.jsxIdentifier('key'), t.jsxExpressionContainer(key))) - } - - for (const prop of props.properties) { - if (t.isSpreadElement(prop)) { - attributes.push(t.jsxSpreadAttribute(prop.argument)) - - continue - } - - if (!t.isObjectProperty(prop)) { - stats.skipped.push(`${node.loc?.start.line}: object method in props`) - - return - } - - const attrName = toAttrName(prop.key, prop.computed) - - if (!attrName) { - stats.skipped.push(`${node.loc?.start.line}: unexpressible prop key`) - - return - } - - if (attrName.name === 'children') { - children = childrenFrom(prop.value) - - continue - } - - // `foo={true}` is idiomatic as a bare `foo`; string values print unquoted. - const value = - t.isStringLiteral(prop.value) && !/[\n"]/.test(prop.value.value) - ? t.stringLiteral(prop.value.value) - : t.isBooleanLiteral(prop.value) && prop.value.value === true - ? null - : t.jsxExpressionContainer(prop.value) - - const attribute = t.jsxAttribute(attrName, value) - - // Carry the explanatory comments that sit above props — this file's - // comments are most of its documentation. - if (prop.leadingComments?.length) { - attribute.leadingComments = prop.leadingComments - } - - attributes.push(attribute) - } - - const selfClosing = children.length === 0 - const element = t.jsxElement( - t.jsxOpeningElement(name, attributes, selfClosing), - selfClosing ? null : t.jsxClosingElement(name), - children, - selfClosing - ) - - t.inherits(element, node) - path.replaceWith(element) - - stats[node.callee.name] += 1 -} - -walk(ast, { - CallExpression: { - // Post-order: inner calls are already JSXElements by the time we rebuild - // the parent, so they slot straight in as children. - exit(path) { - const callee = path.node.callee - - if (t.isIdentifier(callee) && (callee.name === 'jsx' || callee.name === 'jsxs')) { - convert(path) - } - } - } -}) - -// The runtime import is what we just eliminated. -walk(ast, { - ImportDeclaration(path) { - if (path.node.source.value === 'react/jsx-runtime') { - path.remove() - } - } -}) - -const output = gen(ast, { jsescOption: { minimal: true }, retainLines: false, comments: true }, source) - -writeFileSync(outPath, output.code) - -console.log(`jsx: ${stats.jsx} jsxs: ${stats.jsxs} skipped: ${stats.skipped.length}`) - -for (const skip of stats.skipped.slice(0, 40)) { - console.log(` skip ${skip}`) -} diff --git a/apps/desktop/scripts/codemod/verify.mjs b/apps/desktop/scripts/codemod/verify.mjs deleted file mode 100644 index a1c0097776..0000000000 --- a/apps/desktop/scripts/codemod/verify.mjs +++ /dev/null @@ -1,267 +0,0 @@ -/** - * Prove the de-JSX codemod was lossless. - * - * Recompiles the generated .tsx back down to jsx-runtime calls with esbuild, - * then compares it against the original hand-written source. Both sides are - * normalized through the same printer so the diff reflects semantics, not - * formatting. - * - * jsx/jsxs selection is deliberately collapsed: the pair differ only in React's - * dev-mode static-children key warning, and esbuild picks between them on its - * own rules rather than the ones the file was hand-written with. - */ -import { readFileSync } from 'node:fs' - -import generate from '@babel/generator' -import { parse } from '@babel/parser' -import traverse from '@babel/traverse' -import * as t from '@babel/types' -import { transformSync } from 'esbuild' - -const gen = generate.default ?? generate -const walk = traverse.default ?? traverse - -const [, , originalPath, convertedPath] = process.argv - -const allowed = { spreadChildren: 0, redundantKeyProp: 0 } - -function normalize(code, plugins) { - const ast = parse(code, { sourceType: 'module', plugins }) - - walk(ast, { - /** - * JSX has no spread-children syntax: `children: [...items.map(f), x]` can - * only be written `{items.map(f)}`, which compiles to - * `children: [items.map(f), x]`. React flattens nested array children and - * the mapped elements keep their explicit keys, so the two are equivalent - * — this is the idiom the whole ecosystem writes. Fold the spread away on - * the original side so the comparison doesn't flag it, and count it. - */ - ObjectProperty(path) { - if (!t.isIdentifier(path.node.key, { name: 'children' }) || !t.isArrayExpression(path.node.value)) { - return - } - - path.node.value.elements = path.node.value.elements.map(element => { - if (!t.isSpreadElement(element)) { - return element - } - - allowed.spreadChildren += 1 - - return element.argument - }) - }, - // Drop captured raw text so both sides re-print literals canonically — - // one quote style, and `48000` rather than esbuild's `48e3`. - 'StringLiteral|NumericLiteral'(path) { - delete path.node.extra - }, - // Template chunks keep their raw text, so an emoji written as a surrogate - // pair on one side and a code point on the other reads as a diff. - TemplateElement(path) { - const { cooked } = path.node.value - - if (typeof cooked === 'string') { - path.node.value.raw = cooked.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${') - } - }, - /** - * Some call sites pass `key` twice — inside props AND as the third - * argument. React's jsx runtime takes the key from the third argument and - * never copies `key` into props, so the props copy is dead either way. - */ - CallExpression(path) { - const callee = path.node.callee - const [, props] = path.node.arguments - - if (!t.isIdentifier(callee) || callee.name !== 'jsx' || !t.isObjectExpression(props)) { - return - } - - props.properties = props.properties.filter(prop => { - if (t.isObjectProperty(prop) && !prop.computed && t.isIdentifier(prop.key, { name: 'key' })) { - allowed.redundantKeyProp += 1 - - return false - } - - return true - }) - }, - // esbuild folds adjacent string/template concatenation into one literal. - BinaryExpression: { - exit(path) { - const { operator, left, right } = path.node - - if (operator !== '+') { - return - } - - if (t.isStringLiteral(left) && t.isStringLiteral(right)) { - path.replaceWith(t.stringLiteral(left.value + right.value)) - - return - } - - // `` `a${x}b` + 'c' `` → `` `a${x}bc` `` - if (t.isTemplateLiteral(left) && t.isStringLiteral(right)) { - const quasis = left.quasis.map(quasi => t.cloneNode(quasi)) - const last = quasis[quasis.length - 1] - - last.value = { raw: last.value.cooked + right.value, cooked: last.value.cooked + right.value } - path.replaceWith(t.templateLiteral(quasis, left.expressions)) - - return - } - - // `'a' + `b${x}` ` → `` `ab${x}` `` - if (t.isStringLiteral(left) && t.isTemplateLiteral(right)) { - const quasis = right.quasis.map(quasi => t.cloneNode(quasi)) - const first = quasis[0] - - first.value = { raw: left.value + first.value.cooked, cooked: left.value + first.value.cooked } - path.replaceWith(t.templateLiteral(quasis, right.expressions)) - - return - } - - // `` `a${x}` + `b${y}` `` → `` `a${x}b${y}` `` — the seam quasis merge. - if (t.isTemplateLiteral(left) && t.isTemplateLiteral(right)) { - const leftQuasis = left.quasis.map(quasi => t.cloneNode(quasi)) - const rightQuasis = right.quasis.map(quasi => t.cloneNode(quasi)) - const seam = leftQuasis.pop() - const head = rightQuasis.shift() - const merged = (seam.value.cooked ?? '') + (head.value.cooked ?? '') - - seam.value = { raw: merged, cooked: merged } - path.replaceWith( - t.templateLiteral([...leftQuasis, seam, ...rightQuasis], [...left.expressions, ...right.expressions]) - ) - } - } - }, - // esbuild prefers a template literal when it saves escaping. - TemplateLiteral: { - exit(path) { - if (path.node.expressions.length === 0) { - path.replaceWith(t.stringLiteral(path.node.quasis[0].value.cooked ?? '')) - } - } - }, - // esbuild rewrites `undefined` to `void 0`. - UnaryExpression(path) { - if (path.node.operator === 'void' && t.isNumericLiteral(path.node.argument, { value: 0 })) { - path.replaceWith(t.identifier('undefined')) - } - }, - // esbuild emits `import { jsx as _jsx }`; the original imports it bare. - Identifier(path) { - if (path.node.name === '_jsx' || path.node.name === '_jsxs') { - path.node.name = 'jsx' - } - - if (path.node.name === 'jsxs') { - path.node.name = 'jsx' - } - }, - ImportDeclaration(path) { - if (path.node.source.value === 'react/jsx-runtime') { - path.remove() - } - }, - // esbuild hoists the spread helper for `{...props}` on intrinsics. - VariableDeclarator(path) { - if (t.isIdentifier(path.node.id) && /^__(spread|assign|objRest)/.test(path.node.id.name)) { - path.remove() - } - } - }) - - // esbuild hoists `export default {…}` into - // `var X = {…}; export { X as default }`. - const body = ast.program.body - const exportIndex = body.findIndex( - node => - (t.isExportDefaultDeclaration(node) && t.isIdentifier(node.declaration)) || - (t.isExportNamedDeclaration(node) && - !node.declaration && - node.specifiers.length === 1 && - t.isExportSpecifier(node.specifiers[0]) && - t.isIdentifier(node.specifiers[0].exported, { name: 'default' })) - ) - - if (exportIndex !== -1) { - const exported = body[exportIndex] - const name = t.isExportDefaultDeclaration(exported) - ? exported.declaration.name - : exported.specifiers[0].local.name - const declIndex = body.findIndex( - node => - t.isVariableDeclaration(node) && - node.declarations.length === 1 && - t.isIdentifier(node.declarations[0].id, { name }) - ) - - if (declIndex !== -1) { - body[exportIndex] = t.exportDefaultDeclaration(body[declIndex].declarations[0].init) - body.splice(declIndex, 1) - } - } - - // esbuild renames locals that shadow an outer binding (`displayName` → - // `displayName2`). Canonically rename every binding in declaration order on - // both sides so the comparison tests alpha-equivalence rather than spelling. - // Numbered per scope, not globally: one extra binding on one side then - // shifts only its own scope instead of desynchronizing the whole file. - let scopeId = 0 - - walk(ast, { - Scopable(path) { - const bindings = Object.values(path.scope.bindings).sort( - (a, b) => (a.identifier.start ?? 0) - (b.identifier.start ?? 0) - ) - const scope = (scopeId += 1) - - bindings.forEach((binding, index) => { - path.scope.rename(binding.identifier.name, `__s${scope}_${index}`) - }) - } - }) - - return gen(ast, { comments: false, compact: true, jsescOption: { minimal: true } }).code -} - -const original = normalize(readFileSync(originalPath, 'utf8'), []) -const recompiled = normalize( - transformSync(readFileSync(convertedPath, 'utf8'), { - loader: 'tsx', - jsx: 'automatic', - format: 'esm', - target: 'esnext' - }).code, - [] -) - -console.log( - `allowed rewrites — spread children folded: ${allowed.spreadChildren}, redundant key props dropped: ${allowed.redundantKeyProp}` -) - -if (original === recompiled) { - console.log(`IDENTICAL — ${original.length} chars of normalized output match`) - process.exit(0) -} - -console.log(`DIFFER — original ${original.length} chars, recompiled ${recompiled.length} chars`) - -// Report the first divergence with surrounding context so it can be chased. -let i = 0 -while (i < original.length && original[i] === recompiled[i]) { - i += 1 -} - -const window = 260 -console.log(`\nfirst divergence at char ${i}:`) -console.log(`\n--- original ---\n${original.slice(Math.max(0, i - window), i + window)}`) -console.log(`\n--- recompiled ---\n${recompiled.slice(Math.max(0, i - window), i + window)}`) -process.exit(1) diff --git a/apps/desktop/src/plugins/hermes-bots/avatar-image.ts b/apps/desktop/src/plugins/hermes-bots/avatar-image.ts new file mode 100644 index 0000000000..40613c58b7 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/avatar-image.ts @@ -0,0 +1,124 @@ +/** + * Image avatars: the device-upload path and the gateway's `image.generate` + * backend (probe + generation). Kept apart from `avatar.tsx` so the roster's + * render path doesn't drag the picker's network surface along with it. + */ + +import { atom, host } from '@hermes/plugin-sdk' + +import { getPluginCtx } from './shared' + +// ── image avatars: upload from device + generate via image.generate ───────── + +/** Downscale to a small square so plugin storage stays light. */ +export function normalizeAvatarImage(dataUrl: string, edge = 256): Promise { + return new Promise(resolve => { + const img = new Image() + img.onload = () => { + try { + const canvas = document.createElement('canvas') + canvas.width = edge + canvas.height = edge + const ctx2d = canvas.getContext('2d')! + const side = Math.min(img.width, img.height) + ctx2d.drawImage(img, (img.width - side) / 2, (img.height - side) / 2, side, side, 0, 0, edge, edge) + resolve(canvas.toDataURL('image/png')) + } catch { + resolve(dataUrl) + } + } + img.onerror = () => resolve(dataUrl) + img.src = dataUrl + }) +} +export function pickImageFromDevice(): Promise { + return new Promise(resolve => { + const input = document.createElement('input') + input.type = 'file' + input.accept = 'image/png,image/jpeg,image/webp,image/gif' + input.onchange = () => { + const file = input.files?.[0] + if (!file) { + return resolve(null) + } + if (file.size > 15_000_000) { + host.notify({ + kind: 'error', + message: getPluginCtx()?.i18n.t('avatar.imageTooLarge') ?? 'Image too large (max 15MB).' + }) + return resolve(null) + } + const reader = new FileReader() + reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : null) + reader.onerror = () => resolve(null) + reader.readAsDataURL(file) + } + input.click() + }) +} + +/** Cached probe: does the gateway have an image backend? A `false` answer + * is re-checked on every dialog open — the gateway may have been restarted + * (picking up image.generate) or a backend enabled since the last probe. + * Only `true` is sticky. */ +export const $imagenAvailable = atom(null) +let imagenProbeInflight: Promise | null = null +export function probeImagen() { + if (imagenProbeInflight) { + return imagenProbeInflight + } + imagenProbeInflight = host + .request<{ available?: boolean }>('image.generate', { + probe: true + }) + .then(res => $imagenAvailable.set(Boolean(res?.available))) + .catch(() => $imagenAvailable.set(false)) + .finally(() => { + imagenProbeInflight = null + }) + return imagenProbeInflight +} +/** `image.generate`'s reply. `image_data` is a data URL (works over remote + * gateways); `image` is the raw backend URL fallback. */ +export interface GeneratedImage { + error?: string + image?: string + image_data?: string + success?: boolean +} + +export async function generateAvatarImage(bot: string, title?: string, description?: string): Promise { + const who = [title || bot, description].filter(Boolean).join(' — ') + const res = await host.request('image.generate', { + prompt: + `Cute minimal robot avatar for an AI agent named "${who}". ` + + 'Friendly simple mascot face, bold flat vector style, solid color background, centered, no text.', + aspect_ratio: 'square' + }) + if (!res?.success) { + throw new Error(res?.error || 'generation failed') + } + + // image_data (data URL) works over local AND remote gateways; the raw + // backend URL is the fallback when the gateway couldn't inline it. + return res.image_data || res.image +} + +/** The roster backfill draws the live SVG at 160x160. Pets are 96x104 + * and uploads are 256. Use that to tell a still face-copy from a real picture. */ +export function isBackfilledFacePng(dataUrl: null | string | undefined) { + if (!dataUrl || typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/png;base64,')) { + return false + } + try { + const bin = atob(dataUrl.slice('data:image/png;base64,'.length).slice(0, 48)) + if (bin.length < 24) { + return false + } + const w = (bin.charCodeAt(16) << 24) | (bin.charCodeAt(17) << 16) | (bin.charCodeAt(18) << 8) | bin.charCodeAt(19) + const h = (bin.charCodeAt(20) << 24) | (bin.charCodeAt(21) << 16) | (bin.charCodeAt(22) << 8) | bin.charCodeAt(23) + return w === 160 && h === 160 + } catch { + return false + } +} diff --git a/apps/desktop/src/plugins/hermes-bots/avatar-picker.tsx b/apps/desktop/src/plugins/hermes-bots/avatar-picker.tsx new file mode 100644 index 0000000000..c4ae8c13f7 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/avatar-picker.tsx @@ -0,0 +1,282 @@ +/** + * The avatar editor shared by Edit Profile and New Bot: shape grid + color + * swatches, the Generate and Upload tabs, and the petdex Pet tab. + */ + +import { + Button, + cn, + Codicon, + ColorSwatches, + GlyphSpinner, + host, + PROFILE_SWATCHES, + RowButton, + SegmentedControl, + Textarea, + useValue +} from '@hermes/plugin-sdk' +import { useState } from 'react' + +import { + AVATAR_PICKER_SHAPES, + avatarColor, + BLOB_KINDS, + blobatarSvg, + blobShapeString, + BotFace, + defaultShapeFor, + isBlobShape, + parseBlobShape +} from './avatar' +import { + $imagenAvailable, + generateAvatarImage, + type GeneratedImage, + normalizeAvatarImage, + pickImageFromDevice, + probeImagen +} from './avatar-image' +import { useBots } from './i18n' +import { PetTab } from './pet' + +interface AvatarPickerProps { + /** `null` = no explicit pick, i.e. the name's deterministic hue. */ + color: null | string + /** Feeds the Generate tab when the user leaves the description blank. */ + generateSeed?: { description?: string; name?: string; title?: string } | null + image: null | string + onColor: (color: null | string) => void + onImage: (image: null | string) => void + onShape: (shape: string) => void + shape: string +} + +/** Shape grid + color swatches, shared by Edit Profile and New Bot. */ +export function AvatarPicker({ shape, color, image, onShape, onColor, onImage, generateSeed }: AvatarPickerProps) { + const b = useBots() + const pickerName = generateSeed?.name || 'agent' + const imagen = useValue($imagenAvailable) + const [tab, setTab] = useState('bot') + const [describe, setDescribe] = useState('') + const [genBusy, setGenBusy] = useState(false) + if (imagen === null) { + void probeImagen() + } + + // Re-check a stale "unavailable" whenever the user lands on the Generate + // tab — the gateway may have restarted with image.generate since. + const goTab = (id: string) => { + setTab(id) + if (id === 'generate' && $imagenAvailable.get() === false) { + $imagenAvailable.set(null) + void probeImagen() + } + } + const upload = async () => { + const raw = await pickImageFromDevice() + if (raw) { + onImage(await normalizeAvatarImage(raw)) + } + } + const generate = async () => { + if (genBusy) { + return + } + setGenBusy(true) + try { + const custom = describe.trim() + const img = custom + ? await (async () => { + const res = await host.request('image.generate', { + prompt: `${custom}. Avatar for an AI agent: centered, bold flat vector style, solid color background, no text.`, + aspect_ratio: 'square' + }) + if (!res?.success) { + throw new Error(res?.error || 'generation failed') + } + return res.image_data || res.image + })() + : await generateAvatarImage(generateSeed?.name || 'agent', generateSeed?.title, generateSeed?.description) + if (img) { + onImage(await normalizeAvatarImage(img)) + } + } catch (err) { + host.notifyError(err, b.avatar.generationFailed) + } finally { + setGenBusy(false) + } + } + return ( +
+ {/* Tab pills: Bot | Generate | Upload | Pet */} + + {image && tab !== 'generate' ? ( + + ) : null} + {tab === 'bot' ? ( + isBlobShape(shape) && blobatarSvg ? ( + (() => { + const { seedPart, kind } = parseBlobShape(shape, pickerName) + const locked = Boolean(seedPart) + return ( +
+ {/* Silhouette pins: Auto (name decides) + the six blob kinds. */} +
+ {['', ...BLOB_KINDS].map(k => ( + { + onImage(null) + onShape(blobShapeString(seedPart, k)) + }} + > + {k ? ( + + ) : ( + Auto + )} + + ))} +
+
+ + +
+
+ {locked ? 'Face locked — renaming won\u2019t change it.' : 'Face follows the name.'} +
+ +
+ ) + })() + ) : ( +
+
+ {(blobatarSvg ? ['blobatar', ...AVATAR_PICKER_SHAPES] : AVATAR_PICKER_SHAPES).map(s => ( + { + onImage(null) + onShape(s) + }} + > + + + ))} +
+ +
+ ) + ) : null} + {tab === 'generate' ? ( + imagen ? ( +
+