openpencil/packages/core/src/tools/ai-adapter.ts
Danila Poyarkov a2d7b89709 Adopt motion-v for mobile drawer and toolbar animations
- MobileDrawer: spring-animated height via motion.div :animate + @pan,
  replaces useSwipe + manual rAF. Tab state always set (never null),
  content stays rendered when drawer is closed.
- Toolbar: AnimatePresence with directional slide variants and
  layout-animated width via motion.div layout, replaces manual
  scrollWidth measuring + inline CSS transitions.
- Add SWIPE_VELOCITY_THRESHOLD constant.
- activeRibbonTab type narrowed from union | null to union.
2026-03-06 08:50:38 +03:00

105 lines
3.3 KiB
TypeScript

/**
* Adapter: tool definitions → Vercel AI SDK `tool()` objects.
*
* Converts ParamDef types to valibot schemas and wraps execute
* functions with FigmaAPI instantiation.
*/
import type { FigmaAPI } from '../figma-api'
import type { ToolDef, ParamDef, ParamType } from './schema'
export interface AIAdapterOptions {
getFigma: () => FigmaAPI
onBeforeExecute?: () => void
onAfterExecute?: () => void
onFlashNodes?: (nodeIds: string[]) => void
}
function extractIdsFromArray(arr: unknown[]): string[] {
const ids: string[] = []
for (const item of arr) {
if (item && typeof item === 'object' && typeof (item as Record<string, unknown>).id === 'string') {
ids.push((item as Record<string, unknown>).id as string)
}
}
return ids
}
function extractNodeIds(result: unknown): string[] {
if (!result || typeof result !== 'object') return []
const obj = result as Record<string, unknown>
if (typeof obj.deleted === 'string') return []
const ids: string[] = []
if (typeof obj.id === 'string') ids.push(obj.id)
if (Array.isArray(obj.selection)) ids.push(...extractIdsFromArray(obj.selection))
if (Array.isArray(obj.results)) ids.push(...extractIdsFromArray(obj.results))
return ids
}
export function toolsToAI(
tools: ToolDef[],
options: AIAdapterOptions,
deps: {
v: typeof import('valibot')
valibotSchema: (schema: any) => any
tool: (opts: any) => any
}
): Record<string, any> {
const { v, valibotSchema, tool } = deps
const result: Record<string, any> = {}
for (const def of tools) {
const shape: Record<string, unknown> = {}
for (const [key, param] of Object.entries(def.params)) {
shape[key] = paramToValibot(v, param)
}
result[def.name] = tool({
description: def.description,
inputSchema: valibotSchema(v.object(shape as any)),
execute: async (args: Record<string, unknown>) => {
options.onBeforeExecute?.()
try {
const execResult = await def.execute(options.getFigma(), args as any)
if (def.mutates && options.onFlashNodes) {
const ids = extractNodeIds(execResult)
if (ids.length > 0) options.onFlashNodes(ids)
}
return execResult
} finally {
options.onAfterExecute?.()
}
}
})
}
return result
}
function paramToValibot(v: typeof import('valibot'), param: ParamDef): unknown {
const typeMap: Record<ParamType, () => unknown> = {
string: () => (param.enum ? v.picklist(param.enum as [string, ...string[]]) : v.string()),
number: () => {
const pipes: unknown[] = [v.number()]
if (param.min !== undefined) pipes.push(v.minValue(param.min))
if (param.max !== undefined) pipes.push(v.maxValue(param.max))
return pipes.length > 1 ? v.pipe(...(pipes as [any, any, ...any[]])) : v.number()
},
boolean: () => v.boolean(),
color: () => v.pipe(v.string(), v.description('Color value (hex like #ff0000 or #ff000080)')),
'string[]': () => v.pipe(v.array(v.string()), v.minLength(1))
}
let schema = typeMap[param.type]()
if (param.description && param.type !== 'color') {
schema = v.pipe(schema as any, v.description(param.description))
}
if (!param.required) {
schema = v.optional(schema as any, param.default as any)
}
return schema
}