* chore(electron): update mac build scripts for improved artifact handling - Modified the `electron:build:mac-arm64` script to rename the generated YAML file for better clarity. - Adjusted the `electron:build:mac-both` script to run builds sequentially without file renaming logic, ensuring consistent output. * chore(electron): enable notarization for macOS builds and update build workflow secrets - Added notarization support in `electron-builder.yml` for enhanced security. - Updated GitHub Actions workflow to include necessary Apple credentials for notarization. * chore(electron): add additional secrets for macOS notarization in build workflow - Included CSC_LINK and CSC_KEY_PASSWORD in the GitHub Actions workflow to support code signing for macOS builds. * feat(types): add ImageFitMode type and objectFit to ImageNode Support fill/fit/crop/tile image scaling modes, matching Figma's image fill behavior. Default is 'fill' (cover) for backward compat. * feat(canvas): render images per fill mode with native crop Add computeImageTransform helper supporting fill/fit/crop/tile modes. Fill/crop uses FabricImage native cropX/cropY instead of clipPath to avoid conflict with parent frame clipping. Tile mode creates a Rect with Pattern fill. Detect mode changes via __needsRecreation flag for object recreation when switching between tile and non-tile modes. * feat(panels): add image fit mode dropdown to property panel New ImageSection component with Fill/Fit/Crop/Tile dropdown for image nodes. Wired into PropertyPanel between icon and appearance sections. * feat(figma): preserve image scale mode from Figma import Map Figma imageScaleMode (FIT/FILL/TILE) to objectFit property on imported ImageNodes so fill mode is preserved across import. * fix(canvas): fix zoom-to-fit bounds inflated by clipped children computeDocBounds was recursing into frame children, inflating the bounding box beyond visible frame bounds. Now only recurses into groups. Also use double-RAF in Figma import for reliable timing. * feat(figma): implement Figma clipboard paste functionality - Added a new hook, useFigmaPaste, to handle pasting Figma clipboard data into the canvas. - Integrated clipboard data extraction and processing to convert Figma nodes into PenNodes. - Enhanced keyboard shortcuts to attempt reading Figma data from the system clipboard as a fallback. - Introduced utility functions for decoding and processing Figma clipboard HTML data. - Updated editor layout to utilize the new Figma paste functionality. * feat(figma): implement Figma clipboard support for pasting nodes - Added a new hook, `useFigmaPaste`, to handle Figma clipboard data extraction and processing. - Integrated Figma clipboard support into the editor layout and keyboard shortcuts for seamless pasting. - Updated README to reflect changes in file format from `.pen` to `.op`. - Refactored AI service methods to route to appropriate provider SDK based on the `provider` field, enhancing flexibility in AI interactions. * fix(figma): preserve imported node order and disable openpencil auto layout Prevent imported/generated nodes from being prepended in auto-layout containers, which could reverse visual order during progressive insertion. Hide the unfinished OpenPencil auto-layout path from the import dialog to avoid selecting a mode that is not ready yet. * fix(ai): enforce explicit provider and model routing Pass selected provider and model through design generation, orchestration, sub-agent, and validation flows. Disable provider/model fallback and remove model retry-without-selection behavior so requests fail fast instead of silently routing to Claude. * chore(package): bump version to 0.1.1 --------- Co-authored-by: Fini <fini.yang@gmail.com>
227 lines
7.1 KiB
TypeScript
227 lines
7.1 KiB
TypeScript
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
|
|
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
|
|
import {
|
|
buildClaudeAgentEnv,
|
|
getClaudeAgentDebugFilePath,
|
|
} from '../../utils/resolve-claude-agent-env'
|
|
import { writeFile, mkdtemp, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { runCodexExec } from '../../utils/codex-client'
|
|
|
|
interface ValidateBody {
|
|
system: string
|
|
message: string
|
|
imageBase64: string
|
|
model?: string
|
|
provider?: 'anthropic' | 'openai' | 'opencode'
|
|
}
|
|
|
|
/**
|
|
* Vision-based validation endpoint.
|
|
* Accepts a base64 PNG screenshot and a text prompt, sends multimodal
|
|
* content blocks for analysis via Agent SDK.
|
|
*
|
|
* Saves screenshot to temp file, asks Claude Code to read it via its
|
|
* built-in Read tool.
|
|
*/
|
|
export default defineEventHandler(async (event) => {
|
|
const body = await readBody<ValidateBody>(event)
|
|
|
|
if (!body?.system || !body?.message || !body?.imageBase64) {
|
|
setResponseHeaders(event, { 'Content-Type': 'application/json' })
|
|
return { error: 'Missing required fields: system, message, imageBase64' }
|
|
}
|
|
|
|
if (!body.model?.trim()) {
|
|
setResponseHeaders(event, { 'Content-Type': 'application/json' })
|
|
return { error: 'Missing model. Model fallback is disabled.' }
|
|
}
|
|
|
|
try {
|
|
if (body.provider === 'anthropic') {
|
|
return await validateViaAgentSDK(body, body.model)
|
|
}
|
|
if (body.provider === 'openai') {
|
|
return await validateViaCodex(body, body.model)
|
|
}
|
|
if (body.provider === 'opencode') {
|
|
return await validateViaOpenCode(body, body.model)
|
|
}
|
|
return { error: 'Missing or unsupported provider. Provider fallback is disabled.' }
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
return { error: message }
|
|
}
|
|
})
|
|
|
|
function toImageBase64(data: string): string {
|
|
const dataUrlPrefix = 'data:image/png;base64,'
|
|
return data.startsWith(dataUrlPrefix) ? data.slice(dataUrlPrefix.length) : data
|
|
}
|
|
|
|
async function withTempImageFile<T>(
|
|
imageBase64: string,
|
|
run: (tempPath: string) => Promise<T>,
|
|
): Promise<T> {
|
|
const tempDir = await mkdtemp(join(tmpdir(), 'openpencil-validate-'))
|
|
const tempPath = join(tempDir, 'screenshot.png')
|
|
try {
|
|
await writeFile(tempPath, Buffer.from(toImageBase64(imageBase64), 'base64'))
|
|
return await run(tempPath)
|
|
} finally {
|
|
await rm(tempDir, { recursive: true, force: true }).catch(() => {})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Agent SDK fallback: save screenshot to a temp PNG file, then ask Claude
|
|
* Code to read it (Claude Code's Read tool supports images natively).
|
|
*/
|
|
async function validateViaAgentSDK(
|
|
body: ValidateBody,
|
|
model?: string,
|
|
): Promise<{ text: string; skipped?: boolean; error?: string }> {
|
|
return await withTempImageFile(body.imageBase64, async (tempPath) => {
|
|
const { query } = await import('@anthropic-ai/claude-agent-sdk')
|
|
|
|
const env = buildClaudeAgentEnv()
|
|
const debugFile = getClaudeAgentDebugFilePath()
|
|
|
|
const claudePath = resolveClaudeCli()
|
|
|
|
// Prompt Claude Code to read the temp image and analyze it
|
|
const prompt = `Read the image file at "${tempPath}" and analyze it as a UI design screenshot.
|
|
|
|
${body.message}
|
|
|
|
${body.system}
|
|
|
|
Output ONLY the JSON object, no markdown fences, no explanation.`
|
|
|
|
const q = query({
|
|
prompt,
|
|
options: {
|
|
...(model ? { model } : {}),
|
|
maxTurns: 2,
|
|
tools: [],
|
|
plugins: [],
|
|
permissionMode: 'plan',
|
|
persistSession: false,
|
|
env,
|
|
...(debugFile ? { debugFile } : {}),
|
|
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
|
|
},
|
|
})
|
|
|
|
try {
|
|
for await (const message of q) {
|
|
if (message.type === 'result') {
|
|
const isErrorResult = 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error)
|
|
if (message.subtype === 'success' && !isErrorResult) {
|
|
return { text: message.result }
|
|
}
|
|
const errors = 'errors' in message ? (message.errors as string[]) : []
|
|
const resultText = 'result' in message ? String(message.result ?? '') : ''
|
|
return { error: errors.join('; ') || resultText || `Query ended with: ${message.subtype}`, text: '' }
|
|
}
|
|
}
|
|
} finally {
|
|
q.close()
|
|
}
|
|
|
|
return { text: '', skipped: true }
|
|
})
|
|
}
|
|
|
|
async function validateViaCodex(
|
|
body: ValidateBody,
|
|
model?: string,
|
|
): Promise<{ text: string; skipped?: boolean; error?: string }> {
|
|
return await withTempImageFile(body.imageBase64, async (tempPath) => {
|
|
const result = await runCodexExec(
|
|
`${body.message}\n\nOutput ONLY the JSON object, no markdown fences, no explanation.`,
|
|
{
|
|
model,
|
|
systemPrompt: body.system,
|
|
imageFiles: [tempPath],
|
|
},
|
|
)
|
|
if (result.error) {
|
|
return { text: '', error: result.error }
|
|
}
|
|
return { text: result.text ?? '' }
|
|
})
|
|
}
|
|
|
|
function parseOpenCodeModel(model?: string): { providerID: string; modelID: string } | undefined {
|
|
if (!model || !model.includes('/')) return undefined
|
|
const idx = model.indexOf('/')
|
|
return { providerID: model.slice(0, idx), modelID: model.slice(idx + 1) }
|
|
}
|
|
|
|
async function validateViaOpenCode(
|
|
body: ValidateBody,
|
|
model?: string,
|
|
): Promise<{ text: string; skipped?: boolean; error?: string }> {
|
|
let ocServer: { close(): void } | undefined
|
|
try {
|
|
const { getOpencodeClient } = await import('../../utils/opencode-client')
|
|
const oc = await getOpencodeClient()
|
|
const ocClient: any = oc.client
|
|
ocServer = oc.server
|
|
|
|
const { data: session, error: sessionError } = await ocClient.session.create({
|
|
title: 'OpenPencil Validate',
|
|
})
|
|
if (sessionError || !session) {
|
|
return { text: '', error: 'Failed to create OpenCode session' }
|
|
}
|
|
|
|
await ocClient.session.prompt({
|
|
sessionID: session.id,
|
|
noReply: true,
|
|
parts: [{ type: 'text', text: body.system }],
|
|
})
|
|
|
|
const parsed = parseOpenCodeModel(model)
|
|
if (!parsed) {
|
|
return { text: '', error: 'Invalid OpenCode model format. Expected "provider/model".' }
|
|
}
|
|
|
|
const base64 = toImageBase64(body.imageBase64)
|
|
const promptPayload = {
|
|
sessionID: session.id,
|
|
model: parsed,
|
|
parts: [
|
|
{ type: 'image', url: `data:image/png;base64,${base64}` },
|
|
{
|
|
type: 'text',
|
|
text: `${body.message}\n\nOutput ONLY the JSON object, no markdown fences, no explanation.`,
|
|
},
|
|
],
|
|
}
|
|
|
|
const { data: result, error: promptError } = await ocClient.session.prompt(promptPayload)
|
|
if (promptError) {
|
|
return { text: '', error: 'OpenCode validation failed' }
|
|
}
|
|
|
|
const texts: string[] = []
|
|
if (result?.parts) {
|
|
for (const part of result.parts) {
|
|
if (part.type === 'text' && part.text) {
|
|
texts.push(part.text)
|
|
}
|
|
}
|
|
}
|
|
return { text: texts.join('') }
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
return { text: '', error: message }
|
|
} finally {
|
|
const { releaseOpencodeServer } = await import('../../utils/opencode-client')
|
|
releaseOpencodeServer(ocServer)
|
|
}
|
|
}
|