feat(stage-*): port artistry & chatbox enhancements (#1636)
Co-authored-by-agent: Unknown <unknown@example.com>
This commit is contained in:
@@ -113,6 +113,7 @@
|
||||
"reka-ui": "^2.9.6",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"replicate": "catalog:",
|
||||
"semver": "^7.7.4",
|
||||
"shiki": "^4.0.2",
|
||||
"splitpanes": "catalog:",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { any, array, number, object, optional, string } from 'valibot'
|
||||
|
||||
import { createConfig } from '../libs/electron/persistence'
|
||||
|
||||
export const artistryConfigSchema = object({
|
||||
artistryProvider: optional(string(), 'comfyui'),
|
||||
artistryGlobals: optional(object({
|
||||
comfyuiServerUrl: optional(string(), 'http://localhost:8188'),
|
||||
comfyuiSavedWorkflows: optional(array(any()), []),
|
||||
comfyuiActiveWorkflow: optional(string(), ''),
|
||||
replicateApiKey: optional(string(), ''),
|
||||
replicateDefaultModel: optional(string(), 'black-forest-labs/flux-schnell'),
|
||||
replicateAspectRatio: optional(string(), '16:9'),
|
||||
replicateInferenceSteps: optional(number(), 4),
|
||||
nanobananaApiKey: optional(string(), ''),
|
||||
nanobananaModel: optional(string(), 'gemini-3.1-flash-image-preview'),
|
||||
nanobananaResolution: optional(string(), '1K'),
|
||||
}), {}),
|
||||
})
|
||||
|
||||
export function createArtistryConfig() {
|
||||
const config = createConfig('artistry', 'options.json', artistryConfigSchema)
|
||||
config.setup()
|
||||
|
||||
return config
|
||||
}
|
||||
@@ -9,9 +9,9 @@ import messages from '@proj-airi/i18n/locales'
|
||||
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import { Format, LogLevel, setGlobalFormat, setGlobalHookPostLog, setGlobalLogLevel, useLogg } from '@guiiai/logg'
|
||||
import { createContext } from '@moeru/eventa/adapters/electron/main'
|
||||
import { initScreenCaptureForMain } from '@proj-airi/electron-screen-capture/main'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import { noop } from 'es-toolkit'
|
||||
import { createLoggLogger, injeca, lifecycle } from 'injeca'
|
||||
import { isLinux } from 'std-env'
|
||||
|
||||
@@ -19,6 +19,7 @@ import icon from '../../resources/icon.png?asset'
|
||||
|
||||
import { openDebugger, setupDebugger } from './app/debugger'
|
||||
import { nullFileLoggerHandle, setupFileLogger } from './app/file-logger'
|
||||
import { createArtistryConfig } from './configs/artistry'
|
||||
import { createGlobalAppConfig } from './configs/global'
|
||||
import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed } from './libs/bootkit/lifecycle'
|
||||
import { setElectronMainDirname } from './libs/electron/location'
|
||||
@@ -28,6 +29,7 @@ import { setupServerChannel } from './services/airi/channel-server'
|
||||
import { setupBuiltInServer } from './services/airi/http-server'
|
||||
import { setupMcpStdioManager } from './services/airi/mcp-servers'
|
||||
import { setupPluginHost } from './services/airi/plugins'
|
||||
import { setupArtistryBridge } from './services/airi/widgets/artistry-bridge'
|
||||
import { setupAutoUpdater } from './services/electron/auto-updater'
|
||||
import { setupTray } from './tray'
|
||||
import { setupAboutWindowReusable } from './windows/about'
|
||||
@@ -101,15 +103,16 @@ app.whenReady().then(async () => {
|
||||
injeca.setLogger(createLoggLogger(useLogg('injeca').useGlobalConfig()))
|
||||
|
||||
const appConfig = injeca.provide('configs:app', () => createGlobalAppConfig())
|
||||
const artistryConfig = injeca.provide('configs:artistry', () => createArtistryConfig())
|
||||
const electronApp = injeca.provide('host:electron:app', () => app)
|
||||
const autoUpdater = injeca.provide('services:auto-updater', {
|
||||
dependsOn: { appConfig },
|
||||
build: ({ dependsOn }) => setupAutoUpdater({
|
||||
getStoredUpdateLane: () => dependsOn.appConfig.get()?.updateChannel,
|
||||
setStoredUpdateLane: (lane) => {
|
||||
const currentConfig = dependsOn.appConfig.get()
|
||||
const current = dependsOn.appConfig.get()
|
||||
dependsOn.appConfig.update({
|
||||
language: currentConfig?.language ?? 'en',
|
||||
language: current?.language ?? 'en',
|
||||
updateChannel: lane,
|
||||
})
|
||||
},
|
||||
@@ -192,8 +195,15 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
injeca.invoke({
|
||||
dependsOn: { mainWindow, tray, serverChannel, airiHttpServer, pluginHost, mcpStdioManager, onboardingWindow: onboardingWindowManager },
|
||||
callback: noop,
|
||||
dependsOn: { mainWindow, tray, serverChannel, airiHttpServer, pluginHost, mcpStdioManager, onboardingWindow: onboardingWindowManager, widgetsWindow: widgetsManager, artistryConfig },
|
||||
callback: async (deps) => {
|
||||
const { context } = createContext(ipcMain)
|
||||
await setupArtistryBridge({
|
||||
widgetsManager: deps.widgetsWindow,
|
||||
context,
|
||||
artistryConfig: deps.artistryConfig,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
injeca.start().catch(err => console.error(err))
|
||||
|
||||
@@ -17,7 +17,10 @@ export async function createI18nService(params: { context: ReturnType<typeof cre
|
||||
params.i18n.locale(config.get()?.language || 'en')
|
||||
|
||||
defineInvokeHandler(params.context, i18nSetLocale, (locale) => {
|
||||
config.update({ ...config.get(), language: locale })
|
||||
const current = config.get()
|
||||
if (current) {
|
||||
config.update({ ...current, language: locale as string })
|
||||
}
|
||||
params.i18n.locale(locale)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
import type { createContext as createMainEventaContext } from '@moeru/eventa/adapters/electron/main'
|
||||
import type { ProvidedBy } from 'injeca'
|
||||
|
||||
import type { artistryConfigSchema } from '../../../configs/artistry'
|
||||
import type { Config } from '../../../libs/electron/persistence'
|
||||
import type { WidgetsWindowManager } from '../../../windows/widgets'
|
||||
import type { ArtistryProvider, ArtistryRequest } from './providers/base'
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
import { useLogg } from '@guiiai/logg'
|
||||
import { defineInvokeHandler } from '@moeru/eventa'
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import {
|
||||
artistryGenerateHeadless,
|
||||
artistrySyncConfig,
|
||||
artistryTestComfyUIConnection,
|
||||
} from '@proj-airi/stage-shared'
|
||||
import { injeca } from 'injeca'
|
||||
|
||||
import { ComfyUIProvider } from './providers/comfyui'
|
||||
import { NanoBananaProvider } from './providers/nanobanana'
|
||||
import { ReplicateProvider } from './providers/replicate'
|
||||
|
||||
const log = useLogg('artistry-bridge').useGlobalConfig()
|
||||
const DEFAULT_REMIX_ID = '48250602'
|
||||
|
||||
interface ArtistrySyncSnapshot {
|
||||
provider?: string
|
||||
model?: string
|
||||
promptPrefix?: string
|
||||
options?: Record<string, any>
|
||||
globals?: Record<string, any>
|
||||
}
|
||||
|
||||
interface TriggerConfig {
|
||||
provider?: string
|
||||
model?: string
|
||||
promptPrefix?: string
|
||||
options?: Record<string, any>
|
||||
globals?: Record<string, any>
|
||||
}
|
||||
|
||||
function robustParse(input: unknown, context?: string): Record<string, unknown> {
|
||||
if (typeof input === 'object' && input !== null)
|
||||
return input as Record<string, unknown>
|
||||
if (typeof input === 'string' && input.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(input)
|
||||
if (typeof parsed === 'object' && parsed !== null)
|
||||
return parsed as Record<string, unknown>
|
||||
log.warn(`[Artistry Bridge] robustParse(${context || 'unknown'}): Parsed JSON is not an object: ${typeof parsed}`)
|
||||
return {}
|
||||
}
|
||||
catch (e) {
|
||||
log.warn(`[Artistry Bridge] robustParse(${context || 'unknown'}): JSON parse failed: ${errorMessageFrom(e)} | Input: ${input.slice(0, 100)}`)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
const lastTriggerMap = new Map<string, string>()
|
||||
const activeRunMap = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* Volatile storage for active character card artistry defaults.
|
||||
* Synced from the renderer App.vue whenever the character or settings change.
|
||||
*/
|
||||
const cardDefaults: ArtistrySyncSnapshot = {
|
||||
provider: undefined as string | undefined,
|
||||
model: undefined as string | undefined,
|
||||
promptPrefix: undefined as string | undefined,
|
||||
options: undefined as Record<string, unknown> | undefined,
|
||||
globals: undefined as Record<string, unknown> | undefined,
|
||||
}
|
||||
|
||||
function createRunId(widgetId: string) {
|
||||
return `${widgetId}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
async function downloadImageAsBase64(url: string): Promise<string> {
|
||||
try {
|
||||
log.log(`[Artistry Bridge] Downloading image from: ${url}`)
|
||||
const response = await fetch(url)
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to fetch image: ${response.statusText}`)
|
||||
const buffer = await response.arrayBuffer()
|
||||
const base64 = Buffer.from(buffer).toString('base64')
|
||||
// NOTICE: Downstream renderer paths consume this via fetch(), which requires a data URL.
|
||||
return `data:image/png;base64,${base64}`
|
||||
}
|
||||
catch (error: unknown) {
|
||||
log.error(`[Artistry Bridge] Failed to download image: ${errorMessageFrom(error)}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function supportsJobCallback(provider: ArtistryProvider): provider is ArtistryProvider & Required<Pick<ArtistryProvider, 'setJobCallback'>> {
|
||||
return typeof provider.setJobCallback === 'function'
|
||||
}
|
||||
|
||||
// Maintaining a registry of providers
|
||||
export const artistryProviders = new Map<string, ArtistryProvider>()
|
||||
artistryProviders.set('comfyui', new ComfyUIProvider())
|
||||
artistryProviders.set('replicate', new ReplicateProvider())
|
||||
artistryProviders.set('nanobanana', new NanoBananaProvider())
|
||||
|
||||
// Deduplication map for headless requests
|
||||
const pendingHeadlessRequests = new Map<string, Promise<{ imageUrl?: string, base64?: string, error?: string }>>()
|
||||
|
||||
export async function generateHeadless(params: {
|
||||
prompt: string
|
||||
model?: string
|
||||
provider?: string
|
||||
options?: Record<string, any>
|
||||
globals?: Record<string, any>
|
||||
}): Promise<{ imageUrl?: string, base64?: string, error?: string }> {
|
||||
// Resolve config and effective globals early to secure the deduplication fingerprint
|
||||
const { config: artistryConfig } = await injeca.resolve({ config: 'configs:artistry' } as { config: ProvidedBy<Config<typeof artistryConfigSchema>> })
|
||||
const activeGlobals = (params.globals || artistryConfig.get()?.artistryGlobals || {}) as Record<string, any>
|
||||
|
||||
// Create a fingerprint for deduplication
|
||||
const sourceImage = activeGlobals?.image
|
||||
const imageHash = typeof sourceImage === 'string'
|
||||
? createHash('sha256').update(sourceImage).digest('hex')
|
||||
: 'NONE'
|
||||
|
||||
// We hash the globals (excluding the heavy image already covered by imageHash)
|
||||
// to ensure that changing a workflow or provider setting triggers a unique execution.
|
||||
const { image, ...globalsForFingerprint } = activeGlobals
|
||||
const globalsHash = createHash('sha256').update(JSON.stringify(globalsForFingerprint)).digest('hex')
|
||||
|
||||
const fingerprint = JSON.stringify({
|
||||
p: params.prompt,
|
||||
m: params.model,
|
||||
pr: params.provider,
|
||||
o: params.options,
|
||||
ih: imageHash,
|
||||
gh: globalsHash, // Include globals hash (Issue #39)
|
||||
})
|
||||
|
||||
if (pendingHeadlessRequests.has(fingerprint)) {
|
||||
log.log(`[Headless] Deduplicating identical request: ${params.prompt.slice(0, 30)}...`)
|
||||
return pendingHeadlessRequests.get(fingerprint)!
|
||||
}
|
||||
|
||||
const executionPromise = (async () => {
|
||||
const requestedProvider = (params.provider || artistryConfig.get()?.artistryProvider || 'comfyui').trim().toLowerCase()
|
||||
const provider = artistryProviders.get(requestedProvider)
|
||||
if (!provider) {
|
||||
log.error(`[Headless] CRITICAL: Provider '${requestedProvider}' not found in registry! fallback to replicate`)
|
||||
throw new Error(`Provider '${requestedProvider}' not found.`)
|
||||
}
|
||||
|
||||
// Initialize the provider
|
||||
if (provider.initialize && activeGlobals) {
|
||||
log.log(`[Headless] Initializing provider ${requestedProvider} with globals...`)
|
||||
await provider.initialize(activeGlobals)
|
||||
}
|
||||
|
||||
log.log(`[Headless] Globals keys: ${Object.keys(activeGlobals || {}).join(', ')}`)
|
||||
if (activeGlobals?.image)
|
||||
log.log(`[Headless] Source image length: ${activeGlobals.image.length}`)
|
||||
|
||||
const request: ArtistryRequest = {
|
||||
prompt: params.prompt,
|
||||
negativePrompt: params.options?.negativePrompt,
|
||||
width: typeof params.options?.width === 'number' ? params.options.width : undefined,
|
||||
height: typeof params.options?.height === 'number' ? params.options.height : undefined,
|
||||
model: params.model,
|
||||
extra: {
|
||||
...params.options,
|
||||
image: activeGlobals?.image,
|
||||
internalJobId: createRunId('headless'),
|
||||
},
|
||||
}
|
||||
|
||||
log.log(`[Headless] Starting generation with provider: ${requestedProvider}, model: ${params.model || 'default'}`)
|
||||
const job = await provider.generate(request)
|
||||
log.log(`[Headless] Job created: ${job.jobId}`)
|
||||
|
||||
// Polling/Wait for result
|
||||
if (!supportsJobCallback(provider)) {
|
||||
let isDone = false
|
||||
let lastStatus = await provider.getStatus(job.jobId)
|
||||
const start = Date.now()
|
||||
const timeout = 1000 * 60 * 5 // 5 minutes timeout
|
||||
|
||||
while (!isDone) {
|
||||
if (Date.now() - start > timeout) {
|
||||
log.error(`[Headless] Job ${job.jobId} timed out after 5 minutes.`)
|
||||
throw new Error('Image generation timed out after 5 minutes.')
|
||||
}
|
||||
|
||||
log.log(`[Headless] Polling status for job: ${job.jobId}...`)
|
||||
lastStatus = await provider.getStatus(job.jobId)
|
||||
log.log(`[Headless] Status for job ${job.jobId}: ${lastStatus.status}`)
|
||||
|
||||
if (lastStatus.status === 'succeeded' || lastStatus.status === 'failed') {
|
||||
isDone = true
|
||||
}
|
||||
if (!isDone) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
}
|
||||
}
|
||||
|
||||
if (lastStatus.status === 'failed') {
|
||||
log.error(`[Headless] Job ${job.jobId} failed: ${lastStatus.error || 'Unknown error'}`)
|
||||
throw new Error(lastStatus.error || 'Generation failed')
|
||||
}
|
||||
|
||||
log.log(`[Headless] Job ${job.jobId} succeeded. Image URL: ${lastStatus.imageUrl}`)
|
||||
const base64 = lastStatus.imageUrl ? await downloadImageAsBase64(lastStatus.imageUrl) : undefined
|
||||
return { imageUrl: lastStatus.imageUrl, base64 }
|
||||
}
|
||||
else {
|
||||
// For providers with callbacks (like ComfyUI), we wait for the result via the callback
|
||||
log.log(`[Headless] Using callback-based wait logic for provider: ${requestedProvider}`)
|
||||
return new Promise<{ imageUrl?: string, base64?: string }>((resolve, reject) => {
|
||||
const timeout = 1000 * 60 * 5 // 5 minutes timeout
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error('Image generation timed out after 5 minutes.'))
|
||||
}, timeout)
|
||||
|
||||
provider.setJobCallback(request.extra?.internalJobId as string, async (status) => {
|
||||
if (status.status === 'succeeded') {
|
||||
clearTimeout(timer)
|
||||
try {
|
||||
const base64 = status.imageUrl ? await downloadImageAsBase64(status.imageUrl) : undefined
|
||||
resolve({ imageUrl: status.imageUrl, base64 })
|
||||
}
|
||||
catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
}
|
||||
else if (status.status === 'failed') {
|
||||
clearTimeout(timer)
|
||||
reject(new Error(status.error || 'Generation failed'))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
||||
pendingHeadlessRequests.set(fingerprint, executionPromise)
|
||||
|
||||
try {
|
||||
return await executionPromise
|
||||
}
|
||||
catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) }
|
||||
}
|
||||
finally {
|
||||
// Remove from map after completion so it can be re-triggered later
|
||||
pendingHeadlessRequests.delete(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArtistryTrigger(params: {
|
||||
id: string
|
||||
componentName?: string
|
||||
componentProps?: unknown
|
||||
widgetsManager: WidgetsWindowManager
|
||||
}) {
|
||||
if (params.componentName !== 'comfy' && params.componentName !== 'artistry')
|
||||
return
|
||||
|
||||
log.log(`🔍 Intercepted widget update [${params.id}] for component: ${params.componentName}`)
|
||||
|
||||
const props = robustParse(params.componentProps, 'componentProps')
|
||||
const payload = robustParse(props.payload, 'payload')
|
||||
const artistryConfigOverrides = robustParse(props._artistryConfig, '_artistryConfig')
|
||||
const status = props.status
|
||||
const prompt = (payload.prompt || props.prompt) as string | undefined
|
||||
|
||||
// Build configuration with fallbacks:
|
||||
// 1. Explicitly provided in component props (_artistryConfig)
|
||||
// 2. Character-level defaults synced from renderer (cardDefaults)
|
||||
const config: TriggerConfig = {
|
||||
provider: artistryConfigOverrides.provider as string | undefined,
|
||||
model: (artistryConfigOverrides.model as string | undefined) || cardDefaults.model,
|
||||
promptPrefix: (artistryConfigOverrides.promptPrefix as string | undefined) || cardDefaults.promptPrefix,
|
||||
options: {
|
||||
...cardDefaults.options,
|
||||
...robustParse(artistryConfigOverrides.options, 'artistryOptions'),
|
||||
},
|
||||
// NOTICE: Keep legacy `Globals` fallback while standardizing on `globals`.
|
||||
// Older widget payloads can still send `Globals`, and dropping it now would break them.
|
||||
globals: robustParse(artistryConfigOverrides.globals || artistryConfigOverrides.Globals || cardDefaults.globals, 'artistryGlobals'),
|
||||
}
|
||||
const { config: artistryConfig } = await injeca.resolve({ config: 'configs:artistry' } as { config: ProvidedBy<Config<typeof artistryConfigSchema>> })
|
||||
const providerId = config.provider || cardDefaults.provider || artistryConfig.get()?.artistryProvider || 'comfyui'
|
||||
|
||||
// [BY DESIGN]: Short-circuit if artistry is explicitly disabled (provider: 'none').
|
||||
// This prevents noisy "Provider not found" errors when the feature is intentionally bypassed.
|
||||
if (providerId === 'none') {
|
||||
log.log(`[Artistry Bridge] Provider is 'none'. Bypassing generation for widget: ${params.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract options and remix ID fallback
|
||||
const options = config.options || {}
|
||||
// TODO: move remix defaults into per-card/provider config to remove this fallback heuristic.
|
||||
const remixId = (payload.remixId || props.remixId || options.remixId) as string | undefined
|
||||
|| (props.status === 'generating' && !prompt ? DEFAULT_REMIX_ID : undefined)
|
||||
|
||||
const mode = props.mode || (remixId ? 'remix' : 'generate')
|
||||
const triggerFingerprint = `${mode}:${remixId || ''}:${prompt || ''}`
|
||||
|
||||
// [BY DESIGN]: We only trigger a new generation if the fingerprint (mode + remixId + prompt)
|
||||
// has actually changed for this specific widget instance. This denotes our stance on the matter:
|
||||
// it serves as a critical safety guard against redundant, billable API calls triggered
|
||||
// by reactive UI loops or state synchronization "storms". While this prevents retrying
|
||||
// the exact same prompt on the same widget instance without a manual modification,
|
||||
// it protects users from unexpected credit consumption in a high-frequency reactive
|
||||
// bridge environment. (Refer to Catalog Issue #31).
|
||||
if (status === 'generating' && lastTriggerMap.get(params.id) !== triggerFingerprint && (prompt || remixId)) {
|
||||
log.log(`🎯 TRIGGER DETECTED [${params.id}]: ${triggerFingerprint} | Mode: ${mode} | Provider: ${providerId}`)
|
||||
lastTriggerMap.set(params.id, triggerFingerprint)
|
||||
const runId = createRunId(params.id)
|
||||
activeRunMap.set(params.id, runId)
|
||||
|
||||
const provider = artistryProviders.get(providerId)
|
||||
if (!provider) {
|
||||
log.error(`🔴 Provider '${providerId}' not found.`)
|
||||
params.widgetsManager.updateWidget({
|
||||
id: params.id,
|
||||
componentProps: { status: 'error', actionLabel: `Provider '${providerId}' not available` },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize the provider with global config fallback
|
||||
const activeGlobals = config.globals || artistryConfig.get()?.artistryGlobals
|
||||
if (provider.initialize && activeGlobals) {
|
||||
log.log(`[Artistry Bridge] Initializing provider ${providerId} with ${config.globals ? 'provided' : 'fallback'} globals...`)
|
||||
await provider.initialize(activeGlobals)
|
||||
}
|
||||
|
||||
try {
|
||||
// Build the abstract request
|
||||
const request: ArtistryRequest = {
|
||||
prompt: config.promptPrefix ? `${config.promptPrefix} ${prompt}` : (prompt || ''),
|
||||
model: config.model,
|
||||
extra: {
|
||||
...options,
|
||||
...props, // Include root componentProps overrides (template, node overrides)
|
||||
...payload, // Payload takes precedence
|
||||
internalJobId: runId, // Track each generation independently, even on the same widget.
|
||||
remixId,
|
||||
},
|
||||
}
|
||||
|
||||
const updateIfActive = (statusUpdate: Record<string, any>) => {
|
||||
// NOTICE: the same widget can kick off another generation before the previous one fully
|
||||
// settles. Only the most recent run is allowed to keep updating the widget state.
|
||||
if (activeRunMap.get(params.id) !== runId)
|
||||
return
|
||||
|
||||
// [BY DESIGN]: Merging status updates into existing props preserves fields like imageUrl
|
||||
// that would otherwise be lost when the final 'done' status is sent.
|
||||
const existing = params.widgetsManager.getWidgetSnapshot(params.id)
|
||||
params.widgetsManager.updateWidget({
|
||||
id: params.id,
|
||||
componentProps: {
|
||||
...(existing?.componentProps as any || {}),
|
||||
...statusUpdate,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// If the provider accepts callbacks (like ComfyUI streaming stdout)
|
||||
if (supportsJobCallback(provider)) {
|
||||
provider.setJobCallback(runId, (statusUpdate) => {
|
||||
updateIfActive(statusUpdate as Record<string, any>)
|
||||
if (statusUpdate.status === 'succeeded') {
|
||||
log.log(`🎉 Job complete (via callback) for ${params.id}. Sending final status: done`)
|
||||
updateIfActive({ status: 'done', progress: 100, actionLabel: undefined })
|
||||
}
|
||||
else if (statusUpdate.status === 'failed') {
|
||||
log.log(`🔴 Job failed (via callback) for ${params.id}. Preserving error status.`)
|
||||
// [BY DESIGN]: Don't send status: 'done' here to avoid clearing the error message (Issue #56)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const job = await provider.generate(request)
|
||||
|
||||
// Polling loop for providers that don't do callbacks (like Replicate)
|
||||
if (!supportsJobCallback(provider)) {
|
||||
let isDone = false
|
||||
const startTime = Date.now()
|
||||
const timeoutLength = 1000 * 60 * 5 // 5 minutes timeout (Issue #56)
|
||||
|
||||
while (!isDone) {
|
||||
// Check for timeout
|
||||
if (Date.now() - startTime > timeoutLength) {
|
||||
log.error(`[Artistry Bridge] Job ${job.jobId} timed out after 5 minutes.`)
|
||||
updateIfActive({ status: 'error', actionLabel: 'Generation timed out' })
|
||||
break
|
||||
}
|
||||
|
||||
// Check if this run is still the active one for this widget.
|
||||
// If a user started a new generation, we must kill the old polling loop.
|
||||
if (activeRunMap.get(params.id) !== runId) {
|
||||
log.log(`[Artistry Bridge] Stale polling loop detected for ${params.id}. Aborting background task.`)
|
||||
break
|
||||
}
|
||||
|
||||
const status = await provider.getStatus(job.jobId)
|
||||
if (status.status === 'succeeded' || status.status === 'failed') {
|
||||
isDone = true
|
||||
}
|
||||
|
||||
updateIfActive(status as Record<string, any>)
|
||||
|
||||
if (!isDone) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
}
|
||||
}
|
||||
|
||||
if (isDone) {
|
||||
const finalStatus = await provider.getStatus(job.jobId)
|
||||
if (finalStatus.status === 'succeeded') {
|
||||
log.log(`🎉 Job complete (via polling) for ${params.id}. Sending final status: done`)
|
||||
updateIfActive({ status: 'done', progress: 100, actionLabel: undefined })
|
||||
}
|
||||
else {
|
||||
log.log(`🔴 Job failed (via polling) for ${params.id}. Preserving error status.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error: unknown) {
|
||||
const message = errorMessageFrom(error) ?? 'Unknown generation error'
|
||||
log.error(`🔴 Generation failed: ${message}`)
|
||||
if (activeRunMap.get(params.id) === runId) {
|
||||
lastTriggerMap.delete(params.id) // [BY DESIGN]: Clear fingerprint on failure to allow retry (Issue #44)
|
||||
params.widgetsManager.updateWidget({
|
||||
id: params.id,
|
||||
componentProps: { status: 'error', actionLabel: message },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function setupArtistryBridge(params: {
|
||||
widgetsManager: WidgetsWindowManager
|
||||
context?: ReturnType<typeof createMainEventaContext>['context']
|
||||
artistryConfig: Config<typeof artistryConfigSchema>
|
||||
}) {
|
||||
log.log('🚀 Initializing Artistry bridge (Spawn + Update Interceptor + Headless Handler)...')
|
||||
|
||||
if (params.context) {
|
||||
defineInvokeHandler(params.context, artistryGenerateHeadless, async (payload) => {
|
||||
log.log(`[Artistry Bridge] [Headless] Received invoke for prompt: ${payload.prompt.slice(0, 50)}...`)
|
||||
return await generateHeadless(payload)
|
||||
})
|
||||
|
||||
defineInvokeHandler(params.context, artistrySyncConfig, (payload) => {
|
||||
log.log(`🔄 Syncing artistry config to main. Provider: ${payload.provider}`)
|
||||
params.artistryConfig.update({
|
||||
artistryProvider: payload.provider || params.artistryConfig.get()?.artistryProvider || 'comfyui',
|
||||
artistryGlobals: payload.globals || params.artistryConfig.get()?.artistryGlobals || {
|
||||
comfyuiServerUrl: 'http://localhost:8188',
|
||||
comfyuiSavedWorkflows: [],
|
||||
comfyuiActiveWorkflow: '',
|
||||
replicateApiKey: '',
|
||||
replicateDefaultModel: 'black-forest-labs/flux-schnell',
|
||||
replicateAspectRatio: '16:9',
|
||||
replicateInferenceSteps: 4,
|
||||
nanobananaApiKey: '',
|
||||
nanobananaModel: 'gemini-3.1-flash-image-preview',
|
||||
nanobananaResolution: '1K',
|
||||
},
|
||||
})
|
||||
|
||||
// Update character-level defaults (volatile only)
|
||||
cardDefaults.provider = payload.provider
|
||||
cardDefaults.model = payload.model
|
||||
cardDefaults.promptPrefix = payload.promptPrefix
|
||||
cardDefaults.options = payload.options
|
||||
cardDefaults.globals = payload.globals
|
||||
})
|
||||
|
||||
defineInvokeHandler(params.context, artistryTestComfyUIConnection, async (payload) => {
|
||||
log.log(`🔌 Testing ComfyUI connection at: ${payload.url}`)
|
||||
try {
|
||||
const url = payload.url.replace(/\/+$/, '')
|
||||
const controller = new AbortController()
|
||||
const id = setTimeout(() => controller.abort(), 10000)
|
||||
const resp = await fetch(`${url}/system_stats`, { signal: controller.signal })
|
||||
clearTimeout(id)
|
||||
|
||||
if (!resp.ok)
|
||||
throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json() as { devices?: Array<{ name?: string, vram_total?: number }> }
|
||||
const gpus = data.devices?.map(d => d.name).join(', ') || 'Unknown GPU'
|
||||
const vram = data.devices?.[0]?.vram_total
|
||||
const vramStr = vram ? `${(vram / 1024 / 1024 / 1024).toFixed(1)} GB` : ''
|
||||
return {
|
||||
ok: true,
|
||||
info: `Connected — ${gpus}${vramStr ? ` (${vramStr} VRAM)` : ''}`,
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
const message = errorMessageFrom(e) ?? 'Unknown connection error'
|
||||
log.error(`🔌 ComfyUI connection test failed: ${message}`)
|
||||
return {
|
||||
ok: false,
|
||||
info: `Failed: ${message}`,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const originalUpdateWidget = params.widgetsManager.updateWidget
|
||||
params.widgetsManager.updateWidget = async (payload) => {
|
||||
const snapshot = params.widgetsManager.getWidgetSnapshot(payload.id)
|
||||
await originalUpdateWidget.call(params.widgetsManager, payload)
|
||||
await handleArtistryTrigger({
|
||||
id: payload.id,
|
||||
componentName: snapshot?.componentName,
|
||||
componentProps: payload.componentProps,
|
||||
widgetsManager: params.widgetsManager,
|
||||
})
|
||||
}
|
||||
|
||||
const originalPushWidget = params.widgetsManager.pushWidget
|
||||
params.widgetsManager.pushWidget = async (payload) => {
|
||||
if (payload.componentName === 'comfy' || payload.componentName === 'artistry') {
|
||||
log.log(`🖼️ Enabling 'Living Wall' mode for ${payload.id}. Forcing infinite TTL. (Component: ${payload.componentName})`)
|
||||
payload.ttlMs = 0
|
||||
}
|
||||
|
||||
const resultId = await originalPushWidget.call(params.widgetsManager, payload)
|
||||
|
||||
await handleArtistryTrigger({
|
||||
id: resultId,
|
||||
componentName: payload.componentName,
|
||||
componentProps: payload.componentProps,
|
||||
widgetsManager: params.widgetsManager,
|
||||
})
|
||||
|
||||
return resultId
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,16 @@ import type { WidgetsWindowManager } from '../../../windows/widgets'
|
||||
|
||||
import { defineInvokeHandlers } from '@moeru/eventa'
|
||||
|
||||
import { widgetsAdd, widgetsClear, widgetsFetch, widgetsOpenWindow, widgetsPrepareWindow, widgetsRemove, widgetsUpdate } from '../../../../shared/eventa'
|
||||
import {
|
||||
widgetsAdd,
|
||||
widgetsClear,
|
||||
widgetsFetch,
|
||||
widgetsHideWindow,
|
||||
widgetsOpenWindow,
|
||||
widgetsPrepareWindow,
|
||||
widgetsRemove,
|
||||
widgetsUpdate,
|
||||
} from '../../../../shared/eventa'
|
||||
import {
|
||||
normalizeOptionalWidgetId,
|
||||
normalizeRequiredWidgetId,
|
||||
@@ -49,6 +58,7 @@ export function createWidgetsService(params: { context: ReturnType<typeof create
|
||||
defineInvokeHandlers(params.context, {
|
||||
widgetsPrepareWindow,
|
||||
widgetsOpenWindow,
|
||||
widgetsHideWindow,
|
||||
widgetsAdd,
|
||||
widgetsUpdate,
|
||||
widgetsRemove,
|
||||
@@ -67,6 +77,11 @@ export function createWidgetsService(params: { context: ReturnType<typeof create
|
||||
const id = normalizeOptionalWidgetId(payload?.id)
|
||||
return params.widgetsManager.openWindow(id ? { id } : undefined)
|
||||
},
|
||||
widgetsHideWindow: async (payload, options) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
return undefined
|
||||
return params.widgetsManager!.hideWindow(payload ?? undefined)
|
||||
},
|
||||
widgetsAdd: async (payload, options) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
return undefined
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Abstract Artistry Provider Interface
|
||||
*
|
||||
* All image generation providers (ComfyUI, Replicate, etc.) must implement
|
||||
* this interface. The bridge dispatches to the active provider based on
|
||||
* the current AIRI card's artistry settings.
|
||||
*/
|
||||
|
||||
export interface ArtistryRequest {
|
||||
/** The text prompt describing the desired image */
|
||||
prompt: string
|
||||
/** Negative prompt — things to avoid (provider support varies) */
|
||||
negativePrompt?: string
|
||||
/** Image width in pixels */
|
||||
width?: number
|
||||
/** Image height in pixels */
|
||||
height?: number
|
||||
/** Provider-specific model identifier */
|
||||
model?: string
|
||||
/** Provider-specific extras (e.g. remixId, checkpoint, seed, aspect_ratio) */
|
||||
extra?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface ArtistryJob {
|
||||
/** Internal job ID for tracking */
|
||||
jobId: string
|
||||
/** Provider's native job/prediction ID */
|
||||
providerJobId: string
|
||||
}
|
||||
|
||||
export type ArtistryJobStatusType = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
|
||||
|
||||
export interface ArtistryJobStatus {
|
||||
status: ArtistryJobStatusType
|
||||
/** Generation progress 0-100 (not all providers support this) */
|
||||
progress?: number
|
||||
/** Final output image URL */
|
||||
imageUrl?: string
|
||||
/** Error message if failed */
|
||||
error?: string
|
||||
/** Human-readable label of current stage (e.g. "Sampling", "VAE Decode") */
|
||||
actionLabel?: string
|
||||
}
|
||||
|
||||
export interface ArtistryProviderConfig {
|
||||
/** Unique provider ID (e.g. "comfyui", "replicate") */
|
||||
id: string
|
||||
/** Human-readable display name */
|
||||
name: string
|
||||
/** Provider-specific configuration (API keys, paths, etc.) */
|
||||
settings: Record<string, any>
|
||||
}
|
||||
|
||||
export interface ArtistryProvider {
|
||||
/** Unique provider ID */
|
||||
readonly id: string
|
||||
/** Human-readable display name */
|
||||
readonly name: string
|
||||
|
||||
/**
|
||||
* Start an image generation job.
|
||||
* Returns a job handle for tracking.
|
||||
*/
|
||||
generate: (request: ArtistryRequest) => Promise<ArtistryJob>
|
||||
|
||||
/**
|
||||
* Poll the current status of a running job.
|
||||
* Returns status, progress, and final image URL when done.
|
||||
*/
|
||||
getStatus: (jobId: string) => Promise<ArtistryJobStatus>
|
||||
|
||||
/**
|
||||
* Cancel a running job (optional — not all providers support this).
|
||||
*/
|
||||
cancel?: (jobId: string) => Promise<void>
|
||||
|
||||
/**
|
||||
* Called when the provider is first initialized with its config.
|
||||
*/
|
||||
initialize?: (config: Record<string, any>) => Promise<void>
|
||||
|
||||
/**
|
||||
* Optional push callback for providers that stream or callback status updates.
|
||||
*/
|
||||
setJobCallback?: (jobId: string, callback: (status: ArtistryJobStatus) => void) => void
|
||||
|
||||
/**
|
||||
* Clean up resources when the provider is being switched out.
|
||||
*/
|
||||
dispose?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-card artistry settings stored in AiriExtension.modules.artistry
|
||||
*/
|
||||
export interface ArtistryModuleSettings {
|
||||
/** Active provider ID (e.g. "comfyui", "replicate") */
|
||||
provider?: string
|
||||
/** Provider-specific model identifier */
|
||||
model?: string
|
||||
/** String prepended to every LLM-generated prompt for style consistency */
|
||||
defaultPromptPrefix?: string
|
||||
/**
|
||||
* Free-form provider-specific options as a JSON object.
|
||||
* For Replicate: { go_fast: true, megapixels: "1", aspect_ratio: "16:9", ... }
|
||||
* For ComfyUI: { remixId: 48250602, checkpoint: "bunnyMint.safetensors" }
|
||||
*/
|
||||
providerOptions?: Record<string, any>
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import type { ArtistryJob, ArtistryJobStatus, ArtistryProvider, ArtistryRequest } from './base'
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
|
||||
import { useLogg } from '@guiiai/logg'
|
||||
|
||||
const log = useLogg('providers-comfyui').useGlobalConfig()
|
||||
|
||||
const POLL_INTERVAL_MS = 5000
|
||||
const POLL_TIMEOUT_MS = 1000 * 60 * 5 // 5 minutes
|
||||
|
||||
export class ComfyUIProvider implements ArtistryProvider {
|
||||
readonly id = 'comfyui'
|
||||
readonly name = 'ComfyUI (Local)'
|
||||
|
||||
private serverUrl = 'http://localhost:8188'
|
||||
private savedWorkflows: any[] = []
|
||||
private activeWorkflowId = ''
|
||||
|
||||
private jobResults = new Map<string, ArtistryJobStatus>()
|
||||
private callbacks = new Map<string, (status: ArtistryJobStatus) => void>()
|
||||
|
||||
private async fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = 30000) {
|
||||
const controller = new AbortController()
|
||||
const id = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(id)
|
||||
return response
|
||||
}
|
||||
catch (error) {
|
||||
clearTimeout(id)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) {
|
||||
this.callbacks.set(jobId, callback)
|
||||
// If we already have a result, fire it immediately
|
||||
const result = this.jobResults.get(jobId)
|
||||
if (result)
|
||||
callback(result)
|
||||
}
|
||||
|
||||
private updateStatus(jobId: string, status: ArtistryJobStatus) {
|
||||
this.jobResults.set(jobId, status)
|
||||
const callback = this.callbacks.get(jobId)
|
||||
if (callback)
|
||||
callback(status)
|
||||
}
|
||||
|
||||
async initialize(config: any): Promise<void> {
|
||||
if (config?.comfyuiServerUrl)
|
||||
this.serverUrl = config.comfyuiServerUrl.replace(/\/+$/, '') // strip trailing slashes
|
||||
if (config?.comfyuiSavedWorkflows)
|
||||
this.savedWorkflows = config.comfyuiSavedWorkflows
|
||||
if (config?.comfyuiActiveWorkflow)
|
||||
this.activeWorkflowId = config.comfyuiActiveWorkflow
|
||||
}
|
||||
|
||||
async generate(request: ArtistryRequest): Promise<ArtistryJob> {
|
||||
const jobId = request.extra?.internalJobId || Math.random().toString(36).slice(2)
|
||||
|
||||
// Resolve which workflow template to use --- per-request template override takes precedence over card model default
|
||||
const templateId = request.extra?.template || request.model || this.activeWorkflowId
|
||||
const template = this.savedWorkflows.find((w: any) => w.id === templateId)
|
||||
|
||||
if (!template) {
|
||||
this.updateStatus(jobId, {
|
||||
status: 'failed',
|
||||
error: 'No workflow template configured. Upload a workflow in Settings > Providers > ComfyUI.',
|
||||
actionLabel: 'Error: No workflow configured',
|
||||
})
|
||||
return { jobId, providerJobId: jobId }
|
||||
}
|
||||
|
||||
// Start async generation
|
||||
this.pollForResult(jobId, template, request)
|
||||
|
||||
return { jobId, providerJobId: jobId }
|
||||
}
|
||||
|
||||
private async pollForResult(
|
||||
jobId: string,
|
||||
template: { workflow: Record<string, any>, exposedFields: Record<string, string[]> },
|
||||
request: ArtistryRequest,
|
||||
) {
|
||||
this.updateStatus(jobId, { status: 'running', actionLabel: 'Preparing workflow...' })
|
||||
|
||||
try {
|
||||
// 0. Handle potential image and prompt upload bidirectional flow
|
||||
const extraStr = JSON.stringify(request.extra || {})
|
||||
const workflowStr = JSON.stringify(template.workflow || {})
|
||||
const hasImagePlaceholder = extraStr.includes('{{IMAGE}}') || workflowStr.includes('{{IMAGE}}')
|
||||
const hasPromptPlaceholder = extraStr.includes('{{PROMPT}}') || workflowStr.includes('{{PROMPT}}')
|
||||
|
||||
let uploadedImageName = ''
|
||||
if (hasImagePlaceholder && request.extra?.image) {
|
||||
log.log(`[ComfyUI] Bidirectional flow detected. Uploading texture for job ${jobId}...`)
|
||||
this.updateStatus(jobId, { status: 'running', actionLabel: 'Uploading texture to ComfyUI...' })
|
||||
try {
|
||||
uploadedImageName = await this.uploadImage(request.extra.image)
|
||||
log.log(`[ComfyUI] Texture uploaded as: ${uploadedImageName}`)
|
||||
}
|
||||
catch (e: any) {
|
||||
log.error(`[ComfyUI] Texture upload failed: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Apply overrides to the workflow template (standard injection)
|
||||
let resolvedPrompt = this.applyOverrides(template, request)
|
||||
|
||||
// 2. Perform final placeholder resolution across the ENTIRE resolved prompt
|
||||
if (hasImagePlaceholder || hasPromptPlaceholder) {
|
||||
log.log(`[ComfyUI] Performing final placeholder resolution for ${jobId}...`)
|
||||
const replacements: Record<string, string> = {
|
||||
'{{PROMPT}}': request.prompt || '',
|
||||
}
|
||||
if (uploadedImageName) {
|
||||
replacements['{{IMAGE}}'] = uploadedImageName
|
||||
}
|
||||
|
||||
resolvedPrompt = this.replacePlaceholders(resolvedPrompt, replacements)
|
||||
}
|
||||
|
||||
log.log(`[ComfyUI] Resolved prompt for ${jobId}:`, JSON.stringify(resolvedPrompt, null, 2))
|
||||
|
||||
// 2. POST /prompt to queue the workflow
|
||||
this.updateStatus(jobId, { status: 'running', actionLabel: 'Queuing in ComfyUI...' })
|
||||
|
||||
let queueResp: Response
|
||||
try {
|
||||
queueResp = await this.fetchWithTimeout(`${this.serverUrl}/prompt`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: resolvedPrompt }),
|
||||
}, 15000)
|
||||
}
|
||||
catch (e: any) {
|
||||
throw new Error(`Cannot connect to ComfyUI at ${this.serverUrl}: ${e.message}`)
|
||||
}
|
||||
|
||||
if (!queueResp.ok) {
|
||||
const errorBody = await queueResp.text()
|
||||
throw new Error(`Workflow error: ${errorBody.slice(0, 200)}`)
|
||||
}
|
||||
|
||||
const queueData = await queueResp.json()
|
||||
const promptId = queueData.prompt_id
|
||||
if (!promptId) {
|
||||
throw new Error('ComfyUI returned no prompt_id')
|
||||
}
|
||||
|
||||
log.log(`[ComfyUI] Queued prompt ${promptId} for job ${jobId}`)
|
||||
this.updateStatus(jobId, { status: 'running', actionLabel: 'Generating...' })
|
||||
|
||||
// 3. Poll /history/{prompt_id} until completion
|
||||
let historyDone = false
|
||||
let attempt = 0
|
||||
const startTime = Date.now()
|
||||
|
||||
while (!historyDone) {
|
||||
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS))
|
||||
attempt++
|
||||
|
||||
if (Date.now() - startTime > POLL_TIMEOUT_MS) {
|
||||
throw new Error('Generation timed out after 5 minutes')
|
||||
}
|
||||
|
||||
if (attempt % 3 === 0) {
|
||||
log.log(`[ComfyUI] Polling history for ${promptId}... attempt ${attempt}`)
|
||||
}
|
||||
|
||||
let histResp: Response
|
||||
try {
|
||||
histResp = await this.fetchWithTimeout(`${this.serverUrl}/history/${promptId}`, {}, 10000)
|
||||
}
|
||||
catch (e: any) {
|
||||
throw new Error(`ComfyUI disconnected during polling: ${e.message}`)
|
||||
}
|
||||
|
||||
if (histResp.ok) {
|
||||
const histData = await histResp.json()
|
||||
if (histData[promptId]) {
|
||||
let outputs = histData[promptId].outputs
|
||||
const stats = histData[promptId].status
|
||||
|
||||
// 3.1. Race condition protection: If outputs are missing, wait a beat and retry once
|
||||
if ((!outputs || Object.keys(outputs).length === 0) && !historyDone) {
|
||||
log.warn(`[ComfyUI] Job ${jobId} finished but outputs are empty. Retrying history in 1s...`)
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
const retryResp = await this.fetchWithTimeout(`${this.serverUrl}/history/${promptId}`, {}, 10000)
|
||||
if (retryResp.ok) {
|
||||
const retryData = await retryResp.json()
|
||||
if (retryData[promptId] && retryData[promptId].outputs) {
|
||||
log.log(`[ComfyUI] Retry successful for ${jobId}. Managed to find outputs!`)
|
||||
outputs = retryData[promptId].outputs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log raw history if no images found or if there are status messages
|
||||
if (stats?.messages && stats.messages.length > 0) {
|
||||
log.warn(`[ComfyUI] History messages for ${promptId}:`, stats.messages)
|
||||
}
|
||||
|
||||
// Find first image in any node's output
|
||||
for (const nodeId in outputs) {
|
||||
const nodeOutput = outputs[nodeId]
|
||||
if (nodeOutput.images && nodeOutput.images.length > 0) {
|
||||
const img = nodeOutput.images[0]
|
||||
const imageUrl = `${this.serverUrl}/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || '')}&type=${encodeURIComponent(img.type || 'output')}`
|
||||
log.log(`[ComfyUI] Generation complete for job ${jobId}. Image: ${imageUrl}`)
|
||||
this.updateStatus(jobId, { status: 'succeeded', progress: 100, imageUrl })
|
||||
historyDone = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Job finished but no images
|
||||
if (!historyDone) {
|
||||
log.error(`[ComfyUI] Job finished for ${jobId} (Prompt ${promptId}) but no output images found. Raw History:`, JSON.stringify(histData[promptId], null, 2))
|
||||
this.updateStatus(jobId, {
|
||||
status: 'failed',
|
||||
error: 'Job completed but no images were generated',
|
||||
actionLabel: 'Error: No images generated',
|
||||
})
|
||||
historyDone = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
const errorMessage = error.message || String(error)
|
||||
log.error(`[ComfyUI] Generation failed for job ${jobId}: ${errorMessage}`)
|
||||
this.updateStatus(jobId, {
|
||||
status: 'failed',
|
||||
error: errorMessage,
|
||||
actionLabel: `Error: ${errorMessage.slice(0, 50)}${errorMessage.length > 50 ? '...' : ''}`,
|
||||
})
|
||||
}
|
||||
finally {
|
||||
// Clean up callback and job result after completion to prevent memory leaks
|
||||
setTimeout(() => {
|
||||
this.callbacks.delete(jobId)
|
||||
this.jobResults.delete(jobId)
|
||||
}, 10000)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply request overrides to a workflow template.
|
||||
* Matches nodes by _meta.title and overwrites exposed input fields.
|
||||
* Mirrors the logic from CUIPP's getComfyTemplate.js.
|
||||
*/
|
||||
private applyOverrides(
|
||||
template: { workflow: Record<string, any>, exposedFields: Record<string, string[]> },
|
||||
request: ArtistryRequest,
|
||||
): Record<string, any> {
|
||||
// Deep clone the workflow so we don't mutate the stored template
|
||||
const prompt = JSON.parse(JSON.stringify(template.workflow))
|
||||
|
||||
// Build overrides from the request
|
||||
const overrides: Record<string, Record<string, any>> = {}
|
||||
|
||||
// The main prompt text goes into the first exposed "text" field we find
|
||||
// COMPAT: If the user ALREADY used a {{PROMPT}} placeholder in the extra params, we skip this auto-injection
|
||||
const hasPromptPlaceholder = JSON.stringify(request.extra).includes('{{PROMPT}}')
|
||||
if (request.prompt && !hasPromptPlaceholder) {
|
||||
for (const [nodeTitle, fields] of Object.entries(template.exposedFields)) {
|
||||
if (fields.includes('text')) {
|
||||
if (!overrides[nodeTitle])
|
||||
overrides[nodeTitle] = {}
|
||||
overrides[nodeTitle].text = request.prompt
|
||||
break // Only inject into the first text field
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge in any explicit per-node overrides from request.extra
|
||||
// We skip known reserved keys and look for keys that might be node titles
|
||||
const reservedKeys = ['template', 'internalJobId', 'remixId', 'options']
|
||||
if (request.extra) {
|
||||
for (const [key, value] of Object.entries(request.extra)) {
|
||||
if (reservedKeys.includes(key))
|
||||
continue
|
||||
|
||||
// If it's an object, treat it as a potential node override
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
if (!overrides[key])
|
||||
overrides[key] = {}
|
||||
Object.assign(overrides[key], value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Still support legacy .options nesting just in case
|
||||
if (request.extra?.options) {
|
||||
for (const [nodeTitle, fields] of Object.entries(request.extra.options as Record<string, Record<string, any>>)) {
|
||||
if (!overrides[nodeTitle])
|
||||
overrides[nodeTitle] = {}
|
||||
Object.assign(overrides[nodeTitle], fields)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply overrides to matching nodes
|
||||
for (const nodeId in prompt) {
|
||||
const node = prompt[nodeId]
|
||||
const title = node._meta?.title
|
||||
if (title && overrides[title]) {
|
||||
const nodeOverrides = overrides[title]
|
||||
for (const [field, value] of Object.entries(nodeOverrides)) {
|
||||
// Only override exposed fields (security boundary)
|
||||
if (template.exposedFields[title]?.includes(field)) {
|
||||
node.inputs[field] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-randomize seed if it's exposed and not explicitly set
|
||||
for (const [nodeTitle, fields] of Object.entries(template.exposedFields)) {
|
||||
if (fields.includes('seed') && (overrides[nodeTitle]?.seed === undefined || overrides[nodeTitle]?.seed === null)) {
|
||||
for (const nodeId in prompt) {
|
||||
const node = prompt[nodeId]
|
||||
if (node._meta?.title === nodeTitle) {
|
||||
node.inputs.seed = Math.floor(Math.random() * 1e15)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
async getStatus(jobId: string): Promise<ArtistryJobStatus> {
|
||||
return this.jobResults.get(jobId) || { status: 'queued' }
|
||||
}
|
||||
|
||||
private async uploadImage(base64Data: string): Promise<string> {
|
||||
// 1. Clean data URL prefix if present
|
||||
const base64 = base64Data.replace(/^data:image\/\w+;base64,/, '')
|
||||
const buffer = Buffer.from(base64, 'base64')
|
||||
|
||||
// 2. Prepare multipart form data
|
||||
const formData = new FormData()
|
||||
const fileName = `vhack_${Date.now()}.png`
|
||||
|
||||
// Electron/Node 18+ fetch handles Blobs in FormData
|
||||
const blob = new Blob([buffer], { type: 'image/png' })
|
||||
formData.append('image', blob, fileName)
|
||||
formData.append('overwrite', 'true')
|
||||
|
||||
const response = await this.fetchWithTimeout(`${this.serverUrl}/upload/image`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}, 60000) // 1 minute timeout for uploads
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
throw new Error(`ComfyUI upload failed: ${error}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.name // Returns the filename in ComfyUI's input folder
|
||||
}
|
||||
|
||||
private replacePlaceholders(obj: any, replacements: Record<string, string>): any {
|
||||
if (typeof obj === 'string') {
|
||||
let result = obj
|
||||
for (const [placeholder, value] of Object.entries(replacements)) {
|
||||
result = result.replace(new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'), 'g'), value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
if (Array.isArray(obj))
|
||||
return obj.map(item => this.replacePlaceholders(item, replacements))
|
||||
|
||||
if (obj !== null && typeof obj === 'object') {
|
||||
const newObj: any = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
newObj[key] = this.replacePlaceholders(value, replacements)
|
||||
}
|
||||
return newObj
|
||||
}
|
||||
return obj
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { ArtistryJob, ArtistryJobStatus, ArtistryProvider, ArtistryRequest } from './base'
|
||||
|
||||
import { useLogg } from '@guiiai/logg'
|
||||
|
||||
const log = useLogg('providers-nanobanana').useGlobalConfig()
|
||||
|
||||
export class NanoBananaProvider implements ArtistryProvider {
|
||||
readonly id = 'nanobanana'
|
||||
readonly name = 'Nano Banana (Google AI Studio)'
|
||||
private apiKey = ''
|
||||
private defaultModel = 'gemini-1.5-flash'
|
||||
private defaultResolution = '1K'
|
||||
|
||||
private jobResults = new Map<string, ArtistryJobStatus>()
|
||||
private callbacks = new Map<string, (status: ArtistryJobStatus) => void>()
|
||||
|
||||
setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) {
|
||||
this.callbacks.set(jobId, callback)
|
||||
const result = this.jobResults.get(jobId)
|
||||
if (result)
|
||||
callback(result)
|
||||
}
|
||||
|
||||
private updateStatus(jobId: string, status: ArtistryJobStatus) {
|
||||
this.jobResults.set(jobId, status)
|
||||
const callback = this.callbacks.get(jobId)
|
||||
if (callback)
|
||||
callback(status)
|
||||
}
|
||||
|
||||
async initialize(config: any) {
|
||||
this.apiKey = config.nanobananaApiKey || config.apiKey || ''
|
||||
if (config.nanobananaModel)
|
||||
this.defaultModel = config.nanobananaModel
|
||||
if (config.nanobananaResolution)
|
||||
this.defaultResolution = config.nanobananaResolution
|
||||
log.log(`[Nano Banana] Initialized. API Key present: ${!!this.apiKey}`)
|
||||
}
|
||||
|
||||
async generate(request: ArtistryRequest): Promise<ArtistryJob> {
|
||||
if (!this.apiKey) {
|
||||
throw new Error('Nano Banana API Key not configured')
|
||||
}
|
||||
|
||||
const jobId = request.extra?.internalJobId || `nanobanana-${Date.now()}`
|
||||
const model = request.model || this.defaultModel
|
||||
const resolution = request.extra?.resolution || this.defaultResolution
|
||||
|
||||
// Robust image extraction & cleansing
|
||||
let base64Image = request.extra?.image || request.extra?.providerOptions?.image || ''
|
||||
if (base64Image.includes('base64,'))
|
||||
base64Image = base64Image.split('base64,')[1]
|
||||
|
||||
this.runGeneration(jobId, model, resolution, request.prompt, base64Image)
|
||||
|
||||
return {
|
||||
jobId,
|
||||
providerJobId: jobId,
|
||||
}
|
||||
}
|
||||
|
||||
private async runGeneration(jobId: string, model: string, resolution: string, prompt: string, base64Image: string) {
|
||||
this.updateStatus(jobId, { status: 'running', actionLabel: 'Inscribing with Nano Banana...' })
|
||||
|
||||
try {
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${this.apiKey}`
|
||||
const generationParts: any[] = [{ text: prompt }]
|
||||
if (base64Image) {
|
||||
generationParts.push({ inline_data: { mime_type: 'image/jpeg', data: base64Image } })
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: generationParts }],
|
||||
generationConfig: { imageConfig: { aspectRatio: '1:1', imageSize: resolution } },
|
||||
}),
|
||||
})
|
||||
|
||||
const json = await response.json()
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message || 'Nano Banana API Error')
|
||||
}
|
||||
|
||||
// Search all parts for the first image
|
||||
const responseParts = json.candidates?.[0]?.content?.parts || []
|
||||
const imagePart = responseParts.find((p: any) => p.inlineData?.data)
|
||||
const inlineData = imagePart?.inlineData
|
||||
|
||||
if (inlineData?.data) {
|
||||
const dataUrl = `data:${inlineData.mimeType};base64,${inlineData.data}`
|
||||
this.updateStatus(jobId, { status: 'succeeded', progress: 100, imageUrl: dataUrl })
|
||||
}
|
||||
else {
|
||||
throw new Error('No image data returned from Nano Banana')
|
||||
}
|
||||
}
|
||||
catch (e: any) {
|
||||
log.error(`[Nano Banana] Generation failed: ${e.message}`)
|
||||
this.updateStatus(jobId, { status: 'failed', error: e.message })
|
||||
}
|
||||
finally {
|
||||
// Clean up callback and job result after completion to prevent memory leaks
|
||||
setTimeout(() => {
|
||||
this.callbacks.delete(jobId)
|
||||
this.jobResults.delete(jobId)
|
||||
}, 10000)
|
||||
}
|
||||
}
|
||||
|
||||
async getStatus(jobId: string): Promise<ArtistryJobStatus> {
|
||||
return this.jobResults.get(jobId) || { status: 'queued' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { ArtistryJob, ArtistryJobStatus, ArtistryProvider, ArtistryRequest } from './base'
|
||||
|
||||
import Replicate from 'replicate'
|
||||
|
||||
import { useLogg } from '@guiiai/logg'
|
||||
|
||||
const log = useLogg('providers-replicate').useGlobalConfig()
|
||||
|
||||
export class ReplicateProvider implements ArtistryProvider {
|
||||
readonly id = 'replicate'
|
||||
readonly name = 'Replicate.ai (Cloud)'
|
||||
|
||||
private apiKey = ''
|
||||
private defaultModel = 'black-forest-labs/flux-schnell'
|
||||
private aspectRatio = '16:9'
|
||||
private inferenceSteps = 4
|
||||
private replicate: Replicate | null = null
|
||||
|
||||
private jobResults = new Map<string, ArtistryJobStatus>()
|
||||
private callbacks = new Map<string, (status: ArtistryJobStatus) => void>()
|
||||
|
||||
setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) {
|
||||
this.callbacks.set(jobId, callback)
|
||||
const result = this.jobResults.get(jobId)
|
||||
if (result)
|
||||
callback(result)
|
||||
}
|
||||
|
||||
private updateStatus(jobId: string, status: ArtistryJobStatus) {
|
||||
this.jobResults.set(jobId, status)
|
||||
const callback = this.callbacks.get(jobId)
|
||||
if (callback)
|
||||
callback(status)
|
||||
}
|
||||
|
||||
async initialize(config: any): Promise<void> {
|
||||
if (config?.replicateApiKey) {
|
||||
this.apiKey = config.replicateApiKey
|
||||
this.replicate = new Replicate({ auth: this.apiKey })
|
||||
}
|
||||
else {
|
||||
this.apiKey = ''
|
||||
this.replicate = null
|
||||
}
|
||||
if (config?.replicateDefaultModel)
|
||||
this.defaultModel = config.replicateDefaultModel
|
||||
if (config?.replicateAspectRatio)
|
||||
this.aspectRatio = config.replicateAspectRatio
|
||||
if (config?.replicateInferenceSteps)
|
||||
this.inferenceSteps = config.replicateInferenceSteps
|
||||
}
|
||||
|
||||
async generate(request: ArtistryRequest): Promise<ArtistryJob> {
|
||||
if (!this.replicate) {
|
||||
throw new Error('Replicate provider is not configured. Missing API Key.')
|
||||
}
|
||||
|
||||
const model = (request.model || request.extra?.model || this.defaultModel) as `${string}/${string}`
|
||||
const base64Image = request.extra?.image || ''
|
||||
|
||||
// 1. Start with defaults
|
||||
const hasPromptPlaceholder = JSON.stringify(request.extra).includes('{{PROMPT}}')
|
||||
let inputOptions: Record<string, any> = {
|
||||
go_fast: request.extra?.go_fast ?? true,
|
||||
aspect_ratio: request.extra?.aspect_ratio ?? this.aspectRatio,
|
||||
output_format: request.extra?.output_format ?? 'png',
|
||||
output_quality: request.extra?.output_quality ?? 80,
|
||||
num_inference_steps: request.extra?.num_inference_steps ?? this.inferenceSteps,
|
||||
}
|
||||
|
||||
// Default prompt injection if NO placeholder is used in overrides
|
||||
if (request.prompt && !hasPromptPlaceholder) {
|
||||
inputOptions.prompt = request.prompt
|
||||
}
|
||||
|
||||
// 2. Merge overrides from the "JSON Parameters" textarea if present
|
||||
if (request.extra) {
|
||||
const { image, internalJobId, remixId, ...rest } = request.extra
|
||||
// [BY DESIGN]: Strip 'prompt' from rest to avoid overwriting the prefixed version from the bridge.
|
||||
const { prompt: _overriddenPrompt, ...safeRest } = rest as any
|
||||
inputOptions = { ...inputOptions, ...safeRest }
|
||||
}
|
||||
|
||||
// 3. Recursive placeholder replacement for {{IMAGE}} and {{PROMPT}}
|
||||
const replacePlaceholders = (obj: any): any => {
|
||||
if (typeof obj === 'string') {
|
||||
let result = obj
|
||||
// Handle image replacement
|
||||
if (result.includes('{{IMAGE}}')) {
|
||||
const dataUrl = base64Image.startsWith('data:') ? base64Image : `data:image/jpeg;base64,${base64Image}`
|
||||
result = result.replace(/\{\{IMAGE\}\}/g, dataUrl)
|
||||
}
|
||||
// Handle prompt replacement
|
||||
if (result.includes('{{PROMPT}}')) {
|
||||
const truncatedPrompt = this.truncatePrompt(request.prompt || '')
|
||||
result = result.replace(/\{\{PROMPT\}\}/g, truncatedPrompt)
|
||||
}
|
||||
return result
|
||||
}
|
||||
if (Array.isArray(obj))
|
||||
return obj.map(replacePlaceholders)
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
const newObj: any = {}
|
||||
for (const key in obj)
|
||||
newObj[key] = replacePlaceholders(obj[key])
|
||||
return newObj
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
inputOptions = replacePlaceholders(inputOptions)
|
||||
|
||||
// Ensure main prompt is also truncated if not using a placeholder
|
||||
if (inputOptions.prompt && !hasPromptPlaceholder) {
|
||||
inputOptions.prompt = this.truncatePrompt(inputOptions.prompt)
|
||||
}
|
||||
|
||||
log.log(`[Replicate] Generating with model ${model}. Input keys: ${Object.keys(inputOptions).join(', ')}`)
|
||||
|
||||
// We don't await the result here because the interface expects us to return an ArtistryJob immediately.
|
||||
// However, replicate.run() blocks until completion. We'll run it in the background and store the result.
|
||||
const jobId = request.extra?.internalJobId || Math.random().toString(36).slice(2)
|
||||
|
||||
// Start generation asynchronously
|
||||
this.runGeneration(jobId, model, inputOptions)
|
||||
|
||||
return { jobId, providerJobId: jobId }
|
||||
}
|
||||
|
||||
private async runGeneration(jobId: string, model: `${string}/${string}`, input: object) {
|
||||
this.updateStatus(jobId, { status: 'running', actionLabel: 'Requesting cloud generation...' })
|
||||
|
||||
try {
|
||||
const output = await this.replicate!.run(model, { input })
|
||||
|
||||
if (!output) {
|
||||
throw new Error('No output received from Replicate.')
|
||||
}
|
||||
|
||||
log.log(`[Replicate] Raw output type: ${typeof output}, isArray: ${Array.isArray(output)}`)
|
||||
|
||||
// Replicate's run() can return a single string, an array of strings, or an array of FileUpload objects
|
||||
const items = Array.isArray(output) ? output : [output]
|
||||
if (items.length > 0) {
|
||||
const first = items[0]
|
||||
let imageUrl: string | undefined
|
||||
|
||||
// Case 1: FileUpload object with .url() method (common in recent SDK versions)
|
||||
if (typeof first === 'object' && first !== null && 'url' in first && typeof (first as any).url === 'function') {
|
||||
imageUrl = (first as any).url().href
|
||||
}
|
||||
// Case 2: Object with url property as a string
|
||||
else if (typeof first === 'object' && first !== null && 'url' in first && typeof (first as any).url === 'string') {
|
||||
imageUrl = (first as any).url
|
||||
}
|
||||
// Case 3: Simple string (the URL itself)
|
||||
else if (typeof first === 'string') {
|
||||
imageUrl = first
|
||||
}
|
||||
|
||||
if (imageUrl && (imageUrl.startsWith('http') || imageUrl.startsWith('data:'))) {
|
||||
log.log(`[Replicate] EXTRACTED IMAGE: ${imageUrl.startsWith('data:') ? 'DATA_URL' : imageUrl}`)
|
||||
this.updateStatus(jobId, { status: 'succeeded', progress: 100, imageUrl })
|
||||
}
|
||||
else {
|
||||
log.error(`[Replicate] Failed to extract URL from output: ${JSON.stringify(first)}`)
|
||||
throw new Error('Output does not contain a recognizable image URL.')
|
||||
}
|
||||
}
|
||||
else {
|
||||
throw new Error('Replicate returned an empty output array.')
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
const errorMessage = error.message || (typeof error === 'object' ? JSON.stringify(error) : String(error))
|
||||
log.error(`[Replicate] Generation Failed for ${jobId}: ${errorMessage}`)
|
||||
this.updateStatus(jobId, {
|
||||
status: 'failed',
|
||||
error: errorMessage,
|
||||
actionLabel: `Error: ${errorMessage.slice(0, 50)}${errorMessage.length > 50 ? '...' : ''}`,
|
||||
})
|
||||
}
|
||||
finally {
|
||||
// Clean up callback and job result after completion to prevent memory leaks
|
||||
setTimeout(() => {
|
||||
this.callbacks.delete(jobId)
|
||||
this.jobResults.delete(jobId)
|
||||
}, 10000)
|
||||
}
|
||||
}
|
||||
|
||||
async getStatus(jobId: string): Promise<ArtistryJobStatus> {
|
||||
return this.jobResults.get(jobId) || { status: 'queued' }
|
||||
}
|
||||
|
||||
private truncatePrompt(prompt: string, maxChars: number = 380): string {
|
||||
if (prompt.length <= maxChars)
|
||||
return prompt
|
||||
log.log(`[Replicate] Truncating prompt from ${prompt.length} to ${maxChars} chars.`)
|
||||
return `${prompt.slice(0, maxChars)}...`
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,7 @@ export interface WidgetsWindowManager {
|
||||
* - Resolves after the registry, renderer, and child windows have been cleared
|
||||
*/
|
||||
clearWidgets: () => Promise<void>
|
||||
hideWindow: (params?: { id?: string }) => Promise<void>
|
||||
/**
|
||||
* Reads the current snapshot for a single widget id.
|
||||
*
|
||||
@@ -654,6 +655,14 @@ export function setupWidgetsWindowManager(params: {
|
||||
return toSnapshot(record)
|
||||
}
|
||||
|
||||
async function hideWindow(params?: { id?: string }) {
|
||||
const id = params?.id
|
||||
const context = id ? windowContexts.get(id) : undefined
|
||||
const window = context?.window || activeWidgetsWindow
|
||||
if (window && !window.isDestroyed())
|
||||
window.hide()
|
||||
}
|
||||
|
||||
widgetsManager = {
|
||||
getWindow,
|
||||
openWindow,
|
||||
@@ -661,6 +670,7 @@ export function setupWidgetsWindowManager(params: {
|
||||
updateWidget,
|
||||
removeWidget,
|
||||
clearWidgets,
|
||||
hideWindow,
|
||||
getWidgetSnapshot,
|
||||
prepareWidgetWindow,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { defineInvokeHandler } from '@moeru/eventa'
|
||||
import { useElectronEventaContext, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { themeColorFromValue, useThemeColor } from '@proj-airi/stage-layouts/composables/theme-color'
|
||||
import { artistrySyncConfig } from '@proj-airi/stage-shared'
|
||||
import { ToasterRoot } from '@proj-airi/stage-ui/components'
|
||||
import { useInferencePreload } from '@proj-airi/stage-ui/composables'
|
||||
import { useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
|
||||
@@ -12,6 +13,7 @@ import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models
|
||||
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
|
||||
import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
|
||||
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
|
||||
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
|
||||
import { usePerfTracerBridgeStore } from '@proj-airi/stage-ui/stores/perf-tracer-bridge'
|
||||
import { listProvidersForPluginHost, shouldPublishPluginHostCapabilities } from '@proj-airi/stage-ui/stores/plugin-host-capabilities'
|
||||
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
@@ -71,6 +73,8 @@ const mcpToolsStore = useTamagotchiMcpToolsStore()
|
||||
const pluginToolsStore = useTamagotchiPluginToolsStore()
|
||||
const stageWindowLifecycleStore = useStageWindowLifecycleStore()
|
||||
const settingsAudioDeviceStore = useSettingsAudioDevice()
|
||||
const artistryStore = useArtistryStore()
|
||||
const { activeProvider, artistryGlobals, activeModel, defaultPromptPrefix, providerOptions } = storeToRefs(artistryStore)
|
||||
const context = useElectronEventaContext()
|
||||
usePerfTracerBridgeStore()
|
||||
initializeStageThreeRuntimeTraceBridge()
|
||||
@@ -87,6 +91,7 @@ const inspectPluginHost = useElectronEventaInvoke(electronPluginInspect)
|
||||
const startTrackingCursorPoint = useElectronEventaInvoke(electronStartTrackMousePosition)
|
||||
const reportPluginCapability = useElectronEventaInvoke(electronPluginUpdateCapability)
|
||||
const setLocale = useElectronEventaInvoke(i18nSetLocale)
|
||||
const syncArtistryConfig = useElectronEventaInvoke(artistrySyncConfig)
|
||||
const isChatWindowRoute = () => route.path === '/chat'
|
||||
const isWidgetsWindowRoute = () => route.path === '/widgets'
|
||||
|
||||
@@ -138,10 +143,22 @@ void mcpToolsStore.refresh().catch((error) => {
|
||||
void refreshPluginRuntimeTools()
|
||||
|
||||
watch(language, () => {
|
||||
i18n.locale.value = language.value
|
||||
setLocale(language.value)
|
||||
i18n.locale.value = language.value || 'en'
|
||||
setLocale(language.value || 'en')
|
||||
})
|
||||
|
||||
watch([activeProvider, artistryGlobals, activeModel, defaultPromptPrefix, providerOptions], () => {
|
||||
if (activeProvider.value) {
|
||||
void syncArtistryConfig({
|
||||
provider: activeProvider.value as string,
|
||||
globals: JSON.parse(JSON.stringify(artistryGlobals.value)),
|
||||
model: activeModel.value,
|
||||
promptPrefix: defaultPromptPrefix.value,
|
||||
options: providerOptions.value,
|
||||
})
|
||||
}
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
const { updateThemeColor } = useThemeColor(themeColorFromValue({ light: 'rgb(255 255 255)', dark: 'rgb(18 18 18)' }))
|
||||
watch(dark, () => updateThemeColor(), { immediate: true })
|
||||
watch(route, () => updateThemeColor(), { immediate: true })
|
||||
|
||||
@@ -2,43 +2,66 @@
|
||||
import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { ChatHistory } from '@proj-airi/stage-ui/components'
|
||||
import { ChatHistory, JournalPreviewModal } from '@proj-airi/stage-ui/components'
|
||||
import { useBackgroundStore } from '@proj-airi/stage-ui/stores/background'
|
||||
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
|
||||
import { useJournalPreviewStore } from '@proj-airi/stage-ui/stores/journal-preview'
|
||||
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
|
||||
import { BasicTextarea } from '@proj-airi/ui'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger } from 'reka-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useChatSyncStore } from '../stores/chat-sync'
|
||||
|
||||
const router = useRouter()
|
||||
const messageInput = ref('')
|
||||
const lastEnterTime = ref(0)
|
||||
const attachments = ref<{ type: 'image', data: string, mimeType: string, url: string }[]>([])
|
||||
|
||||
const chatOrchestrator = useChatOrchestratorStore()
|
||||
const chatSession = useChatSessionStore()
|
||||
const chatStream = useChatStreamStore()
|
||||
const chatSyncStore = useChatSyncStore()
|
||||
const backgroundStore = useBackgroundStore()
|
||||
const journalPreviewStore = useJournalPreviewStore()
|
||||
const airiCardStore = useAiriCardStore()
|
||||
|
||||
const { messages } = storeToRefs(chatSession)
|
||||
const { streamingMessage } = storeToRefs(chatStream)
|
||||
const { sending } = storeToRefs(chatOrchestrator)
|
||||
const { activeCardId } = storeToRefs(airiCardStore)
|
||||
const { t } = useI18n()
|
||||
const { openImagePreview } = journalPreviewStore
|
||||
const isComposing = ref(false)
|
||||
const DOUBLE_ENTER_INTERVAL_MS = 300
|
||||
const TRAILING_NEWLINES_REGEX = /[\r\n]+$/
|
||||
const SEND_MODES = ['enter', 'ctrl-enter', 'double-enter'] as const
|
||||
type SendMode = (typeof SEND_MODES)[number]
|
||||
const sendMode = useLocalStorage<SendMode>('ui/chat/settings/send-mode', 'enter')
|
||||
const lastEnterTime = ref(0)
|
||||
const sendModeLabels = computed<Record<SendMode, string>>(() => ({
|
||||
'enter': t('stage.send-mode.enter'),
|
||||
'ctrl-enter': t('stage.send-mode.ctrl-enter'),
|
||||
'double-enter': t('stage.send-mode.double-enter'),
|
||||
}))
|
||||
|
||||
const latestImageEntries = computed(() => {
|
||||
if (!activeCardId.value)
|
||||
return []
|
||||
return backgroundStore.journalEntries.slice(0, 3)
|
||||
})
|
||||
|
||||
function navigateToImageJournal() {
|
||||
if (!activeCardId.value)
|
||||
return
|
||||
router.push(`/settings/airi-card?cardId=${activeCardId.value}&tab=gallery`)
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (isComposing.value) {
|
||||
return
|
||||
@@ -59,7 +82,7 @@ async function handleSend() {
|
||||
await chatSyncStore.requestIngest({
|
||||
text: textToSend,
|
||||
attachments: attachmentsToSend,
|
||||
toolset: 'widgets',
|
||||
toolset: 'artistry',
|
||||
})
|
||||
|
||||
attachmentsToSend.forEach(att => URL.revokeObjectURL(att.url))
|
||||
@@ -67,10 +90,7 @@ async function handleSend() {
|
||||
catch (error) {
|
||||
// restore on failure
|
||||
messageInput.value = textToSend
|
||||
attachments.value = attachmentsToSend.map(att => ({
|
||||
...att,
|
||||
url: URL.createObjectURL(new Blob([Uint8Array.from(atob(att.data), c => c.charCodeAt(0))], { type: att.mimeType })),
|
||||
}))
|
||||
attachments.value = attachmentsToSend
|
||||
chatSession.setSessionMessages(chatSession.activeSessionId, [
|
||||
...messages.value,
|
||||
{
|
||||
@@ -86,6 +106,19 @@ function sendFromKeyboard() {
|
||||
void handleSend()
|
||||
}
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function handleManualAttach() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files?.length) {
|
||||
handleFilePaste(Array.from(target.files))
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessageInputKeydown(event: KeyboardEvent) {
|
||||
if (isComposing.value || event.key !== 'Enter')
|
||||
return
|
||||
@@ -159,6 +192,10 @@ async function handleDeleteMessage(index: number) {
|
||||
await chatSyncStore.requestDeleteMessage({ index })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
backgroundStore.initializeStore()
|
||||
})
|
||||
|
||||
async function handleRetryMessage(index: number) {
|
||||
await chatSyncStore.requestRetry({
|
||||
sessionId: chatSession.activeSessionId,
|
||||
@@ -178,6 +215,37 @@ async function handleRetryMessage(index: number) {
|
||||
@retry-message="handleRetryMessage($event.index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Journal Preview Chips -->
|
||||
<div v-if="latestImageEntries.length > 0" class="flex gap-2 overflow-x-auto px-2 py-1 scrollbar-none">
|
||||
<div
|
||||
v-for="entry in latestImageEntries"
|
||||
:key="entry.id"
|
||||
:class="[
|
||||
'group relative h-14 w-14 shrink-0 cursor-pointer of-hidden rounded-lg',
|
||||
'border border-primary-200/30 transition-all hover:border-primary-500',
|
||||
'dark:border-primary-800/30 dark:hover:border-primary-400',
|
||||
]"
|
||||
@click="openImagePreview(entry)"
|
||||
>
|
||||
<img :src="entry.url || ''" class="h-full w-full object-cover">
|
||||
<div :class="['absolute inset-0 flex items-end p-1', 'bg-gradient-to-t from-black/60 to-transparent']">
|
||||
<span class="truncate text-[8px] text-white font-medium">{{ entry.title }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Save Button (Top Right, Hover Only) -->
|
||||
<button
|
||||
:class="[
|
||||
'absolute right-1 top-1 z-10 p-1 rounded-md bg-black/40 text-white backdrop-blur-sm',
|
||||
'opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/60',
|
||||
]"
|
||||
title="Save to computer"
|
||||
@click.stop="journalPreviewStore.downloadImage(entry.url || '', entry.title)"
|
||||
>
|
||||
<div class="i-solar:download-minimalistic-bold-duotone text-[10px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="attachments.length > 0"
|
||||
:class="[
|
||||
@@ -259,6 +327,42 @@ async function handleRetryMessage(index: number) {
|
||||
>
|
||||
<div class="i-solar:trash-bin-2-bold-duotone" />
|
||||
</button>
|
||||
|
||||
<!-- Image Journal Deep Link -->
|
||||
<button
|
||||
class="max-h-[10lh] min-h-[1lh]"
|
||||
bg="neutral-100 dark:neutral-800"
|
||||
text="lg neutral-500 dark:neutral-400"
|
||||
hover:text="primary-500 dark:primary-400"
|
||||
flex items-center justify-center rounded-md p-2 outline-none
|
||||
transition-colors transition-transform active:scale-95
|
||||
title="Image Journal"
|
||||
@click="navigateToImageJournal"
|
||||
>
|
||||
<div class="i-solar:gallery-bold-duotone" />
|
||||
</button>
|
||||
|
||||
<!-- Attach Image -->
|
||||
<button
|
||||
class="max-h-[10lh] min-h-[1lh]"
|
||||
bg="neutral-100 dark:neutral-800"
|
||||
text="lg neutral-500 dark:neutral-400"
|
||||
hover:text="primary-500 dark:primary-400"
|
||||
flex items-center justify-center rounded-md p-2 outline-none
|
||||
transition-colors transition-transform active:scale-95
|
||||
title="Attach Image"
|
||||
@click="handleManualAttach"
|
||||
>
|
||||
<div class="i-solar:camera-add-bold-duotone" />
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
multiple
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
</div>
|
||||
<BasicTextarea
|
||||
v-model="messageInput"
|
||||
@@ -276,5 +380,8 @@ async function handleRetryMessage(index: number) {
|
||||
@keydown="handleMessageInputKeydown"
|
||||
@paste-file="handleFilePaste"
|
||||
/>
|
||||
|
||||
<!-- Shared Preview Modal -->
|
||||
<JournalPreviewModal />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -163,6 +163,7 @@ const Registry: Record<string, ReturnType<typeof defineAsyncComponent>> = {
|
||||
'extension-ui': defineAsyncComponent(async () => (await import('../widgets/extension-ui')).ExtensionUi),
|
||||
'map': defineAsyncComponent(async () => (await import('../widgets/map')).Map),
|
||||
'weather': defineAsyncComponent(async () => (await import('../widgets/weather')).Weather),
|
||||
'artistry': defineAsyncComponent(async () => (await import('../widgets/artistry')).Artistry),
|
||||
}
|
||||
|
||||
const GenericWidget = defineComponent({
|
||||
@@ -221,6 +222,7 @@ function handleClose() {
|
||||
<div v-else-if="widget" class="relative h-full">
|
||||
<component
|
||||
:is="resolveWidgetComponent(widget.componentName)"
|
||||
:id="widget.id"
|
||||
:key="widget.id"
|
||||
:title="widget.componentName"
|
||||
:model-value="widget.componentProps"
|
||||
|
||||
@@ -13,11 +13,12 @@ import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { imageJournalTools } from './tools/builtin/image-journal'
|
||||
import { weatherTools } from './tools/builtin/weather'
|
||||
import { widgetsTools } from './tools/builtin/widgets'
|
||||
|
||||
type ChatSyncMode = 'inactive' | 'authority' | 'follower'
|
||||
type ToolsetId = 'widgets'
|
||||
type ToolsetId = 'widgets' | 'artistry'
|
||||
|
||||
interface AttachmentPayload {
|
||||
type: 'image'
|
||||
@@ -238,18 +239,23 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
|
||||
}
|
||||
|
||||
function resolveTools(toolset?: ToolsetId) {
|
||||
if (toolset === 'widgets') {
|
||||
return async () => {
|
||||
const [widgetTools, weatherToolset] = await Promise.all([
|
||||
const toolsetRegistry: Record<string, () => Promise<any[]>> = {
|
||||
widgets: async () => {
|
||||
const [w, we] = await Promise.all([widgetsTools(), weatherTools()])
|
||||
return [...w, ...we]
|
||||
},
|
||||
artistry: async () => {
|
||||
const [ai, wi, we] = await Promise.all([
|
||||
imageJournalTools(),
|
||||
widgetsTools(),
|
||||
weatherTools(),
|
||||
])
|
||||
return [...ai, ...wi, ...we]
|
||||
},
|
||||
}
|
||||
|
||||
return [
|
||||
...widgetTools,
|
||||
...weatherToolset,
|
||||
]
|
||||
}
|
||||
if (toolset && toolsetRegistry[toolset]) {
|
||||
return toolsetRegistry[toolset]
|
||||
}
|
||||
|
||||
return undefined
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { resolveArtistryConfigFromStore } from '@proj-airi/stage-ui/stores/modules/artistry'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('image_journal config snapshot', () => {
|
||||
it('extracts plain values instead of leaking Ref objects', () => {
|
||||
const config = resolveArtistryConfigFromStore({
|
||||
activeProvider: { value: 'comfyui' },
|
||||
activeModel: { value: 'flux' },
|
||||
defaultPromptPrefix: { value: 'anime style' },
|
||||
providerOptions: { value: { seed: 42 } },
|
||||
comfyuiServerUrl: { value: 'http://localhost:8188' },
|
||||
comfyuiSavedWorkflows: { value: [{ id: 'wf-1' }] },
|
||||
comfyuiActiveWorkflow: { value: 'wf-1' },
|
||||
replicateApiKey: { value: 'r8_xxx' },
|
||||
replicateDefaultModel: { value: 'black-forest-labs/flux-schnell' },
|
||||
replicateAspectRatio: { value: '16:9' },
|
||||
replicateInferenceSteps: { value: 4 },
|
||||
nanobananaApiKey: { value: 'AIza-test' },
|
||||
nanobananaModel: { value: 'gemini-3.1-flash-image-preview' },
|
||||
nanobananaResolution: { value: '1K' },
|
||||
})
|
||||
|
||||
expect(config).toEqual({
|
||||
provider: 'comfyui',
|
||||
model: 'flux',
|
||||
promptPrefix: 'anime style',
|
||||
options: { seed: 42 },
|
||||
globals: {
|
||||
comfyuiServerUrl: 'http://localhost:8188',
|
||||
comfyuiSavedWorkflows: [{ id: 'wf-1' }],
|
||||
comfyuiActiveWorkflow: 'wf-1',
|
||||
replicateApiKey: 'r8_xxx',
|
||||
replicateDefaultModel: 'black-forest-labs/flux-schnell',
|
||||
replicateAspectRatio: '16:9',
|
||||
replicateInferenceSteps: 4,
|
||||
nanobananaApiKey: 'AIza-test',
|
||||
nanobananaModel: 'gemini-3.1-flash-image-preview',
|
||||
nanobananaResolution: '1K',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { ResolvedArtistryConfig } from '@proj-airi/stage-ui/stores/modules/artistry'
|
||||
import type { Tool } from '@xsai/shared-chat'
|
||||
|
||||
import { defineInvoke } from '@moeru/eventa'
|
||||
import { createContext } from '@moeru/eventa/adapters/electron/renderer'
|
||||
import { artistryGenerateHeadless } from '@proj-airi/stage-shared'
|
||||
import { useBackgroundStore } from '@proj-airi/stage-ui/stores/background'
|
||||
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
|
||||
import { resolveArtistryConfigFromStore, useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
|
||||
import { tool } from '@xsai/tool'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { widgetsAdd } from '../../../../shared/eventa'
|
||||
|
||||
export function getArtistryConfig(): ResolvedArtistryConfig {
|
||||
return resolveArtistryConfigFromStore(useArtistryStore())
|
||||
}
|
||||
|
||||
function createInvokers() {
|
||||
const { context } = createContext(window.electron.ipcRenderer)
|
||||
return {
|
||||
generateHeadless: defineInvoke(context, artistryGenerateHeadless),
|
||||
addWidget: defineInvoke(context, widgetsAdd),
|
||||
}
|
||||
}
|
||||
|
||||
type Invokers = ReturnType<typeof createInvokers>
|
||||
let invokeCache: Invokers | undefined
|
||||
|
||||
function getInvokers(): Invokers {
|
||||
if (!invokeCache)
|
||||
invokeCache = createInvokers()
|
||||
return invokeCache
|
||||
}
|
||||
|
||||
const imageJournalParams = z.object({
|
||||
action: z.enum(['create', 'apply']).describe('Choose "create" to generate a new image, or "apply" to use an existing one.'),
|
||||
prompt: z.string().optional().describe('Description for the image (required for "create").'),
|
||||
title: z.string().optional().describe('Label for the entry (optional).'),
|
||||
query: z.string().optional().describe('Search term for existing images (required for "apply").'),
|
||||
mode: z.enum(['inline', 'widget', 'bg', 'bg_widget']).optional().describe('Display mode: "inline" (in chat), "widget" (overlay), "bg" (environment), or "bg_widget" (both). Defaults to character preference.'),
|
||||
})
|
||||
|
||||
async function executeCreateImageJournalEntry(params: { prompt?: string, title?: string, mode?: 'inline' | 'widget' | 'bg' | 'bg_widget' }) {
|
||||
if (!params.prompt?.trim())
|
||||
throw new Error('prompt is required for image_journal.create')
|
||||
|
||||
const backgroundStore = useBackgroundStore()
|
||||
const cardStore = useAiriCardStore()
|
||||
const activeCard = cardStore.activeCard
|
||||
const globalArtistryConfig = getArtistryConfig()
|
||||
|
||||
const airiExt = activeCard?.extensions?.airi
|
||||
const cardArtistry = airiExt?.modules?.artistry
|
||||
const artistryConfig = {
|
||||
provider: cardArtistry?.provider || globalArtistryConfig.provider,
|
||||
model: cardArtistry?.model || globalArtistryConfig.model,
|
||||
promptPrefix: cardArtistry?.promptPrefix || globalArtistryConfig.promptPrefix,
|
||||
options: cardArtistry?.options || globalArtistryConfig.options,
|
||||
globals: globalArtistryConfig.globals,
|
||||
}
|
||||
|
||||
const title = params.title || `Generation ${new Date().toLocaleString()}`
|
||||
|
||||
// Resolve mode: explicit param > character fallback > global default (inline)
|
||||
const spawnMode = cardArtistry?.spawnMode
|
||||
const mode = params.mode || spawnMode || 'inline'
|
||||
|
||||
const { addWidget, generateHeadless } = getInvokers()
|
||||
|
||||
try {
|
||||
const artistryResult = await generateHeadless({
|
||||
prompt: artistryConfig.promptPrefix ? `${artistryConfig.promptPrefix} ${params.prompt}` : params.prompt as string,
|
||||
model: artistryConfig.model as string,
|
||||
provider: artistryConfig.provider as string,
|
||||
options: JSON.parse(JSON.stringify(artistryConfig.options || {})),
|
||||
globals: JSON.parse(JSON.stringify(artistryConfig.globals || {})),
|
||||
})
|
||||
|
||||
if (artistryResult.error || (!artistryResult.base64 && !artistryResult.imageUrl)) {
|
||||
throw new Error(`Failed to generate image: ${artistryResult.error || 'No output received'}`)
|
||||
}
|
||||
|
||||
let blob: Blob
|
||||
if (artistryResult.base64) {
|
||||
const response = await fetch(artistryResult.base64)
|
||||
blob = await response.blob()
|
||||
}
|
||||
else {
|
||||
const response = await fetch(artistryResult.imageUrl!)
|
||||
blob = await response.blob()
|
||||
}
|
||||
|
||||
const entryId = await backgroundStore.addBackground('journal', blob, title, params.prompt, cardStore.activeCardId)
|
||||
|
||||
// Handle Application Logic based on Mode
|
||||
if (mode === 'bg' || mode === 'bg_widget') {
|
||||
const cardId = cardStore.activeCardId
|
||||
if (cardId) {
|
||||
const card = cardStore.cards.get(cardId)
|
||||
if (card) {
|
||||
const extension = JSON.parse(JSON.stringify(card.extensions || {}))
|
||||
if (!extension.airi)
|
||||
extension.airi = {}
|
||||
if (!extension.airi.modules)
|
||||
extension.airi.modules = {}
|
||||
extension.airi.modules.activeBackgroundId = entryId
|
||||
cardStore.updateCard(cardId, { ...card, extensions: extension })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'widget' || mode === 'bg_widget') {
|
||||
try {
|
||||
await addWidget({
|
||||
componentName: 'artistry',
|
||||
componentProps: {
|
||||
status: 'done',
|
||||
entryId,
|
||||
imageUrl: artistryResult.imageUrl || artistryResult.base64,
|
||||
prompt: params.prompt as string,
|
||||
title,
|
||||
_skipIngestion: true,
|
||||
},
|
||||
size: 'm',
|
||||
ttlMs: 0,
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('[ImageJournalTool] Failed to spawn Result widget', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Return structured result for UI rendering
|
||||
return JSON.stringify({
|
||||
message: `Image created in ${mode} mode${mode === 'bg' || mode === 'bg_widget' ? ' and set as background' : ''}.`,
|
||||
entryId,
|
||||
imageUrl: artistryResult.imageUrl || artistryResult.base64,
|
||||
title,
|
||||
prompt: params.prompt,
|
||||
mode,
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
console.error('[ImageJournalTool] Failed to create entry', e)
|
||||
return `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
}
|
||||
|
||||
async function executeSetAsBackground(params: { query?: string }) {
|
||||
if (!params.query?.trim())
|
||||
return 'Error: query is required for image_journal.apply. Provide a title or ID to search for.'
|
||||
|
||||
const backgroundStore = useBackgroundStore()
|
||||
const cardStore = useAiriCardStore()
|
||||
const cardId = cardStore.activeCardId
|
||||
const query = params.query.toLowerCase().trim()
|
||||
|
||||
const entries = Array.from(backgroundStore.entries.values())
|
||||
.filter(e => e.characterId === null || e.characterId === cardId)
|
||||
|
||||
let entry = entries.find(e => e.type === 'journal' && (e.id === query || e.id.toLowerCase().includes(query)))
|
||||
if (!entry)
|
||||
entry = entries.find(e => e.type === 'journal' && e.title.toLowerCase().includes(query))
|
||||
if (!entry)
|
||||
entry = entries.find(e => e.type !== 'journal' && e.title.toLowerCase().includes(query))
|
||||
|
||||
if (entry) {
|
||||
try {
|
||||
if (cardId) {
|
||||
const card = cardStore.cards.get(cardId)
|
||||
if (card) {
|
||||
const extension = JSON.parse(JSON.stringify(card.extensions || {}))
|
||||
if (!extension.airi)
|
||||
extension.airi = {}
|
||||
if (!extension.airi.modules)
|
||||
extension.airi.modules = {}
|
||||
extension.airi.modules.activeBackgroundId = entry.id
|
||||
cardStore.updateCard(cardId, { ...card, extensions: extension })
|
||||
}
|
||||
}
|
||||
return `Background set to "${entry.title}".`
|
||||
}
|
||||
catch (e) {
|
||||
return `Error applying "${entry.title}": ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
}
|
||||
|
||||
const available = entries.filter(e => e.type === 'journal').map(e => e.title).slice(0, 10)
|
||||
return `No match for "${params.query}".${available.length > 0 ? ` Try: ${available.join(', ')}` : ''}`
|
||||
}
|
||||
|
||||
async function executeImageJournalAction(params: any) {
|
||||
if (params.action === 'create')
|
||||
return await executeCreateImageJournalEntry(params)
|
||||
if (params.action === 'apply' || params.action === 'set_as_background')
|
||||
return await executeSetAsBackground(params)
|
||||
return 'No action performed.'
|
||||
}
|
||||
|
||||
const tools: Promise<Tool>[] = [
|
||||
tool({
|
||||
name: 'image_journal',
|
||||
description: 'Manage AI-generated images. Use "create" to generate and display images. An optional "mode" (inline, widget, bg, bg_widget) can override the default character routing preference. Use "apply" to switch to an existing image from the journal.',
|
||||
execute: params => executeImageJournalAction(params),
|
||||
parameters: imageJournalParams,
|
||||
}),
|
||||
]
|
||||
|
||||
export const imageJournalTools = async () => Promise.all(tools)
|
||||
@@ -0,0 +1,349 @@
|
||||
<script setup lang="ts">
|
||||
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { useBackgroundStore } from '@proj-airi/stage-ui/stores/background'
|
||||
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { widgetsHideWindow, widgetsRemove } from '../../../../shared/eventa'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
id?: string
|
||||
status?: 'idle' | 'generating' | 'done' | 'error'
|
||||
entryId?: string // Unified background ID
|
||||
imageUrl?: string // Legacy/Fallback
|
||||
prompt?: string
|
||||
progress?: number
|
||||
actionLabel?: string
|
||||
remixId?: string | number
|
||||
renderTime?: string
|
||||
engineStats?: string
|
||||
}>(), {
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
})
|
||||
|
||||
const cardStore = useAiriCardStore()
|
||||
const backgroundStore = useBackgroundStore()
|
||||
|
||||
// NOTICE: Comfy.vue is a display-only widget. All journal ingestion is
|
||||
// handled by the `image_journal` tool. This widget only reads existing
|
||||
// entries for gallery browsing and background setting.
|
||||
|
||||
// Filter history for current character using unified store
|
||||
const history = computed(() => backgroundStore.getCharacterJournalEntries(cardStore.activeCardId))
|
||||
const currentIndex = ref(0)
|
||||
|
||||
// When entryId prop matches a new generation, jump to it in the gallery
|
||||
watch([() => props.entryId, history], ([newId, newHistory]) => {
|
||||
if (newId) {
|
||||
const index = newHistory.findIndex(e => e.id === newId)
|
||||
if (index >= 0) {
|
||||
currentIndex.value = index
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const isFlipped = ref(false)
|
||||
const errorOccurred = ref(false)
|
||||
const isSettingBackground = ref(false)
|
||||
const isBrowsingGallery = ref(false)
|
||||
|
||||
watch(() => props.status, (newStatus) => {
|
||||
if (newStatus === 'generating') {
|
||||
isBrowsingGallery.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const hideWindow = useElectronEventaInvoke(widgetsHideWindow)
|
||||
const removeWidget = useElectronEventaInvoke(widgetsRemove)
|
||||
|
||||
// The current image is either resolved from the collection or fallback to props
|
||||
const currentImage = computed(() => {
|
||||
if (!isBrowsingGallery.value && !props.entryId && props.imageUrl) {
|
||||
return undefined
|
||||
}
|
||||
return history.value[currentIndex.value]
|
||||
})
|
||||
const resolvedImageUrl = computed(() => {
|
||||
if (currentImage.value)
|
||||
return backgroundStore.getBackgroundUrl(currentImage.value.id)
|
||||
if (props.entryId)
|
||||
return backgroundStore.getBackgroundUrl(props.entryId)
|
||||
return props.imageUrl
|
||||
})
|
||||
|
||||
function handleImageError() {
|
||||
errorOccurred.value = true
|
||||
}
|
||||
|
||||
function nextImage() {
|
||||
if (history.value.length === 0)
|
||||
return
|
||||
errorOccurred.value = false
|
||||
currentIndex.value = (currentIndex.value + 1) % history.value.length
|
||||
}
|
||||
|
||||
function prevImage() {
|
||||
if (history.value.length === 0)
|
||||
return
|
||||
errorOccurred.value = false
|
||||
currentIndex.value = (currentIndex.value - 1 + history.value.length) % history.value.length
|
||||
}
|
||||
|
||||
function toggleFlip() {
|
||||
isFlipped.value = !isFlipped.value
|
||||
}
|
||||
|
||||
async function handleSetAsBackground() {
|
||||
if (!currentImage.value || !cardStore.activeCardId)
|
||||
return
|
||||
isSettingBackground.value = true
|
||||
try {
|
||||
const entry = currentImage.value
|
||||
// Update the active card's background ID
|
||||
const cardId = cardStore.activeCardId
|
||||
const card = cardStore.activeCard
|
||||
if (card) {
|
||||
const extension = JSON.parse(JSON.stringify(card.extensions || {}))
|
||||
if (!extension.airi)
|
||||
extension.airi = {}
|
||||
if (!extension.airi.modules)
|
||||
extension.airi.modules = {}
|
||||
extension.airi.modules.activeBackgroundId = entry.id
|
||||
|
||||
await cardStore.updateCard(cardId, { ...card, extensions: extension })
|
||||
console.log(`[ComfyWidget] Set activeBackgroundId to ${entry.id} for ${cardId}`)
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error('[ComfyWidget] Failed to set background', e)
|
||||
}
|
||||
finally {
|
||||
isSettingBackground.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClose() {
|
||||
if (props.id) {
|
||||
await hideWindow({ id: props.id })
|
||||
await removeWidget({ id: props.id })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="comfy-widget relative h-full w-full perspective-1000 select-none font-sans">
|
||||
<div
|
||||
class="relative h-full w-full preserve-3d transition-transform duration-700"
|
||||
:class="{ 'rotate-y-180': isFlipped }"
|
||||
>
|
||||
<!-- Front Side: Gallery/Generator -->
|
||||
<div
|
||||
class="backface-hidden absolute inset-0 overflow-hidden border border-white/10 rounded-2xl from-neutral-900 via-neutral-900 to-neutral-800 bg-gradient-to-br shadow-2xl"
|
||||
>
|
||||
<!-- Generation Overlay -->
|
||||
<div
|
||||
v-if="status === 'generating'"
|
||||
class="pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center transition-all duration-500"
|
||||
>
|
||||
<!-- Center Loader: Only if no images yet -->
|
||||
<template v-if="history.length === 0">
|
||||
<div class="z-minus-1 absolute inset-0 bg-black/60" />
|
||||
<div class="relative mb-6">
|
||||
<div class="animate-spin-slow i-iconify-meteocons:clear-day-fill text-[5rem] text-yellow-400 drop-shadow-[0_0_15px_rgba(250,204,21,0.5)]" />
|
||||
<div class="absolute inset-0 flex items-center justify-center text-xl text-white font-bold drop-shadow-md">
|
||||
{{ Math.round(progress) }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-xs w-full px-6 space-y-2">
|
||||
<div class="truncate text-center text-sm text-white/90 font-medium tracking-widest uppercase">
|
||||
{{ actionLabel || 'Thinking...' }}
|
||||
</div>
|
||||
<div class="h-1.5 w-full overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
class="h-full from-yellow-400 to-orange-500 bg-gradient-to-r transition-all duration-300 ease-out"
|
||||
:style="{ width: `${progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Slim Bottom Progress: If images exist -->
|
||||
<template v-else>
|
||||
<div class="absolute inset-x-0 bottom-0 z-40 h-10 flex flex-col justify-end from-black/80 to-transparent bg-gradient-to-t px-4 pb-1">
|
||||
<div class="mb-1 flex items-center justify-between px-1">
|
||||
<div class="flex items-center gap-1.5 text-[9px] text-white/50 font-mono">
|
||||
<span class="size-1.5 animate-pulse rounded-full bg-yellow-400" />
|
||||
<span class="tracking-widest uppercase opacity-80">{{ actionLabel || 'Manifesting...' }}</span>
|
||||
</div>
|
||||
<div class="text-[9px] text-yellow-400/80 font-bold font-mono">
|
||||
{{ Math.round(progress) }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-1 w-full overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
class="h-full from-yellow-400 to-orange-500 bg-gradient-to-r shadow-[0_0_10px_rgba(250,204,21,0.4)] transition-all duration-300 ease-out"
|
||||
:style="{ width: `${progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="relative h-full w-full flex items-center justify-center bg-black">
|
||||
<img
|
||||
v-if="resolvedImageUrl && !errorOccurred"
|
||||
:key="resolvedImageUrl"
|
||||
:src="resolvedImageUrl"
|
||||
class="h-full w-full object-cover transition-all duration-500"
|
||||
@error="handleImageError"
|
||||
>
|
||||
<div v-else-if="errorOccurred" class="h-full w-full">
|
||||
<img
|
||||
src="https://placehold.co/600x400/991b1b/white?text=Error+Loading+Image&font=roboto"
|
||||
class="h-full w-full object-cover"
|
||||
>
|
||||
</div>
|
||||
<div v-else-if="status !== 'generating'" class="p-8 text-center text-white/20">
|
||||
<div class="i-iconify-material-symbols:image-not-supported-outline mb-2 text-4xl" />
|
||||
<div class="text-sm">
|
||||
Awaiting first generation...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Overlay -->
|
||||
<div v-if="history.length > 1" class="pointer-events-none absolute inset-x-0 top-1/2 z-30 flex justify-between px-3 -translate-y-1/2">
|
||||
<button
|
||||
class="pointer-events-auto size-14 flex items-center justify-center border border-white/20 rounded-full bg-black/50 text-white shadow-2xl backdrop-blur-md transition-all active:scale-95 hover:scale-110 hover:bg-black/80"
|
||||
@click.stop="prevImage"
|
||||
>
|
||||
<span class="flex items-center justify-center pb-1 text-2xl leading-none font-mono"><</span>
|
||||
</button>
|
||||
<button
|
||||
class="pointer-events-auto size-14 flex items-center justify-center border border-white/20 rounded-full bg-black/50 text-white shadow-2xl backdrop-blur-md transition-all active:scale-95 hover:scale-110 hover:bg-black/80"
|
||||
@click.stop="nextImage"
|
||||
>
|
||||
<span class="flex items-center justify-center pb-1 text-2xl leading-none font-mono">></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
class="absolute right-3 top-3 z-30 size-8 flex items-center justify-center border border-white/20 rounded-full bg-black/40 text-white/70 backdrop-blur-md transition-all active:scale-95 hover:bg-black/70 hover:text-white"
|
||||
@click.stop="handleClose"
|
||||
>
|
||||
<div class="i-iconify-material-symbols:close text-lg" />
|
||||
</button>
|
||||
|
||||
<!-- Counter & Flip Toggle -->
|
||||
<div class="absolute inset-x-0 bottom-2 z-10 flex items-center justify-between px-3">
|
||||
<div v-if="history.length > 0" class="rounded-full bg-black/40 px-2 py-0.5 text-[10px] text-white/70 font-mono backdrop-blur-sm">
|
||||
{{ currentIndex + 1 }} / {{ history.length }}
|
||||
</div>
|
||||
<div v-else />
|
||||
|
||||
<button
|
||||
class="rounded-lg bg-white/10 p-1.5 text-white/80 backdrop-blur-sm transition-all active:scale-95 hover:scale-110 hover:bg-white/20"
|
||||
@click="toggleFlip"
|
||||
>
|
||||
<div class="i-iconify-material-symbols:info-outline text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Back Side: Metadata -->
|
||||
<div
|
||||
class="backface-hidden absolute inset-0 flex flex-col rotate-y-180 gap-3 overflow-hidden border border-white/20 rounded-2xl bg-[#0a0a0c] p-4 font-mono shadow-2xl"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-white/10 pb-2">
|
||||
<div class="text-xs text-yellow-500 font-bold tracking-tighter uppercase">
|
||||
Engine.Cortex_V1
|
||||
</div>
|
||||
<button class="text-white/40 transition-colors hover:text-white" @click="toggleFlip">
|
||||
<div class="i-iconify-material-symbols:close text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="custom-scrollbar flex-1 overflow-y-auto pr-1 text-[11px] space-y-4">
|
||||
<div class="space-y-1">
|
||||
<div class="text-[9px] text-white/30 font-bold uppercase">
|
||||
Generated Prompt
|
||||
</div>
|
||||
<div class="border border-white/5 rounded bg-white/5 p-2 text-white/80 leading-relaxed italic">
|
||||
{{ currentImage?.prompt || prompt || 'No prompt available for this frame.' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="border border-white/5 rounded bg-white/5 p-2">
|
||||
<div class="mb-1 text-[8px] text-white/30 font-bold uppercase">
|
||||
Remix ID
|
||||
</div>
|
||||
<div class="text-white/90">
|
||||
#{{ currentImage?.remixId || remixId || '000000' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="border border-white/5 rounded bg-white/5 p-2">
|
||||
<div class="mb-1 text-[8px] text-white/30 font-bold uppercase">
|
||||
Time
|
||||
</div>
|
||||
<div class="text-white/90">
|
||||
{{ renderTime || '--.--s' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-2 space-y-2">
|
||||
<button
|
||||
class="w-full flex items-center justify-center gap-2 border border-yellow-500/30 rounded-lg bg-yellow-500/10 py-2.5 text-xs text-yellow-500 font-bold transition-all active:scale-95 hover:bg-yellow-500/20 disabled:opacity-50"
|
||||
:disabled="!currentImage || isSettingBackground"
|
||||
@click="handleSetAsBackground"
|
||||
>
|
||||
<div v-if="isSettingBackground" class="i-iconify-line-md:loading-twotone-loop text-base" />
|
||||
<div v-else class="i-iconify-material-symbols:wallpaper text-base" />
|
||||
{{ isSettingBackground ? 'SETTING...' : 'SET AS BACKGROUND' }}
|
||||
</button>
|
||||
|
||||
<div class="pointer-events-none flex select-none items-center gap-2 text-[9px] text-white opacity-30">
|
||||
<div class="size-1.5 animate-pulse rounded-full bg-green-500" />
|
||||
<span>CUIPP BACKEND LINKED</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.perspective-1000 {
|
||||
perspective: 1000px;
|
||||
}
|
||||
.preserve-3d {
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.backface-hidden {
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
.rotate-y-180 {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin 3s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Artistry } from './components/Comfy.vue'
|
||||
@@ -53,6 +53,8 @@ export const electronSetUpdaterPreferences = defineInvokeEventa<ElectronUpdaterP
|
||||
|
||||
export const captionIsFollowingWindowChanged = defineEventa<boolean>('eventa:event:electron:windows:caption-overlay:is-following-window-changed')
|
||||
export const captionGetIsFollowingWindow = defineInvokeEventa<boolean>('eventa:invoke:electron:windows:caption-overlay:get-is-following-window')
|
||||
export const electronCaptionToggleVisibility = defineInvokeEventa<void>('eventa:invoke:electron:windows:caption:toggle-visibility')
|
||||
export const electronCaptionSyncDocking = defineInvokeEventa<void, 'top' | 'bottom' | undefined>('eventa:invoke:electron:windows:caption:sync-docking')
|
||||
|
||||
export type RequestWindowActionDefault = 'confirm' | 'cancel' | 'close'
|
||||
export interface RequestWindowPayload {
|
||||
@@ -180,8 +182,12 @@ export const electronMcpApplyAndRestart = defineInvokeEventa<ElectronMcpStdioApp
|
||||
export const electronMcpGetRuntimeStatus = defineInvokeEventa<ElectronMcpStdioRuntimeStatus>('eventa:invoke:electron:mcp:get-runtime-status')
|
||||
export const electronMcpListTools = defineInvokeEventa<ElectronMcpToolDescriptor[]>('eventa:invoke:electron:mcp:list-tools')
|
||||
export const electronMcpCallTool = defineInvokeEventa<ElectronMcpCallToolResult, ElectronMcpCallToolPayload>('eventa:invoke:electron:mcp:call-tool')
|
||||
export const electronMcpGetConfig = defineInvokeEventa<ElectronMcpStdioConfigFile>('eventa:invoke:electron:mcp:get-config')
|
||||
export const electronMcpUpdateConfig = defineInvokeEventa<void, Partial<ElectronMcpStdioConfigFile>>('eventa:invoke:electron:mcp:update-config')
|
||||
export const electronMcpConfigChanged = defineEventa<ElectronMcpStdioConfigFile>('eventa:event:electron:mcp:config-changed')
|
||||
|
||||
export const widgetsOpenWindow = defineInvokeEventa<void, { id?: string }>('eventa:invoke:electron:windows:widgets:open')
|
||||
export const widgetsHideWindow = defineInvokeEventa<void, { id?: string }>('eventa:invoke:electron:windows:widgets:hide')
|
||||
export const widgetsAdd = defineInvokeEventa<string | undefined, WidgetsAddPayload>('eventa:invoke:electron:windows:widgets:add')
|
||||
export const widgetsRemove = defineInvokeEventa<void, { id: string }>('eventa:invoke:electron:windows:widgets:remove')
|
||||
export const widgetsClear = defineInvokeEventa('eventa:invoke:electron:windows:widgets:clear')
|
||||
@@ -245,6 +251,8 @@ export const widgetsUpdateEvent = defineEventa<WidgetsUpdatePayload>('eventa:eve
|
||||
|
||||
// Onboarding window events
|
||||
export const electronOnboardingClose = defineInvokeEventa('eventa:invoke:electron:windows:onboarding:close')
|
||||
export const electronOnboardingCompleted = defineInvokeEventa('eventa:invoke:electron:windows:onboarding:completed')
|
||||
export const electronOnboardingSkipped = defineInvokeEventa('eventa:invoke:electron:windows:onboarding:skipped')
|
||||
export const electronOpenOnboarding = defineInvokeEventa('eventa:invoke:electron:windows:onboarding:open')
|
||||
|
||||
// Auth — OIDC Authorization Code + PKCE flow via system browser
|
||||
|
||||
Reference in New Issue
Block a user