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 ? ( +
+