chore: bump plugin version to 0.1.25
This commit is contained in:
1 parent
10c986d653
commit
36b2f1076b
2 files changed
+30
-267
No files matched your search
+1
-1
@@ -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
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user