feat(stage-*): port artistry & chatbox enhancements (#1636)

Co-authored-by-agent: Unknown <unknown@example.com>
This commit is contained in:
Richard Pinedo
2026-04-24 23:25:04 +08:00
committed by GitHub
parent 3cc9c87b0f
commit 4e6da5ef04
64 changed files with 6235 additions and 290 deletions
+1
View File
@@ -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
}
+15 -5
View File
@@ -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,
}
+19 -2
View File
@@ -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">&lt;</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">&gt;</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