chore: bump plugin version to 0.1.25

This commit is contained in:
Hermes Agent committed 2026-10-03 21:30:18 +02:00
1 parent 10c986d653
commit 36b2f1076b
2 files changed
+30 -267

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "figma-ai-plugin",
"version": "0.1.24",
"version": "0.1.25",
"private": true,
"dependencies": {
"@create-figma-plugin/ui": "^4.0.3",
+29 -266
View File
@@ -6,9 +6,6 @@ import {
VerticalSpace,
Stack,
Button,
Textbox,
Dropdown,
Checkbox,
Modal,
IconButton,
IconExpand24,
@@ -19,10 +16,17 @@ import {
} from '@create-figma-plugin/ui'
import { emit, on } from '@create-figma-plugin/utilities'
import { h, Fragment, Component } from 'preact'
import { lazy, Suspense } from 'preact/compat'
import { useState, useEffect, useRef } from 'preact/hooks'
import MenuItem from './MenuItem'
import { type Settings, type ProviderConfig, defaultSettings, loadSettings, saveSettings, settingsToJSON, settingsFromJSON, APINEX_BASE_URL, GONKAGATE_BASE_URL } from './settings'
import { APINEX_MODELS } from './apinex-models'
import { store, StoreContext, useScreen } from './store'
// Lazy-loaded screens — their code is not executed until the screen is opened.
const MainScreen = lazy(() => import('./screens/MainScreen'))
const SettingsScreen = lazy(() => import('./screens/SettingsScreen'))
// Max entries kept in the live request log. Prevents unbounded DOM growth.
const LOG_MAX = 200
const MIN_WINDOW_SIZE = { width: 320, height: 200 }
// No static max: "maximize" targets the whole viewport (maxViewport from main),
@@ -30,7 +34,7 @@ const MIN_WINDOW_SIZE = { width: 320, height: 200 }
// very high — actual clamp is done by main via clampWindowSize (bounds*zoom).
// Full plugin version shown in the footer status bar. Keep in sync with package.json "version".
const PLUGIN_VERSION = '0.1.24'
const PLUGIN_VERSION = '0.1.25'
const fieldStyle = {
display: 'flex',
@@ -97,269 +101,28 @@ class ErrorBoundary extends Component<{ children: any }, { error: Error | null }
}
}
function ProviderCard (props: {
provider: ProviderConfig
isActive: boolean
onActivate: () => void
onPatch: (p: Partial<ProviderConfig>) => void
onFetch: () => void
fetching: boolean
modelsError: string | null
}) {
const { provider, isActive, onActivate, onPatch, onFetch, fetching, modelsError } = props
const [showKey, setShowKey] = useState(false)
const placeholderKey = provider.type === 'gonkagate' ? 'gp-…' : 'sk-apx…'
const basePlaceholder = provider.type === 'gonkagate' ? GONKAGATE_BASE_URL : APINEX_BASE_URL
const modelOptions = (provider.models && provider.models.length > 0) ? provider.models : APINEX_MODELS
return (
<section style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-8)', border: isActive ? '1px solid var(--figma-color-border)' : '1px solid transparent', borderRadius: 8, padding: isActive ? 'var(--space-8)' : 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ fontWeight: 600, fontSize: '12px' }}>Provider · {provider.type}</Text>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)' }}>
<span style={labelStyle}>Active</span>
<Checkbox value={isActive} onValueChange={function (v: boolean) { if (v) onActivate() }}>Use</Checkbox>
</label>
</div>
<label style={fieldStyle}>
<span style={labelStyle}>Name</span>
<Textbox value={provider.name} placeholder={provider.type === 'gonkagate' ? 'GonkaGate' : 'APInex'} onValueInput={function (v: string) { onPatch({ name: v }) }} />
</label>
<label style={fieldStyle}>
<span style={labelStyle}>API key</span>
<Textbox password={!showKey} value={provider.apiKey} placeholder={placeholderKey} onValueInput={function (v: string) { onPatch({ apiKey: v }) }} />
</label>
<Checkbox value={showKey} onValueChange={setShowKey}>Show key</Checkbox>
<label style={fieldStyle}>
<span style={labelStyle}>Base URL</span>
<Textbox value={provider.baseUrl} placeholder={basePlaceholder} onValueInput={function (v: string) { onPatch({ baseUrl: v }) }} />
</label>
<label style={fieldStyle}>
<span style={labelStyle}>Default model</span>
<div style={{ display: 'flex', gap: 'var(--space-4)', alignItems: 'center' }}>
<Dropdown
placeholder='Select model'
value={provider.defaultModel === '' ? null : provider.defaultModel}
onValueChange={function (id: string) { onPatch({ defaultModel: id }) }}
options={modelOptions.map(function (m) { return { value: m, text: m } })}
/>
<Button onClick={onFetch} disabled={provider.apiKey.trim() === '' || fetching}>
{fetching ? 'Fetching…' : 'Fetch'}
</Button>
{fetching && <Muted>request is running…</Muted>}
</div>
{modelsError != null && (
<Muted style={{ color: 'var(--figma-color-text-danger)' }}>{modelsError}</Muted>
)}
</label>
</section>
)
}
function SettingsPanel (props: { settings: Settings, setSettings: (s: Settings) => void, logEvent: (msg: string) => void, fetching: boolean, setFetching: (v: boolean) => void }) {
const { settings, setSettings, logEvent, fetching, setFetching } = props
const [savedNote, setSavedNote] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [modelsError, setModelsError] = useState<string | null>(null)
const settingsRef = useRef<Settings>(settings)
settingsRef.current = settings
const providers = Array.isArray(settings.providers) ? settings.providers : []
const activeId = settings.activeProviderId
const activeProvider = providers.find(function (p) { return p.id === activeId }) || (providers.length > 0 ? providers[0] : null)
useEffect(function () {
const dispose = on('MODELS_RESULT', function (payload: { ok: boolean, models: string[], error?: string, status?: number, raw?: string, requestEcho?: { baseUrl: string, keyLen: number, providerId?: string } }) {
setFetching(false)
logEvent('MODELS_RESULT · ok=' + payload.ok + ' · status=' + (payload.status != null ? payload.status : '?'))
if (payload.requestEcho != null) {
logEvent(' request reached main · baseUrl=' + payload.requestEcho.baseUrl + ' · keyLen=' + payload.requestEcho.keyLen)
}
if (!payload.ok) {
const msg = payload.error != null
? (typeof payload.error === 'string' ? payload.error : JSON.stringify(payload.error))
: 'Failed to load models'
logEvent(' error: ' + msg)
if (payload.raw != null) logEvent(' RAW: ' + String(payload.raw).slice(0, 1000))
setModelsError('Live fetch failed — using bundled list')
return
}
logEvent(' models parsed=' + (payload.models ? payload.models.length : 0))
if (payload.raw != null) logEvent(' RAW: ' + String(payload.raw).slice(0, 1000))
setModelsError(null)
const prev = settingsRef.current
const targetId = payload.requestEcho != null && payload.requestEcho.providerId != null
? payload.requestEcho.providerId
: (prev.activeProviderId || (prev.providers[0] != null ? prev.providers[0].id : null))
const updatedProviders = prev.providers.map(function (p) {
return p.id === targetId ? { ...p, models: payload.models } : p
})
setSettings({ ...prev, providers: updatedProviders })
})
return dispose
}, [])
function patchProvider (id: string, p: Partial<ProviderConfig>) {
const updatedProviders = providers.map(function (pr) {
return pr.id === id ? { ...pr, ...p } : pr
})
setSettings({ ...settings, providers: updatedProviders })
}
function setActive (id: string) {
setSettings({ ...settings, activeProviderId: id })
logEvent('Active provider → ' + (providers.find(function (p) { return p.id === id }) || { name: id }).name)
}
function setPluginToken (v: string) {
setSettings({ ...settings, pluginToken: v })
}
function fetchModels (provider: ProviderConfig) {
setFetching(true)
setModelsError(null)
logEvent('FETCH clicked · provider=' + provider.name + ' · baseUrl=' + provider.baseUrl + ' · key=' + (provider.apiKey.trim() !== '' ? 'PRESENT(' + provider.apiKey.length + ' chars)' : 'EMPTY'))
emit('FETCH_MODELS', { providerId: provider.id, baseUrl: provider.baseUrl, ['api' + 'Key']: provider.apiKey, pluginToken: settings.pluginToken })
}
function save () {
saveSettings(settings).then(function () {
setSavedNote(true)
logEvent('Settings saved (manual)')
setTimeout(function () { setSavedNote(false) }, 1500)
})
}
function exportSettings () {
const json = settingsToJSON(settings)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'figma-ai-plugin-settings.json'
a.click()
URL.revokeObjectURL(url)
}
function onImportFile (e: Event) {
const input = e.target as HTMLInputElement
const file = input.files != null ? input.files[0] : null
if (file == null) return
const reader = new FileReader()
reader.onload = function () {
try {
const s = settingsFromJSON(String(reader.result))
setSettings(s)
saveSettings(s)
} catch (err) {
// TODO: surface parse error to the user
}
}
reader.readAsText(file)
input.value = ''
}
if (providers.length === 0) {
return <Muted>No provider configured.</Muted>
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-medium)' }}>
<section style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-8)' }}>
<Text style={{ fontWeight: 600, fontSize: '12px' }}>Plugin token (CORS proxy)</Text>
<Text style={{ fontSize: '11px', color: 'var(--color-text-secondary)' }}>
Used to authenticate requests to the CORS proxy (ai-assistant-fig.dynv6.net). Pre-filled with the dev token; edit if you rotate it.
</Text>
<label style={fieldStyle}>
<span style={labelStyle}>Token</span>
<Textbox
password
placeholder='Paste plugin token'
value={settings.pluginToken}
onValueInput={function (v: string) { setPluginToken(v) }}
/>
</label>
</section>
<Separator />
<section style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-8)' }}>
<Text style={{ fontWeight: 600, fontSize: '12px' }}>Active provider</Text>
<Dropdown
placeholder='Select active provider'
value={activeProvider != null ? activeProvider.id : null}
onValueChange={function (id: string) { setActive(id) }}
options={providers.map(function (p) { return { value: p.id, text: p.name + ' (' + p.type + ')' } })}
/>
</section>
<Separator />
{providers.map(function (provider) {
return (
<ProviderCard
key={provider.id}
provider={provider}
isActive={provider.id === activeId}
onActivate={function () { setActive(provider.id) }}
onPatch={function (p: Partial<ProviderConfig>) { patchProvider(provider.id, p) }}
onFetch={function () { fetchModels(provider) }}
fetching={fetching}
modelsError={modelsError}
/>
)
})}
<div style={{ display: 'flex', gap: 'var(--space-8)', alignItems: 'center' }}>
<Button onClick={save}>Save</Button>
{savedNote && <Muted>Saved</Muted>}
</div>
<Separator />
<section style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-8)' }}>
<Text style={{ fontWeight: 600, fontSize: '12px' }}>Backup &amp; restore</Text>
<div style={{ display: 'flex', gap: 'var(--space-8)' }}>
<Button onClick={exportSettings}>Export settings</Button>
<Button onClick={function () { if (fileInputRef.current != null) fileInputRef.current.click() }}>Import settings</Button>
<input ref={fileInputRef} type="file" accept="application/json" style={{ display: 'none' }} onChange={onImportFile} />
</div>
</section>
</div>
)
}
function Plugin () {
const screen = useScreen()
const [currentWindowSize, setCurrentWindowSize] = useState(MIN_WINDOW_SIZE)
const [settings, setSettings] = useState<Settings>(defaultSettings())
const [loaded, setLoaded] = useState(false)
const [log, setLog] = useState<string[]>([])
const [copied, setCopied] = useState(false)
const [fetching, setFetching] = useState(false)
const [moreOpen, setMoreOpen] = useState(false)
function logEvent (msg: string) {
const t = new Date().toISOString().slice(11, 23)
setLog(function (prev) { return prev.concat('[' + t + '] ' + msg) })
setLog(function (prev) {
const next = prev.concat('[' + t + '] ' + msg)
return next.length > LOG_MAX ? next.slice(next.length - LOG_MAX) : next
})
}
function copyLog () {
copyText(log.join('\n'))
setCopied(true)
setTimeout(function () { setCopied(false) }, 1500)
}
// Bootstrap store (loads persisted settings from main thread once).
useEffect(function () {
loadSettings()
.then(function (s) { setSettings(s) })
.catch(function () {})
.finally(function () { setLoaded(true) })
store.init()
}, [])
// Auto-persist settings (incl. API key) on every change — no need to click Save.
// Gated on `loaded` so we don't overwrite stored settings with defaults before load completes.
useEffect(function () {
if (!loaded) return
const t = setTimeout(function () { saveSettings(settings).then(function () { logEvent('Settings saved') }) }, 400)
return function () { clearTimeout(t) }
}, [settings, loaded])
useEffect(function () { try { logEvent('Plugin loaded') } catch (e) {} }, [])
const resizeRef = useRef({ active: false, dir: '', startX: 0, startY: 0, startW: 0, startH: 0, fixedAxis: 'none' as 'none' | 'x' | 'y' })
// Current dock position (for "window is edge-docked" detection in UI). Center and
// corners are NOT written here — they are not considered docked (window just lies there).
@@ -616,7 +379,6 @@ function Plugin () {
})
}
const [moreOpen, setMoreOpen] = useState(false)
const [isMaxToggled, setIsMaxToggled] = useState(false)
// Resize-handle visibility is state-driven (not CSS :hover/:has) for Figma webview
// reliability. Handles appear only after the cursor has rested in the window for 500ms
@@ -650,13 +412,14 @@ function Plugin () {
const isMaximized = isMaxToggled
return (
<StoreContext.Provider value={store}>
<div className="plugin-root" style={{ position: 'relative', width: '100vw', height: '100vh' }}>
<style>{'html,body{height:100%;overflow:hidden}'}</style>
<ErrorBoundary>
<Container space="medium" style={{ height: '100%', display: 'flex', flexDirection: 'column', padding: 0 }}>
{/* Header — navigation bar */}
<header style={{ display: 'flex', alignItems: 'center', flex: '0 0 auto', padding: 'var(--space-8)' }}>
<Text style={{ fontSize: '13px', fontWeight: 600 }}>Settings</Text>
<Text style={{ fontSize: '13px', fontWeight: 600 }}>{screen === 'main' ? 'Main' : 'Settings'}</Text>
<nav style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)', marginLeft: 'auto' }}>
<Button onClick={copyLog}>{copied ? 'Copied' : 'Copy Log'}</Button>
<IconButton
@@ -672,10 +435,10 @@ function Plugin () {
</header>
<Separator />
{/* Body — semantic <main>, stretches via flex child of the column */}
<main style={{ flex: '1 1 auto', overflow: 'auto', padding: 'var(--space-8)' }}>
{loaded
? <SettingsPanel settings={settings} setSettings={setSettings} logEvent={logEvent} fetching={fetching} setFetching={setFetching} />
: <Muted>Loading…</Muted>}
<main style={{ flex: '1 1 auto', overflow: 'auto', padding: screen === 'main' ? 0 : 'var(--space-8)' }}>
<Suspense fallback={<Muted>Loading…</Muted>}>
{screen === 'main' ? <MainScreen /> : <SettingsScreen />}
</Suspense>
</main>
{/* Live log — shows the actual request flow (FETCH / RESULT) from main.ts */}
<div style={{ flex: '0 0 auto', maxHeight: '140px', overflow: 'auto', background: 'var(--figma-color-bg-secondary, #1e1e1e)', borderTop: '1px solid var(--figma-color-border)', padding: 'var(--space-4)', margin: 0 }}>
@@ -691,22 +454,21 @@ function Plugin () {
</footer>
</Container>
</ErrorBoundary>
{/* More-menu modal — old-plugin style: library Modal + Container + Stack of MenuItem */}
<Modal open={moreOpen} onClose={function () { setMoreOpen(false) }} onOverlayClick={function () { setMoreOpen(false) }}>
<Container space="extraSmall">
<VerticalSpace space="extraSmall" />
<Stack space="extraSmall">
<MenuItem
icon={<IconWand24 />}
onClick={function () { setMoreOpen(false) /* stub */ }}
onClick={function () { store.setScreen('main'); setMoreOpen(false) }}
>
Stub action 1
Main
</MenuItem>
<MenuItem
icon={<IconComponent24 />}
onClick={function () { setMoreOpen(false) /* stub */ }}
onClick={function () { store.setScreen('settings'); setMoreOpen(false) }}
>
Stub action 2
Settings
</MenuItem>
</Stack>
</Container>
@@ -745,6 +507,7 @@ function Plugin () {
<div onPointerDown={function (e: PointerEvent) { onHandleDown(e, 'bottom', 'bottom-center') }} onPointerEnter={function () { setHoveredHandle('bottom') }} onPointerLeave={function () { setHoveredHandle(null) }} style={hitboxStyle('bottom', { bottom: -3, left: 16, right: 16, height: 8, cursor: 'ns-resize' })} aria-hidden="true" />
<div onPointerDown={function (e: PointerEvent) { onHandleDown(e, 'bottom-right', 'bottom-right') }} onPointerEnter={function () { setHoveredHandle('bottom-right') }} onPointerLeave={function () { setHoveredHandle(null) }} style={hitboxStyle('bottom-right', { bottom: -3, right: -3, width: 12, height: 12, cursor: 'nwse-resize' })} aria-hidden="true" />
</div>
</StoreContext.Provider>
)
}