From ea6b9592826a87025e2a085ddfcad556a0bd89ab Mon Sep 17 00:00:00 2001 From: jacksonkasi1 Date: Fri, 27 Feb 2026 00:23:09 +0530 Subject: [PATCH 1/8] feat: add AIRE AI Rename tab with full Gemini integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'AI' tab to plugin tab switch - Add types: AIModelProvider, AIRenameStatus, AISettings, AIRenameGroup, AIRenameResult, AIState - Add AI event handler types: AIRenameRequest, AIBatchReady, AIApplyRename, AIRenameProgress, AIRenameComplete, AIRenameError - Add useAIStore (Zustand) with settings, statuses, progress, isRunning - Add core/ai: svg-detector, section-grouper, export-for-ai, rename-handler - Add AIPage with AINodeList, AISettingsPanel, AIRunButton, AIStatusBadge components - Wire full flow: AI_RENAME_REQUEST → classify/group/export → AI_BATCH_READY → POST /api/ai/rename-batch → AI_APPLY_RENAME → AI_RENAME_PROGRESS - Add server route POST /api/ai/rename-batch with GeminiAdapter (OpenAI/Anthropic stubs) - Add server promise-pool utility (max 5 concurrent AI calls) - Persist AI settings via useStorageManager (clientStorage) - Fix IconButton: rename loading prop to isLoading to avoid JSX img attribute collision - Plugin build: passes TypeScript typecheck clean --- plan.md | 282 ++++ plugin/src/components/tab-switch/index.tsx | 4 + plugin/src/components/ui/icon-button.tsx | 12 +- plugin/src/core/ai/export-for-ai.ts | 67 + plugin/src/core/ai/rename-handler.ts | 173 +++ plugin/src/core/ai/section-grouper.ts | 146 ++ plugin/src/core/ai/svg-detector.ts | 62 + plugin/src/hooks/useStorageManager.ts | 4 +- plugin/src/main.ts | 15 + .../pages/AIPage/_components/AINodeList.tsx | 107 ++ .../pages/AIPage/_components/AIRunButton.tsx | 44 + .../AIPage/_components/AISettingsPanel.tsx | 168 +++ .../AIPage/_components/AIStatusBadge.tsx | 48 + plugin/src/pages/AIPage/index.tsx | 210 +++ .../ImageGridListView/ListView.tsx | 2 +- .../_components/ImageSelector/index.tsx | 2 +- plugin/src/pages/index.tsx | 2 + plugin/src/store/use-ai-store.ts | 50 + plugin/src/styles/output.css | 1239 +---------------- plugin/src/types/ai.ts | 68 + plugin/src/types/events.ts | 33 + server/imagepro-file-process/package.json | 1 + .../src/routes/ai/adapters/anthropic.ts | 26 + .../src/routes/ai/adapters/gemini.ts | 151 ++ .../src/routes/ai/adapters/index.ts | 34 + .../src/routes/ai/adapters/openai.ts | 26 + .../src/routes/ai/adapters/types.ts | 40 + .../src/routes/ai/index.ts | 70 + .../imagepro-file-process/src/routes/index.ts | 7 +- .../src/utils/promise-pool.ts | 37 + todo.md | 59 + 31 files changed, 2004 insertions(+), 1185 deletions(-) create mode 100644 plan.md create mode 100644 plugin/src/core/ai/export-for-ai.ts create mode 100644 plugin/src/core/ai/rename-handler.ts create mode 100644 plugin/src/core/ai/section-grouper.ts create mode 100644 plugin/src/core/ai/svg-detector.ts create mode 100644 plugin/src/pages/AIPage/_components/AINodeList.tsx create mode 100644 plugin/src/pages/AIPage/_components/AIRunButton.tsx create mode 100644 plugin/src/pages/AIPage/_components/AISettingsPanel.tsx create mode 100644 plugin/src/pages/AIPage/_components/AIStatusBadge.tsx create mode 100644 plugin/src/pages/AIPage/index.tsx create mode 100644 plugin/src/store/use-ai-store.ts create mode 100644 plugin/src/types/ai.ts create mode 100644 server/imagepro-file-process/src/routes/ai/adapters/anthropic.ts create mode 100644 server/imagepro-file-process/src/routes/ai/adapters/gemini.ts create mode 100644 server/imagepro-file-process/src/routes/ai/adapters/index.ts create mode 100644 server/imagepro-file-process/src/routes/ai/adapters/openai.ts create mode 100644 server/imagepro-file-process/src/routes/ai/adapters/types.ts create mode 100644 server/imagepro-file-process/src/routes/ai/index.ts create mode 100644 server/imagepro-file-process/src/utils/promise-pool.ts create mode 100644 todo.md diff --git a/plan.md b/plan.md new file mode 100644 index 00000000..3ea8da4d --- /dev/null +++ b/plan.md @@ -0,0 +1,282 @@ +# AIRE — AI Rename Engine: Plan + +## Feature Goal + +Add an **AI** tab to the ImagePro Figma plugin that intelligently renames selected design nodes +using AI. The AI reads structural context (and optionally a low-quality screenshot) of each node +and suggests a meaningful asset name, then applies it directly in Figma — in real-time, node by node. + +--- + +## Tab Naming + +The new tab is labeled **AI** (internally `'ai'` as the page key, consistent with existing store types). + +--- + +## UX Layout (from design spec 3.4) + +``` +┌──────────────────────────────────────────┐ +│ Asset │ Upload │ AI ⚙ │ ← gear icon opens settings panel +├──────────────────────────────────────────┤ +│ ☐ Select All 3/12 nodes │ +├──────────────────────────────────────────┤ +│ ☐ [thumb] hero-banner ✅ renamed │ +│ ☐ [thumb] icon/star ⏳ pending │ +│ ☐ [thumb] vector-path ✅ renamed │ +│ ☐ [thumb] Frame 42 ❌ error │ +├──────────────────────────────────────────┤ +│ [ Read image for better naming ☐ ] │ +│ ────────────────────────────────────── │ +│ [ Run AI Rename ] │ +└──────────────────────────────────────────┘ +``` + +- Node statuses update **in real-time** as each rename completes — no batch wait. +- Settings gear opens a panel with: model selector, API key input, custom system prompt textarea. +- "Read image" toggle controls whether a low-quality screenshot is sent to AI alongside text context. + +--- + +## Architecture + +### Data Flow + +``` +User clicks Run + → UI emits AI_RENAME_REQUEST {nodeIds, readImage, settings} + → main.ts: classifyNode() each selected node + → main.ts: groupNodesByParent() into RenameGroups + → main.ts: exportAsync() low-quality JPG per group (sequential, max 2 concurrent) + → main.ts emits AI_BATCH_READY {groups[]} + → UI: POST /api/ai/rename-batch {groups[], settings} + → Server: promisePool(aiCalls, concurrency=5) + → Server: GeminiAdapter → structured JSON response + → Server: stream results back as they complete (NDJSON chunks) + → UI: for each result → emit AI_APPLY_RENAME {nodeId, newName} + → main.ts: node.name = newName → emit AI_RENAME_PROGRESS {nodeId, status:'done'} + → UI: update status badge for that node in real-time + → when all done → emit AI_RENAME_COMPLETE +``` + +### No-Hang Guarantee + +The plugin must never feel frozen: +- Figma `exportAsync` calls are throttled to max 2 concurrent (sandbox constraint). +- AI API calls are capped at 5 concurrent (network I/O bound). +- Progress events are emitted **per node** so the UI is always updating. +- The Run button shows a live counter: "Renaming 3/12…". +- Errors per node are shown inline — one node failing never blocks the rest. + +--- + +## Algorithm 1: SVG Detection — `classifyNode()` + +**Purpose**: Determine if a node is a vector/SVG composite before traversal, to avoid pointlessly +recursing deep into shapes that will only ever produce the same SVG image. + +**Problem**: A FRAME containing 50 nested VECTOR path children is still just an icon. Traversing +all 50 children wastes time and produces 50 separate rename calls when one would suffice. + +**Solution**: Walk children; if ALL descendants are vector primitives → treat the whole node as +a single SVG leaf. Export the parent node once as an image. + +``` +VECTOR_PRIMITIVES = {VECTOR, BOOLEAN_OPERATION, LINE, ELLIPSE, POLYGON, STAR, RECTANGLE} + +classifyNode(node): + if node.type in VECTOR_PRIMITIVES → return 'svg_leaf' + if node has children: + if ALL children recursively are vector primitives → return 'svg_leaf' (composite SVG) + else → return 'section' + return 'raster_leaf' +``` + +**Outcome**: +- `svg_leaf` → export the node itself as a low-quality JPG → send to AI with hint "this is a vector icon" +- `raster_leaf` → export as low-quality JPG → send to AI +- `section` → recurse one level, group children by parent for bundling + +--- + +## Algorithm 2: Section Grouper — `groupNodesByParent()` + +**Purpose**: Preserve layout context when sending to AI. An isolated node out of context loses +semantic meaning. A node within its siblings (e.g. a card in a grid) tells AI much more. + +**Problem**: Sending the full Figma page to AI = huge payload, slow, noisy. Sending one node +at a time = loses surrounding context. Need a middle ground. + +**Solution**: Group selected nodes by their direct `parentId`. If ≤ 4 siblings are selected from +the same parent, export the **parent** as a single image (captures layout context). If > 4, +export each individually. + +``` +groupNodesByParent(selectedNodes): + map = {} + for each node in selectedNodes: + pid = node.parent.id ?? 'root' + map[pid].push(node) + + for each [parentId, siblings] in map: + if siblings.length <= 4: + contextNodeId = parentId ← export parent for visual context + else: + contextNodeId = each sibling individually + + → return RenameGroup[] +``` + +**Context Text** (when readImage=false): Serialize a lightweight 2-level JSON of the node tree. +Include `TEXT` node `.characters` because text content is the richest semantic signal. +Never send more than 2 levels deep. No binary data. Only: id, name, type, dimensions, text chars. + +--- + +## Algorithm 3: Promise Pool — `promisePool(tasks, concurrency)` + +**Purpose**: Run up to N async tasks concurrently, no more. Ensures the plugin stays responsive +and doesn't blast the AI API with 50 simultaneous requests. + +``` +promisePool(tasks, concurrency=5): + executing = Set() + for each task: + p = task().then(result → executing.delete(p)) + executing.add(p) + if executing.size >= concurrency: + await Promise.race(executing) ← wait for one slot to free + await Promise.all(executing) ← drain remaining +``` + +- Export phase (main.ts, Figma sandbox): `concurrency = 2` +- AI call phase (server): `concurrency = 5` + +--- + +## Algorithm 4: Model Adapter Factory + +**Purpose**: Decouple the rename logic from any specific AI provider. Adding OpenAI or Anthropic +in the future = add one adapter file, register in factory. Zero other changes. + +``` +IModelAdapter: + rename(groups, systemPrompt, concurrency) → Promise + +GeminiAdapter implements IModelAdapter +OpenAIAdapter implements IModelAdapter (stub for now) +AnthropicAdapter implements IModelAdapter (stub for now) + +ModelAdapterFactory.create(provider, apiKey, model) → IModelAdapter +``` + +--- + +## Low-Quality Image Export Spec + +For AI vision mode (`readImage = true`): +- Export format: `JPG` +- Scale: `0.5x` (half resolution — readable by vision models, small payload) +- If exported image > 150KB: reduce scale to `0.25x` +- Max longest dimension capped at 800px +- Purpose: AI needs to identify what the asset IS, not print-quality detail + +--- + +## Default System Prompt + +``` +You are a professional UI asset naming assistant for Figma. +Given a JSON description of a design node (and optionally a screenshot), +suggest a concise, descriptive asset name in the specified naming convention. + +Rules: +- Name should reflect the visual PURPOSE of the element, not its Figma layer name +- Use what you see: if it's a hero banner with a CTA, name it "hero-get-started" +- If it's an icon/vector: describe the shape — "icon-arrow-right", "icon-user-profile" +- Maximum 4 words, no generic names like "frame-1" or "rectangle-5" +- Apply naming convention: {{caseOption}} +- Respond ONLY with a valid JSON array: [{"nodeId":"...","suggestedName":"..."}] +``` + +User can override this from the settings panel. Stored in Figma `clientStorage`. + +--- + +## Default AI Models + +- **Fast (default)**: `gemini-2.5-flash-preview-04-17` +- **Quality**: `gemini-2.5-pro-preview-03-25` + +Reference: https://ai.google.dev/gemini-api/docs/models + +--- + +## Server Route + +``` +POST /api/ai/rename-batch +Body: { + groups: AIRenameGroup[], + settings: { modelProvider, model, apiKey, systemPrompt, caseOption } +} +Response: AIRenameResult[] + (streamed as NDJSON: one JSON line per result as each completes) +``` + +--- + +## New Files + +### Plugin (`plugin/src/`) + +| File | Purpose | +|------|---------| +| `types/ai.ts` | All AI-feature type definitions | +| `store/use-ai-store.ts` | Zustand store for AI settings + rename statuses | +| `pages/AIPage/index.tsx` | AI tab page root | +| `pages/AIPage/_components/AINodeList.tsx` | Node list with real-time status badges | +| `pages/AIPage/_components/AISettingsPanel.tsx` | Model, prompt, API key settings | +| `pages/AIPage/_components/AIStatusBadge.tsx` | Per-node status indicator | +| `pages/AIPage/_components/AIRunButton.tsx` | Action button with live counter | +| `core/ai/svg-detector.ts` | `classifyNode()` algorithm | +| `core/ai/section-grouper.ts` | `groupNodesByParent()` algorithm | +| `core/ai/export-for-ai.ts` | Low-quality export + context serializer | +| `core/ai/rename-handler.ts` | main.ts-side orchestrator | +| `helpers/ai/promise-pool.ts` | Concurrency utility (used by server too) | + +### Server (`server/imagepro-file-process/src/`) + +| File | Purpose | +|------|---------| +| `routes/ai/index.ts` | Route registration | +| `routes/ai/rename-batch.ts` | POST handler | +| `routes/ai/adapters/index.ts` | IModelAdapter interface + factory | +| `routes/ai/adapters/gemini.ts` | GeminiAdapter (live) | +| `routes/ai/adapters/openai.ts` | OpenAIAdapter (stub) | +| `routes/ai/adapters/anthropic.ts` | AnthropicAdapter (stub) | + +### Modified Files + +| File | Change | +|------|--------| +| `components/tab-switch/index.tsx` | Add AI option to SegmentedControl | +| `pages/index.tsx` | Register AIPage | +| `types/events.ts` | Add AI event handler types | +| `main.ts` | Wire AI event handlers | +| `hooks/useStorageManager.ts` | Register aiStore for persistence | +| `server/src/routes/index.ts` | Register /api/ai routes | + +--- + +## Coding Standards + +Follow existing patterns exactly: +- Import comment headers: `// ** import types`, `// ** import core packages`, etc. +- Preact (`h`, `Fragment`) not React +- Zustand with `create()` +- `emit/on` from `@create-figma-plugin/utilities` for plugin messaging +- Tailwind classes for styling (no inline styles except transitions) +- Component structure: `ComponentName/index.tsx` with `_components/` subfolder +- All types in `types/` directory, not inline diff --git a/plugin/src/components/tab-switch/index.tsx b/plugin/src/components/tab-switch/index.tsx index 923f2362..7d21daa5 100644 --- a/plugin/src/components/tab-switch/index.tsx +++ b/plugin/src/components/tab-switch/index.tsx @@ -18,6 +18,10 @@ const TabSwitch = () => { value: 'upload', label: 'Upload', }, + { + value: 'ai', + label: 'AI', + }, ]; function handleChange(event: JSX.TargetedEvent) { diff --git a/plugin/src/components/ui/icon-button.tsx b/plugin/src/components/ui/icon-button.tsx index 6866723f..1f511279 100644 --- a/plugin/src/components/ui/icon-button.tsx +++ b/plugin/src/components/ui/icon-button.tsx @@ -26,7 +26,7 @@ interface IconButtonProps extends JSX.HTMLAttributes { /** * If true, the button will show a loading spinner instead of its children, and be disabled. */ - loading?: boolean; + isLoading?: boolean; } /** @@ -40,7 +40,7 @@ export const IconButton: preact.FunctionComponent = ({ variant = 'blank', isActive, animate, - loading, + isLoading, children, ...props }) => { @@ -50,17 +50,17 @@ export const IconButton: preact.FunctionComponent = ({ return ( + ); +}; + +export default AIRunButton; diff --git a/plugin/src/pages/AIPage/_components/AISettingsPanel.tsx b/plugin/src/pages/AIPage/_components/AISettingsPanel.tsx new file mode 100644 index 00000000..3b91af52 --- /dev/null +++ b/plugin/src/pages/AIPage/_components/AISettingsPanel.tsx @@ -0,0 +1,168 @@ +import { h } from 'preact'; +import { useCallback } from 'preact/hooks'; + +// ** import utils +import { cn } from '@/lib/utils'; + +// ** import store +import { useAIStore } from '@/store/use-ai-store'; + +// ** import types +import { AIModelProvider } from '@/types/ai'; +import { CaseOption } from '@/types/enums'; + +// ** import components +import { Checkbox } from '@/components/ui/checkbox'; + +const MODEL_OPTIONS: Record = { + gemini: { + label: 'Gemini', + models: ['gemini-2.5-flash-preview-04-17', 'gemini-2.5-pro-preview-03-25'], + }, + openai: { + label: 'OpenAI (coming soon)', + models: ['gpt-4o'], + }, + anthropic: { + label: 'Anthropic (coming soon)', + models: ['claude-3-5-sonnet-20241022'], + }, +}; + +const CASE_OPTIONS: { value: CaseOption; label: string }[] = [ + { value: CaseOption.KEBAB_CASE, label: 'kebab-case' }, + { value: CaseOption.SNAKE_CASE, label: 'snake_case' }, + { value: CaseOption.CAMEL_CASE, label: 'camelCase' }, + { value: CaseOption.PASCAL_CASE, label: 'PascalCase' }, +]; + +const labelClass = 'text-xs text-secondary-text font-medium mb-1 block'; +const inputClass = + 'w-full text-xs bg-primary-bg border border-f-border rounded px-2 py-1.5 text-primary-text focus:outline-none focus:border-brand-bg'; +const selectClass = + 'w-full text-xs bg-primary-bg border border-f-border rounded px-2 py-1.5 text-primary-text focus:outline-none focus:border-brand-bg'; + +const AISettingsPanel = () => { + const { settings, setSettings } = useAIStore(); + + const handleProviderChange = useCallback( + (e: Event) => { + const provider = (e.target as HTMLSelectElement).value as AIModelProvider; + const defaultModel = MODEL_OPTIONS[provider].models[0]; + setSettings({ modelProvider: provider, model: defaultModel }); + }, + [setSettings] + ); + + const handleModelChange = useCallback( + (e: Event) => { + setSettings({ model: (e.target as HTMLSelectElement).value }); + }, + [setSettings] + ); + + const handleApiKeyChange = useCallback( + (e: Event) => { + setSettings({ apiKey: (e.target as HTMLInputElement).value }); + }, + [setSettings] + ); + + const handleCaseChange = useCallback( + (e: Event) => { + setSettings({ caseOption: (e.target as HTMLSelectElement).value as CaseOption }); + }, + [setSettings] + ); + + const handleSystemPromptChange = useCallback( + (e: Event) => { + setSettings({ systemPrompt: (e.target as HTMLTextAreaElement).value }); + }, + [setSettings] + ); + + const handleReadImageChange = useCallback( + (checked: boolean) => { + setSettings({ readImage: checked }); + }, + [setSettings] + ); + + return ( +
+

AI Settings

+ + {/* Provider */} +
+ + +
+ + {/* Model */} +
+ + +
+ + {/* API Key */} +
+ + +
+ + {/* Case */} +
+ + +
+ + {/* Read Image toggle */} +
+ + Send screenshot to AI (slower, more accurate) +
+ + {/* System Prompt */} +
+ +