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
+40
View File
@@ -0,0 +1,40 @@
const { spawnSync } = require('node:child_process')
const query = `
query {
repository(owner: "moeru-ai", name: "airi") {
pullRequest(number: 1636) {
reviewThreads(last: 80) {
nodes {
id
isResolved
comments(last: 1) {
nodes {
body
author {
login
}
}
}
}
}
}
}
}
`
const result = spawnSync('gh', ['api', 'graphql', '-f', `query=${query}`], {
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
})
if (result.error) {
console.error(result.error)
process.exit(1)
}
const data = JSON.parse(result.stdout)
const threads = data.data.repository.pullRequest.reviewThreads.nodes
const unresolved = threads.filter(t => !t.isResolved)
console.log(JSON.stringify(unresolved, null, 2))
+228 -16
View File
@@ -70,7 +70,8 @@ dialogs:
stateNotGranted: Not granted
bug-report:
title: Bug report (´;ω;`)ヾ(・∀・`)
subtitle: Oops, sorry we made something wrong. Would you mind telling us what happened?
subtitle: Oops, sorry we made something wrong. Would you mind telling us what
happened?
trigger-label: Report a Bug
submit-label: Send Bug Report
triage-description: Include screenshot and page context to help us triage this issue.
@@ -234,6 +235,7 @@ pages:
scenario: 'Error: A scenario is required.'
systemprompt: 'Error: Please, provide a system prompt.'
posthistoryinstructions: 'Error: Post history prompt is required.'
invalid_artistry_json: 'Error: Artistry provider options contains invalid JSON.'
modules: Modules
name_asc: Name (A-Z)
name_desc: Name (Z-A)
@@ -372,6 +374,68 @@ pages:
warmup:
description: Whether to warm up before detecting beats for better accuracy.
label: Warmup
artistry:
title: Artistry
description: Image generation, scene background and drawing.
page:
title: Artistry Provider Configuration
description: Select the active backend provider for image generation. Characters can override this in their Card settings.
providers:
none:
name: None
description: Bypass and disable the image generation module globally.
comfyui:
name: ComfyUI (Local)
description: Use a local ComfyUI instance via WSL for image generation.
replicate:
name: Replicate.ai (Cloud)
description: Use cloud-based models via the Replicate API.
nanobanana:
name: Nano Banana (Preview)
description: Use Google AI Studio for image preview and reactions.
card:
description: Configure how AIRI generates images and visual content.
comfyui_empty: No ComfyUI workflows configured. Go to Settings → Providers → ComfyUI to upload a workflow template.
exposed_fields: '{count} exposed fields'
open_on_replicate: Open on Replicate
instruction_sync:
title: ComfyUI Instruction Sync
description: A specialized prompt is ready for your {workflowName} workflow. Applying this will overwrite current widget instructions so the AI knows how to use this specific template.
apply: Apply Recommended Prompt
keep: Keep Existing
provider: Artistry Provider
spawn_mode:
label: Manifestation Mode (Spawn Mode)
description: Choose how images are delivered to the interface.
options:
bg: Background Environment
inline: Inline Chat
widget: Overlay Widget
bg_widget: Dual (Background + Widget)
autonomous:
title: Cinematic Autonomy (Autonomous Artist)
description: When enabled, the "Producer" runs in parallel to the character to decide if a visual is needed. This prevents the character from forgetting to manifest scenes.
threshold: Manifestation Threshold
threshold_description: Use {min} for aggressive scene creation or {max} to require stronger evidence before generating.
threshold_min: Always Generate (0%)
threshold_max: Strict (100%)
model:
label: Artistry Model (Optional Override)
description: Model identifier if needed by provider
prompt-prefix:
label: Artistry Prompt Default Prefix
description: Pre-pended to every prompt sent to the image generator.
options:
label: Artistry Provider Options (JSON)
widget-instructions:
label: Widget System Prompt
description: Custom instructions for the AI on how to use the generation
capabilities.
categories:
essential: Essential
messaging: Messaging
gaming: Gaming
artistry: Artistry
consciousness:
description: Personality, desired model, etc.
sections:
@@ -594,18 +658,6 @@ pages:
buy: Charge
description: Flux packages to choose from.
providers:
explained:
chat: Text generation model providers. e.g. OpenRouter, OpenAI, Ollama.
Speech: Speech (text-to-speech) model providers. e.g. ElevenLabs, Azure Speech.
Transcription: >-
Transcription (speech-to-text) model providers. e.g. Whisper.cpp,
OpenAI, Azure Speech
helpinfo:
title: First time here?
description: >
AIRI requires at least one {chat} provider to be configured to think,
and behave properly. You could think of it as the brain of the
characters living in AIRI system.
catalog:
edit:
config-id-not-found: Provider configuration not found.
@@ -711,6 +763,51 @@ pages:
title: Basic
voice:
title: Voice Settings
labels:
recommended: Recommended
filters:
pricing: Pricing
deployment: Deployment
all: All
free: Free
paid: Paid
internal: Internal
local: Local
cloud: Cloud
categories:
chat:
title: Chat
description: Text generation model providers. e.g. OpenRouter, OpenAI, Ollama.
speech:
title: Speech
description: Speech (text-to-speech) model providers. e.g. ElevenLabs, Azure Speech.
transcription:
title: Transcription
description: >-
Transcription (speech-to-text) model providers. e.g. Whisper.cpp,
OpenAI, Azure Speech
artistry:
title: Artistry
description: Image generation and design model providers. e.g. ComfyUI, Replicate.
items:
comfyui:
description: Local image generation runner.
replicate:
description: Cloud-based model inference service.
nanobanana:
description: Google AI Studio Image Preview.
helpinfo:
title: First time here?
description: >
AIRI requires at least one {chat} provider to be configured to think,
and behave properly. You could think of it as the brain of the
characters living in AIRI system.
explained:
chat: Text generation model providers. e.g. OpenRouter, OpenAI, Ollama.
Speech: Speech (text-to-speech) model providers. e.g. ElevenLabs, Azure Speech.
Transcription: >-
Transcription (speech-to-text) model providers. e.g. Whisper.cpp,
OpenAI, Azure Speech
description: LLMs, speech providers, etc.
provider:
app-local-audio-transcription:
@@ -789,6 +886,98 @@ pages:
speech-noop:
title: None
description: No speech output.
comfyui:
settings:
title: ComfyUI (Local)
heading: ComfyUI Native API
description: Connect to your local ComfyUI and bring your own workflows.
info:
what_you_need:
label: What You Need
value: ComfyUI running locally or on your network.
how_to_export:
label: How To Export
value: Enable Dev Mode → "Save (API Format)".
scope_boundary:
label: Scope Boundary
value: Model downloads & node installs are your job.
connection:
title: Connection
connected: Connected
failed: Connection failed
error_prefix: Error
test: Test
testing: Testing...
unknown_error: Unknown connection error
unknown_gpu: Unknown GPU
server_url:
label: Server URL
description: The address where ComfyUI is running
placeholder: http://localhost:8188
cors:
title: CORS Block Detected
description: ComfyUI blocks requests from other applications by default. To allow AIRI to connect, you must start ComfyUI with the `--enable-cors-header "*"` flag.
command: python main.py --enable-cors-header "*"
workflows:
title: Workflow Templates
upload: Upload Workflow
cancel_upload: Cancel
empty: No workflows uploaded yet. Click "Upload Workflow" to import a workflow_api.json from ComfyUI.
exposed_parameters: Exposed Parameters
summary: '{nodes} nodes · {fields} exposed fields'
remove: Remove
config_snippet: Artistry Config Snippet
copy_json: Copy JSON
paste_hint: Paste this into your AIRI Card artistry config to override these nodes.
upload:
prompt: Drop or select a workflow_api.json file
invalid_json: Invalid JSON
workflow_name:
label: Workflow Name
description: Give this workflow a recognizable name
placeholder: e.g. Anime Text2Img
select_fields: 'Select fields to expose to the AI agent:'
fields_exposed: '{count} field(s) exposed'
save: Save Workflow
replicate:
settings:
title: Replicate.ai
heading: Replicate.ai Configuration
description: Configure your cloud image generation settings.
api_key:
label: API Key
description: Your Replicate API token (starts with r8_)
placeholder: r8_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
default_model:
label: Default Model
description: Fallback owner/model string to use if the character card doesn't specify one
placeholder: black-forest-labs/flux-schnell
aspect_ratio:
label: Aspect Ratio
description: Default image aspect ratio (e.g. 16:9, 1:1, 9:16)
placeholder: '16:9'
inference_steps:
label: Inference Steps
description: Number of steps for the diffusion process (lower is faster, higher is better quality)
nanobanana:
settings:
title: Nano Banana
heading: Nano Banana (Google AI Studio)
description: Configure Google Gemini's native image generation capabilities.
api_key:
label: API Key
description: Your Google AI Studio API Key
placeholder: AIpk...
preferred_model:
label: Preferred Model
description: The specific Gemini image preview model to use
default_resolution:
label: Default Resolution
description: The target resolution for generated images
model_options:
nano_banana_2: Nano Banana 2 (Gemini 3.1 Flash Image)
nano_banana_pro: Nano Banana Pro (Gemini 3 Pro Image)
nano_banana: Nano Banana (Gemini 2.5 Flash Image)
deepseek:
description: deepseek.com
title: DeepSeek
@@ -1039,9 +1228,6 @@ pages:
errors:
title: QR scan failed
failed: Failed to scan or connect with the QR code.
scene:
description: Configure the environment where the character lives
title: Scene
system:
color-scheme:
description: Change the color scheme of the stage.
@@ -1193,6 +1379,32 @@ pages:
button: Open
credits:
buy: Buy
scene:
title: Scenes
description: Customize the virtual environment for your characters.
beta_label: Scenes System
beta_description: Each character card specifies its own preferred background
from this gallery. Setting it here will set it as the default for the
currently active character.
background_image:
title: Active Character Background
no_background: No background active for this character. Upload a square or
landscape image for best results.
upload: Upload to Gallery
change: Change Background
clear: Clear Default
gallery:
title: Scene Gallery
empty: No images in gallery yet. Upload one above!
set_as_global: Set as Character Default
delete: Delete from Gallery
global_badge: Character Default
active_badge: Current Scene
delete_confirm: Are you sure you want to delete this background?
tip:
label: Tip!
description: Using a square image will leverage <b>cover</b> cropping in
portrait mode, focusing on the center of the scene.
sections:
section:
general:
@@ -4,8 +4,10 @@ import type { AiriExtension } from '@proj-airi/stage-ui/stores/modules/airi-card
import kebabcase from '@stdlib/string-base-kebabcase'
import { DEFAULT_ARTISTRY_WIDGET_INSTRUCTION } from '@proj-airi/stage-ui/constants/prompts/artistry-instruction'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
@@ -23,9 +25,27 @@ import {
import { computed, ref, toRaw, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import CardCreationTabArtistry from './tabs/CardCreationTabArtistry.vue'
interface Props {
modelValue: boolean
cardId?: string // If provided, edit mode; otherwise create mode
initialTab?: string
}
interface LegacyArtistrySettings {
provider?: string
model?: string
promptPrefix?: string
widgetInstruction?: string
options?: Record<string, unknown>
}
type AiriExtensionWithLegacyArtistry = AiriExtension & {
artistry?: LegacyArtistrySettings
modules?: AiriExtension['modules'] & {
artistry?: LegacyArtistrySettings
}
}
const props = defineProps<Props>()
@@ -42,11 +62,13 @@ const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const displayModelsStore = useDisplayModelsStore()
const stageModelStore = useSettingsStageModel()
const artistryStore = useArtistryStore()
const { activeProvider: consciousnessProvider, activeModel: defaultConsciousnessModel } = storeToRefs(consciousnessStore)
const { activeSpeechProvider: speechProvider, activeSpeechModel: defaultSpeechModel, activeSpeechVoiceId: defaultSpeechVoiceId } = storeToRefs(speechStore)
const { displayModels } = storeToRefs(displayModelsStore)
const { stageModelSelected: defaultDisplayModelId } = storeToRefs(stageModelStore)
const { activeProvider: defaultArtistryProvider } = storeToRefs(artistryStore)
// Determine if we're in edit mode
const isEditMode = computed(() => !!props.cardId)
@@ -59,6 +81,16 @@ const selectedSpeechModel = ref<string>('')
const selectedSpeechVoiceId = ref<string>('')
const selectedDisplayModelId = ref<string>('')
// Artistry configuration
const selectedArtistryProvider = ref<string>('')
const selectedArtistryModel = ref<string>('')
const selectedArtistryPromptPrefix = ref<string>('')
const selectedArtistryWidgetInstruction = ref<string>('')
const selectedArtistrySpawnMode = ref<'bg' | 'widget' | 'inline' | 'bg_widget'>('bg_widget')
const selectedArtistryAutonomousEnabled = ref<boolean>(false)
const selectedArtistryAutonomousThreshold = ref<number>(70)
const selectedArtistryConfigStr = ref<string>('{\n \n}')
// Computed: available display model options
const displayModelOptions = computed(() =>
displayModels.value.map(model => ({
@@ -119,6 +151,16 @@ const speechVoiceOptions = computed(() => {
}))
})
// Computed: available artistry provider options
const artistryProviderOptions = computed(() => {
return [
{ value: 'none', label: 'None (Disabled)' },
{ value: 'replicate', label: 'Replicate' },
{ value: 'comfyui', label: 'ComfyUI' },
{ value: 'nanobanana', label: 'Nano Banana' },
]
})
// Load models for current providers on init
watch(() => [consciousnessProvider.value, speechProvider.value], async ([consProvider, spProvider]) => {
if (consProvider) {
@@ -184,6 +226,7 @@ const tabs: Tab[] = [
{ id: 'identity', label: t('settings.pages.card.creation.identity'), icon: 'i-solar:emoji-funny-square-bold-duotone' },
{ id: 'behavior', label: t('settings.pages.card.creation.behavior'), icon: 'i-solar:chat-round-line-bold-duotone' },
{ id: 'modules', label: t('settings.pages.card.modules'), icon: 'i-solar:widget-4-bold-duotone' },
{ id: 'artistry', label: t('settings.pages.modules.artistry.title'), icon: 'i-solar:gallery-bold-duotone' },
{ id: 'settings', label: t('settings.pages.card.creation.settings'), icon: 'i-solar:settings-bold-duotone' },
]
@@ -191,8 +234,11 @@ const tabs: Tab[] = [
const activeTab = computed({
get: () => {
// If current active tab is not in available tabs, reset to first tab
if (!tabs.some(tab => tab.id === activeTabId.value))
if (!tabs.some(tab => tab.id === activeTabId.value)) {
if (props.initialTab && tabs.some(tab => tab.id === props.initialTab))
return props.initialTab
return tabs[0]?.id || ''
}
return activeTabId.value
},
set: (value: string) => {
@@ -200,6 +246,16 @@ const activeTab = computed({
},
})
// Reset active tab when dialog opens
watch(() => props.modelValue, (isOpen) => {
if (isOpen) {
if (props.initialTab && tabs.some(tab => tab.id === props.initialTab))
activeTabId.value = props.initialTab
else
activeTabId.value = '' // Let computed handle default
}
})
// Check for errors, and save built Cards :
const showError = ref<boolean>(false)
@@ -251,8 +307,36 @@ function saveCard(card: Card): boolean {
errorMessage.value = t('settings.pages.card.creation.errors.posthistoryinstructions')
return false
}
// Validate Artistry JSON if provided
if (selectedArtistryConfigStr.value.trim()) {
try {
const parsed = JSON.parse(selectedArtistryConfigStr.value)
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Not an object')
}
}
catch (e) {
showError.value = true
errorMessage.value = t('settings.pages.card.creation.errors.invalid_artistry_json')
return false
}
}
showError.value = false
// Build options with final safety parse
let artistryOptions: Record<string, any> | undefined
if (selectedArtistryConfigStr.value.trim()) {
try {
artistryOptions = JSON.parse(selectedArtistryConfigStr.value)
}
catch {
// Should not happen due to validation above
artistryOptions = undefined
}
}
// Build card with modules extension
const cardWithModules = {
...rawCard,
@@ -270,12 +354,21 @@ function saveCard(card: Card): boolean {
voice_id: selectedSpeechVoiceId.value || defaultSpeechVoiceId.value,
},
displayModelId: selectedDisplayModelId.value || defaultDisplayModelId.value,
artistry: {
provider: selectedArtistryProvider.value || defaultArtistryProvider.value,
model: selectedArtistryModel.value,
promptPrefix: selectedArtistryPromptPrefix.value,
widgetInstruction: selectedArtistryWidgetInstruction.value,
spawnMode: selectedArtistrySpawnMode.value,
options: artistryOptions,
autonomousEnabled: selectedArtistryAutonomousEnabled.value,
autonomousThreshold: selectedArtistryAutonomousThreshold.value,
},
},
agents: {},
} as AiriExtension,
},
}
if (isEditMode.value && props.cardId) {
// Edit mode: update existing card
cardStore.updateCard(props.cardId, cardWithModules)
@@ -295,7 +388,7 @@ function saveCard(card: Card): boolean {
function initializeCard(): Card {
// Extract existing card data if in edit mode
const existingCard = (isEditMode.value && props.cardId) ? cardStore.getCard(props.cardId) : undefined
const airiExt = existingCard?.extensions?.airi as AiriExtension | undefined
const airiExt = existingCard?.extensions?.airi as AiriExtensionWithLegacyArtistry | undefined
// Initialize module selections with fallback logic (handles all cases: create, edit with/without extension)
selectedConsciousnessProvider.value = airiExt?.modules?.consciousness?.provider || consciousnessProvider.value
@@ -305,6 +398,23 @@ function initializeCard(): Card {
selectedSpeechVoiceId.value = airiExt?.modules?.speech?.voice_id || defaultSpeechVoiceId.value
selectedDisplayModelId.value = airiExt?.modules?.displayModelId || defaultDisplayModelId.value
// NOTICE: keep legacy `extensions.airi.artistry` fallback so existing cards continue to load.
const artistrySettings = airiExt?.modules?.artistry || airiExt?.artistry
selectedArtistryProvider.value = artistrySettings?.provider || defaultArtistryProvider.value
selectedArtistryModel.value = artistrySettings?.model || ''
selectedArtistryPromptPrefix.value = artistrySettings?.promptPrefix || ''
selectedArtistryWidgetInstruction.value = artistrySettings?.widgetInstruction || DEFAULT_ARTISTRY_WIDGET_INSTRUCTION
selectedArtistrySpawnMode.value = (artistrySettings as any)?.spawnMode || 'bg_widget'
selectedArtistryAutonomousEnabled.value = (artistrySettings as any)?.autonomousEnabled ?? false
selectedArtistryAutonomousThreshold.value = (artistrySettings as any)?.autonomousThreshold ?? 70
try {
selectedArtistryConfigStr.value = artistrySettings?.options ? JSON.stringify(artistrySettings.options, null, 2) : '{\n \n}'
}
catch {
selectedArtistryConfigStr.value = '{\n \n}'
}
// Return existing card data or defaults
if (existingCard) {
return { ...toRaw(existingCard) }
@@ -546,6 +656,20 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string {
<FieldInput v-model="cardVersion" :label="t('settings.pages.card.creation.version')" :required="true" :description="t('settings.pages.card.creation.fields_info.version')" />
</div>
</div>
<!-- Artistry -->
<CardCreationTabArtistry
v-else-if="activeTab === 'artistry'"
v-model:selected-artistry-provider="selectedArtistryProvider"
v-model:selected-artistry-model="selectedArtistryModel"
v-model:selected-artistry-prompt-prefix="selectedArtistryPromptPrefix"
v-model:selected-artistry-widget-instruction="selectedArtistryWidgetInstruction"
v-model:selected-artistry-autonomous-enabled="selectedArtistryAutonomousEnabled"
v-model:selected-artistry-autonomous-threshold="selectedArtistryAutonomousThreshold"
v-model:selected-artistry-spawn-mode="selectedArtistrySpawnMode"
v-model:selected-artistry-config-str="selectedArtistryConfigStr"
:artistry-provider-options="artistryProviderOptions"
:default-artistry-provider-placeholder="getDefaultPlaceholder(defaultArtistryProvider)"
/>
<div class="ml-auto mr-1 flex flex-row gap-2">
<Button
@@ -3,10 +3,11 @@ import type { AiriCard } from '@proj-airi/stage-ui/stores/modules/airi-card'
import DOMPurify from 'dompurify'
import { useBackgroundStore } from '@proj-airi/stage-ui/stores/background'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { Button } from '@proj-airi/ui'
import { Button, Select } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import {
DialogContent,
@@ -15,7 +16,7 @@ import {
DialogRoot,
DialogTitle,
} from 'reka-ui'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import DeleteCardDialog from './DeleteCardDialog.vue'
@@ -23,6 +24,7 @@ import DeleteCardDialog from './DeleteCardDialog.vue'
interface Props {
modelValue: boolean
cardId: string
initialTab?: string
}
const props = defineProps<Props>()
@@ -34,11 +36,15 @@ const { t } = useI18n()
const cardStore = useAiriCardStore()
const consciousnessStore = useConsciousnessStore()
const speechStore = useSpeechStore()
const backgroundStore = useBackgroundStore()
const { removeCard } = cardStore
const { activeCardId } = storeToRefs(cardStore)
const { activeProvider: consciousnessProvider, activeModel: defaultConsciousnessModel } = storeToRefs(consciousnessStore)
const { activeSpeechProvider: speechProvider, activeSpeechModel: defaultSpeechModel, activeSpeechVoiceId: defaultVoiceId } = storeToRefs(speechStore)
const isRefreshingGallery = ref(false)
// Get selected card data
const selectedCard = computed<AiriCard | undefined>(() => {
if (!props.cardId)
@@ -46,6 +52,11 @@ const selectedCard = computed<AiriCard | undefined>(() => {
return cardStore.getCard(props.cardId)
})
// Journal entries for this card
const journalEntries = computed(() => {
return backgroundStore.getCharacterJournalEntries(props.cardId)
})
// Get module settings
const moduleSettings = computed(() => {
if (!selectedCard.value || !selectedCard.value.extensions?.airi?.modules) {
@@ -110,6 +121,36 @@ function handleDeleteConfirm() {
showDeleteConfirm.value = false
}
// Background options including journal entries
const backgroundOptions = computed(() => {
const backgrounds = backgroundStore.getCharacterBackgrounds(props.cardId)
return [
{ value: 'none', label: t('settings.pages.card.creation.none') },
...backgrounds.map(bg => ({
value: bg.id,
label: bg.type === 'journal' ? `Journal: ${bg.title}` : bg.title,
})),
]
})
const activeBackgroundId = computed({
get: () => selectedCard.value?.extensions?.airi?.modules?.activeBackgroundId || 'none',
set: async (val: string) => {
if (!selectedCard.value)
return
const extension = JSON.parse(JSON.stringify(selectedCard.value.extensions))
if (!extension.airi.modules)
extension.airi.modules = {}
extension.airi.modules.activeBackgroundId = val
cardStore.updateCard(props.cardId, {
...selectedCard.value,
extensions: extension,
})
},
})
// Tab type definition
interface Tab {
id: string
@@ -158,15 +199,58 @@ const tabs = computed<Tab[]>(() => {
icon: 'i-solar:tuning-square-linear',
})
// Gallery tab - always show
availableTabs.push({
id: 'gallery',
label: 'Gallery',
icon: 'i-solar:gallery-linear',
})
return availableTabs
})
async function handleSetAsBackground(entry: any) {
activeBackgroundId.value = entry.id
}
async function handleDeleteEntry(id: string) {
if (confirm('Are you sure you want to delete this image from the journal?')) {
await backgroundStore.removeBackground(id)
}
}
async function handleRefreshGallery() {
isRefreshingGallery.value = true
try {
await backgroundStore.initializeStore()
}
finally {
isRefreshingGallery.value = false
}
}
async function handleDownloadEntry(id: string, title: string) {
const url = backgroundStore.getBackgroundUrl(id)
if (!url)
return
const link = document.createElement('a')
link.href = url
link.download = `${title || 'image'}.png`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
// Active tab state - set to first available tab by default
const activeTab = computed({
get: () => {
// If current active tab is not in available tabs, reset to first tab
if (!tabs.value.some(tab => tab.id === activeTabId.value))
if (!tabs.value.some(tab => tab.id === activeTabId.value)) {
if (props.initialTab && tabs.value.some(tab => tab.id === props.initialTab))
return props.initialTab
return tabs.value[0]?.id || ''
}
return activeTabId.value
},
set: (value: string) => {
@@ -174,6 +258,16 @@ const activeTab = computed({
},
})
// Reset active tab when dialog opens
watch(() => props.modelValue, (isOpen) => {
if (isOpen) {
if (props.initialTab && tabs.value.some(tab => tab.id === props.initialTab))
activeTabId.value = props.initialTab
else
activeTabId.value = '' // Let computed handle default
}
})
// Helper function to generate placeholder text for default values
function getDefaultPlaceholder(defaultValue: string | undefined): string {
return defaultValue
@@ -390,6 +484,112 @@ function getModuleDisplayValue(value: string | undefined, defaultValue: string |
</div>
</div>
</div>
<!-- Gallery -->
<div v-if="activeTab === 'gallery'">
<!-- Gallery Header / Preferred Background Selection -->
<div
:class="[
'mb-6 flex flex-row items-center justify-between gap-4',
'border-b border-neutral-100 pb-4 dark:border-neutral-700/50',
]"
>
<div class="flex flex-row items-center gap-3">
<div class="flex flex-col gap-1">
<h3 text-sm font-medium>
Pinned Background
</h3>
<p text-xs text-neutral-500>
Select the image to show when this character is active.
</p>
</div>
<button
:class="[
'flex items-center justify-center size-7 rounded-md',
'bg-neutral-100 dark:bg-neutral-800 text-neutral-500',
'hover:bg-neutral-200 dark:hover:bg-neutral-700 hover:text-neutral-700 dark:hover:text-neutral-300',
'transition-all duration-200 active:scale-90',
]"
:disabled="isRefreshingGallery"
title="Refresh gallery"
@click="handleRefreshGallery"
>
<div
class="i-lucide:refresh-cw text-sm"
:class="{ 'animate-spin': isRefreshingGallery }"
/>
</button>
</div>
<div w-64>
<Select
v-model="activeBackgroundId"
:options="backgroundOptions"
placeholder="Select background"
/>
</div>
</div>
<div
v-if="journalEntries.length === 0"
:class="[
'flex flex-col items-center justify-center',
'border border-dashed border-neutral-200 rounded-xl',
'bg-neutral-50/50 py-12 dark:border-neutral-700/50 dark:bg-neutral-900/50',
]"
>
<div class="i-solar:gallery-wide-broken mb-3 text-5xl text-neutral-300 dark:text-neutral-600" />
<p class="text-neutral-500 dark:text-neutral-400">
No images in the journal yet.
</p>
</div>
<div v-else class="grid grid-cols-2 max-h-120 gap-4 overflow-y-auto pr-2 lg:grid-cols-4 sm:grid-cols-3">
<div
v-for="entry in journalEntries"
:key="entry.id"
class="group relative aspect-square overflow-hidden border border-neutral-200 rounded-lg bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-900"
:class="{ 'ring-2 ring-primary-500 border-primary-500': activeBackgroundId === entry.id }"
>
<img
:src="backgroundStore.getBackgroundUrl(entry.id) ?? undefined"
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
>
<!-- Overlay Actions -->
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-black/60 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<button
class="flex items-center gap-1 rounded-full px-3 py-1.5 text-[10px] text-white font-bold backdrop-blur-md transition-all active:scale-95"
:class="activeBackgroundId === entry.id ? 'bg-primary-500 hover:bg-primary-600' : 'bg-white/20 hover:bg-white/30'"
@click="handleSetAsBackground(entry)"
>
<div :class="activeBackgroundId === entry.id ? 'i-solar:pin-bold' : 'i-solar:pin-linear'" />
{{ activeBackgroundId === entry.id ? 'ACTIVE BG' : 'SET AS BG' }}
</button>
<button
class="flex items-center gap-1 rounded-full bg-blue-500/80 px-3 py-1.5 text-[10px] text-white font-bold backdrop-blur-md transition-all active:scale-95 hover:bg-blue-500"
@click="handleDownloadEntry(entry.id, entry.title)"
>
<div class="i-solar:download-square-linear" />
DOWNLOAD
</button>
<button
class="flex items-center gap-1 rounded-full bg-red-500/80 px-3 py-1.5 text-[10px] text-white font-bold backdrop-blur-md transition-all active:scale-95 hover:bg-red-500"
@click="handleDeleteEntry(entry.id)"
>
<div class="i-solar:trash-bin-trash-linear" />
DELETE
</button>
</div>
<!-- Info Badge -->
<div class="pointer-events-none absolute bottom-1 left-1 right-1 truncate rounded bg-black/40 px-1.5 py-0.5 text-[9px] text-white/90 backdrop-blur-sm">
{{ entry.title }}
</div>
<!-- Active Indicator -->
<div v-if="activeBackgroundId === entry.id" class="absolute left-1 top-1 rounded bg-primary-500 p-1 text-white shadow-lg">
<div class="i-solar:pin-bold text-[10px]" />
</div>
</div>
</div>
</div>
</div>
</div>
<div
@@ -0,0 +1,293 @@
<script setup lang="ts">
import type { ComfyUIWorkflowTemplate } from '@proj-airi/stage-ui/stores/modules/artistry'
import { REPLICATE_IMAGEGEN_PRESETS } from '@proj-airi/stage-shared'
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
import { Button, Checkbox, FieldInput, FieldRange, Select } from '@proj-airi/ui'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
defineProps<{
artistryProviderOptions: { value: string, label: string }[]
defaultArtistryProviderPlaceholder: string
}>()
const selectedArtistryProvider = defineModel<string>('selectedArtistryProvider', { required: true })
const selectedArtistryModel = defineModel<string>('selectedArtistryModel', { required: true })
const selectedArtistryPromptPrefix = defineModel<string>('selectedArtistryPromptPrefix', { required: true })
const selectedArtistryWidgetInstruction = defineModel<string>('selectedArtistryWidgetInstruction', { required: true })
const selectedArtistryAutonomousEnabled = defineModel<boolean>('selectedArtistryAutonomousEnabled', { required: true })
const selectedArtistryAutonomousThreshold = defineModel<number>('selectedArtistryAutonomousThreshold', { required: true })
const selectedArtistrySpawnMode = defineModel<'bg' | 'widget' | 'inline' | 'bg_widget'>('selectedArtistrySpawnMode', { required: true })
const selectedArtistryConfigStr = defineModel<string>('selectedArtistryConfigStr', { required: true })
const { t } = useI18n()
const artistryStore = useArtistryStore()
const comfyuiWorkflows = computed(() => artistryStore.comfyuiSavedWorkflows || [])
const spawnModeOptions = computed(() => [
{ value: 'bg', label: t('settings.pages.modules.artistry.spawn_mode.options.bg') },
{ value: 'inline', label: t('settings.pages.modules.artistry.spawn_mode.options.inline') },
{ value: 'widget', label: t('settings.pages.modules.artistry.spawn_mode.options.widget') },
{ value: 'bg_widget', label: t('settings.pages.modules.artistry.spawn_mode.options.bg_widget') },
])
const pendingInstructionWf = ref<ComfyUIWorkflowTemplate | null>(null)
function handleModelSelect(model: (typeof REPLICATE_IMAGEGEN_PRESETS)[number]) {
selectedArtistryModel.value = model.id
selectedArtistryPromptPrefix.value = model.prompt || ''
selectedArtistryConfigStr.value = JSON.stringify(model.preset, null, 2)
}
function handleComfyWorkflowSelect(wf: ComfyUIWorkflowTemplate) {
selectedArtistryModel.value = wf.id
selectedArtistryConfigStr.value = JSON.stringify({ template: wf.id }, null, 2)
pendingInstructionWf.value = wf
}
function generateAgentInstructions(wf: ComfyUIWorkflowTemplate) {
let fieldsStr = ''
for (const [node, fields] of Object.entries(wf.exposedFields as Record<string, string[]>)) {
fieldsStr += `- **${node}**: ${fields.join(', ')}\n`
}
const exampleKey = Object.keys(wf.exposedFields)[0] || 'NodeTitle'
const exampleField = (wf.exposedFields[exampleKey] as string[])?.[0] || 'field'
return `## Instruction: Widget Spawning (ComfyUI)
You have the ability to generate images using a custom ComfyUI workflow: **${wf.name}**.
### How to Use
**Step 1: Spawn a canvas (do this once)**
- Component name: \`artistry\`
- Give it a unique ID (e.g. \`art-01\`)
**Step 2: Generate an image**
Update the widget with \`status: "generating"\`, a \`prompt\`, and optional field overrides in the root of \`componentProps\`.
**Exposed Fields you can override:**
${fieldsStr}
**Example Update:**
\`\`\`json
{
"status": "generating",
"prompt": "your description",
"template": "${wf.id}",
"${exampleKey}": {
"${exampleField}": "value"
}
}
\`\`\`
`
}
function applyRecommendedInstructions() {
if (!pendingInstructionWf.value)
return
selectedArtistryWidgetInstruction.value = generateAgentInstructions(pendingInstructionWf.value)
pendingInstructionWf.value = null
}
function getExposedFieldsCount(wf: ComfyUIWorkflowTemplate) {
if (!wf.exposedFields)
return 0
return Object.values(wf.exposedFields).reduce((n: number, arr) => n + (arr?.length || 0), 0)
}
function openReplicateModel() {
if (!selectedArtistryModel.value)
return
window.open(`https://replicate.com/${selectedArtistryModel.value}`, '_blank')
}
</script>
<template>
<div class="tab-content ml-auto mr-auto w-95%">
<p class="mb-3">
{{ t('settings.pages.modules.artistry.card.description') }}
</p>
<!-- Autonomous Artist Section -->
<div :class="['mb-6', 'p-4', 'rounded-2xl', 'bg-primary-500/5', 'border-2', 'border-primary-500/10']">
<div :class="['flex', 'items-center', 'justify-between', 'mb-2']">
<label :class="['flex', 'items-center', 'gap-2', 'font-bold', 'text-primary-600', 'dark:text-primary-400']">
<div i-solar:magic-stick-bold-duotone />
{{ t('settings.pages.modules.artistry.autonomous.title') }}
</label>
<Checkbox v-model="selectedArtistryAutonomousEnabled" />
</div>
<p :class="['text-xs', 'text-neutral-500', 'mb-4']">
{{ t('settings.pages.modules.artistry.autonomous.description') }}
</p>
<div v-if="selectedArtistryAutonomousEnabled" :class="['space-y-4', 'animate-in', 'fade-in', 'slide-in-from-top-2']">
<FieldRange
v-model="selectedArtistryAutonomousThreshold"
:label="t('settings.pages.modules.artistry.autonomous.threshold')"
:description="t('settings.pages.modules.artistry.autonomous.threshold_description', {
min: t('settings.pages.modules.artistry.autonomous.threshold_min'),
max: t('settings.pages.modules.artistry.autonomous.threshold_max'),
})"
:min="0"
:max="100"
:step="1"
:format-value="value => `${value}%`"
/>
</div>
</div>
<div :class="['grid', 'grid-cols-1', 'gap-4', 'ml-auto', 'mr-auto', 'w-90%']">
<div :class="['flex', 'flex-col', 'gap-2']">
<label :class="['flex', 'flex-row', 'items-center', 'gap-2', 'text-sm', 'text-neutral-500', 'dark:text-neutral-400']">
<div i-lucide:image />
{{ t('settings.pages.modules.artistry.provider') }}
</label>
<Select
v-model="selectedArtistryProvider"
:options="artistryProviderOptions"
:placeholder="defaultArtistryProviderPlaceholder"
class="w-full"
/>
</div>
<div :class="['flex', 'flex-col', 'gap-2']">
<label :class="['flex', 'flex-row', 'items-center', 'gap-2', 'text-sm', 'text-neutral-500', 'dark:text-neutral-400']">
<div i-solar:route-bold-duotone />
{{ t('settings.pages.modules.artistry.spawn_mode.label') }}
</label>
<Select
v-model="selectedArtistrySpawnMode"
:options="spawnModeOptions"
class="w-full"
/>
<p :class="['text-[10px]', 'text-neutral-400', 'px-1']">
{{ t('settings.pages.modules.artistry.spawn_mode.description') }}
</p>
</div>
<div v-if="selectedArtistryProvider === 'replicate'" class="grid grid-cols-3 mb-2 gap-3">
<Button
v-for="model in REPLICATE_IMAGEGEN_PRESETS"
:key="model.id"
variant="secondary"
:class="[
'h-auto min-h-20 flex flex-col items-center justify-center rounded-xl border p-3 transition-all',
selectedArtistryModel === model.id
? 'border-primary-500 bg-primary-500/10 text-primary-600 dark:text-primary-400'
: 'border-neutral-200 bg-white hover:border-primary-300 dark:border-neutral-700 dark:bg-neutral-800',
]"
@click="handleModelSelect(model)"
>
<span class="text-xs font-bold">{{ model.label }}</span>
<span class="mt-1 text-[10px] opacity-60">{{ model.cost }}</span>
</Button>
</div>
<div
v-if="selectedArtistryProvider === 'comfyui'"
class="mb-2 flex flex-col gap-3"
>
<div
v-if="comfyuiWorkflows.length === 0"
:class="['flex flex-row items-center gap-3 rounded-xl border-2 border-amber-500/20 bg-amber-500/5 p-4 text-sm text-amber-600 dark:text-amber-400']"
>
<div i-solar:info-circle-bold-duotone class="shrink-0 text-lg" />
<p>
{{ t('settings.pages.modules.artistry.card.comfyui_empty') }}
</p>
</div>
<div v-else class="grid grid-cols-2 gap-3">
<Button
v-for="wf in comfyuiWorkflows"
:key="wf.id"
variant="secondary"
:class="[
'h-auto min-h-20 flex flex-col items-center justify-center rounded-xl border p-3 transition-all',
selectedArtistryModel === wf.id
? 'border-primary-500 bg-primary-500/10 text-primary-600 dark:text-primary-400'
: 'border-neutral-200 bg-white hover:border-primary-300 dark:border-neutral-700 dark:bg-neutral-800',
]"
@click="handleComfyWorkflowSelect(wf)"
>
<span class="text-xs font-bold">{{ wf.name }}</span>
<span class="mt-1 text-[10px] opacity-60">{{ t('settings.pages.modules.artistry.card.exposed_fields', { count: getExposedFieldsCount(wf) }) }}</span>
</Button>
</div>
</div>
<div class="mt-4 flex flex-col gap-5">
<div class="relative">
<FieldInput
v-model="selectedArtistryModel"
:label="t('settings.pages.modules.artistry.model.label')"
:description="t('settings.pages.modules.artistry.model.description')"
placeholder="e.g. black-forest-labs/flux-schnell"
/>
<Button
v-if="selectedArtistryProvider === 'replicate' && selectedArtistryModel"
variant="ghost"
size="sm"
shape="square"
:class="[
'absolute right-3 top-9',
]"
:title="t('settings.pages.modules.artistry.card.open_on_replicate')"
@click="openReplicateModel"
>
<div i-solar:link-round-bold-duotone class="text-xl" />
</Button>
</div>
<div
v-if="pendingInstructionWf"
class="flex flex-col gap-3 border-2 border-indigo-500/20 rounded-xl bg-indigo-500/5 p-4"
>
<div class="flex items-center gap-2 text-sm text-indigo-600 font-bold dark:text-indigo-400">
<div i-solar:magic-stick-bold-duotone />
{{ t('settings.pages.modules.artistry.card.instruction_sync.title') }}
</div>
<p class="text-xs text-neutral-600 dark:text-neutral-400">
{{ t('settings.pages.modules.artistry.card.instruction_sync.description', { workflowName: pendingInstructionWf.name }) }}
</p>
<div class="flex items-center gap-2">
<Button
variant="primary"
size="sm"
@click="applyRecommendedInstructions"
>
{{ t('settings.pages.modules.artistry.card.instruction_sync.apply') }}
</Button>
<Button
variant="secondary"
size="sm"
@click="pendingInstructionWf = null"
>
{{ t('settings.pages.modules.artistry.card.instruction_sync.keep') }}
</Button>
</div>
</div>
<FieldInput
v-model="selectedArtistryPromptPrefix"
:label="t('settings.pages.modules.artistry.prompt-prefix.label')"
:description="t('settings.pages.modules.artistry.prompt-prefix.description')"
placeholder="e.g. Masterpiece, high quality, 1girl, anime,"
/>
<FieldInput
v-model="selectedArtistryWidgetInstruction"
:label="t('settings.pages.modules.artistry.widget-instructions.label')"
:description="t('settings.pages.modules.artistry.widget-instructions.description')"
:single-line="false"
:rows="12"
/>
<FieldInput
v-model="selectedArtistryConfigStr"
:label="t('settings.pages.modules.artistry.options.label')"
:single-line="false"
/>
</div>
</div>
</div>
</template>
@@ -8,6 +8,7 @@ import { ComboboxSelect } from '@proj-airi/ui/components/form'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import CardCreate from './components/CardCreate.vue'
import CardCreationDialog from './components/CardCreationDialog.vue'
@@ -20,10 +21,15 @@ const cardStore = useAiriCardStore()
const { addCard, removeCard } = cardStore
const { cards, activeCardId } = storeToRefs(cardStore)
const route = useRoute()
const router = useRouter()
// Currently selected card ID (different from active card ID)
const selectedCardId = ref<string>('')
// Currently editing card ID
const editingCardId = ref<string>('')
// Initial tab to open in the dialog
const initialTabId = ref<string>('')
// Dialog state
const isCardDialogOpen = ref(false)
const isCardCreationDialogOpen = ref(false)
@@ -151,9 +157,47 @@ function activateCard(id: string) {
watch(isCardCreationDialogOpen, (isOpen) => {
if (!isOpen) {
editingCardId.value = ''
initialTabId.value = ''
}
})
// Clear initial tab when detail dialog closes
watch(isCardDialogOpen, (isOpen) => {
if (!isOpen) {
initialTabId.value = ''
}
})
// Handle deep-linking from query params
watch(() => [route.query.cardId, route.query.tab], ([cardId, tab]) => {
if (!cardId || typeof cardId !== 'string' || !cards.value.has(cardId))
return
const targetTab = typeof tab === 'string' ? tab : ''
selectedCardId.value = cardId
initialTabId.value = targetTab
// Gallery or other viewing tabs go to Detail dialog
if (['gallery', 'description', 'notes', 'character'].includes(targetTab)) {
isCardDialogOpen.value = true
isCardCreationDialogOpen.value = false
}
// Artistry or other editing tabs go to Creation/Edit dialog
else if (['artistry', 'identity', 'behavior', 'modules', 'settings'].includes(targetTab)) {
editingCardId.value = cardId
isCardCreationDialogOpen.value = true
isCardDialogOpen.value = false
}
else {
// Default to detail if tab is unknown
isCardDialogOpen.value = true
isCardCreationDialogOpen.value = false
}
// Clear query params to prevent re-triggering and keep URL clean
void router.replace({ query: {} })
}, { immediate: true })
// Card version number
function getVersionNumber(id: string) {
const card = cards.value.get(id)
@@ -305,12 +349,14 @@ function getModuleShortName(id: string, module: 'consciousness' | 'voice') {
<CardDetailDialog
v-model="isCardDialogOpen"
:card-id="selectedCardId"
:initial-tab="initialTabId"
/>
<!-- Card creation/edit dialog -->
<CardCreationDialog
v-model="isCardCreationDialogOpen"
:card-id="editingCardId"
:initial-tab="initialTabId"
/>
<!-- Background decoration -->
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { RadioCardSimple } from '@proj-airi/stage-ui/components'
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const router = useRouter()
const { t } = useI18n()
const artistryStore = useArtistryStore()
const { globalProvider } = storeToRefs(artistryStore)
const availableProviders = computed(() => [
{
id: 'none',
name: t('settings.pages.modules.artistry.providers.none.name'),
description: t('settings.pages.modules.artistry.providers.none.description'),
icon: 'i-solar:forbidden-circle-bold-duotone',
configRoute: '/settings/modules/artistry',
},
{
id: 'comfyui',
name: t('settings.pages.modules.artistry.providers.comfyui.name'),
description: t('settings.pages.modules.artistry.providers.comfyui.description'),
icon: 'i-solar:monitor-camera-bold-duotone',
configRoute: '/settings/providers/artistry/comfyui',
},
{
id: 'replicate',
name: t('settings.pages.modules.artistry.providers.replicate.name'),
description: t('settings.pages.modules.artistry.providers.replicate.description'),
icon: 'i-solar:cloud-upload-bold-duotone',
configRoute: '/settings/providers/artistry/replicate',
},
{
id: 'nanobanana',
name: t('settings.pages.modules.artistry.providers.nanobanana.name'),
description: t('settings.pages.modules.artistry.providers.nanobanana.description'),
icon: 'i-solar:gallery-round-bold-duotone',
configRoute: '/settings/providers/artistry/nanobanana',
},
])
</script>
<template>
<div class="flex flex-col gap-6">
<div class="h-fit w-full flex flex-col gap-4 rounded-xl bg-neutral-100 p-4 dark:bg-[rgba(0,0,0,0.3)]">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
{{ t('settings.pages.modules.artistry.page.title') }}
</h2>
<div class="text-neutral-400 dark:text-neutral-500">
{{ t('settings.pages.modules.artistry.page.description') }}
</div>
</div>
<div class="max-w-full">
<fieldset
class="min-w-0 flex flex-row gap-4 overflow-x-auto scroll-smooth pb-2"
style="scrollbar-width: none;"
role="radiogroup"
>
<RadioCardSimple
v-for="provider in availableProviders"
:id="provider.id"
:key="provider.id"
v-model="globalProvider"
name="artistry-provider"
:value="provider.id"
:title="provider.name"
:description="provider.description"
@click="router.push(provider.configRoute)"
/>
</fieldset>
</div>
</div>
</div>
<div
v-motion
class="pointer-events-none fixed bottom-0 right-[-1.25rem] top-[calc(100dvh-15rem)] z-[-1] size-60 flex items-center justify-center text-neutral-200/50 dark:text-neutral-600/20"
:initial="{ scale: 0.9, opacity: 0, x: 20 }"
:enter="{ scale: 1, opacity: 1, x: 0 }"
:duration="500"
>
<div class="i-solar:gallery-bold-duotone text-[60px]" />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
titleKey: settings.pages.modules.artistry.title
subtitleKey: settings.title
stageTransition:
name: slide
</route>
@@ -0,0 +1,549 @@
<script setup lang="ts">
import type { ComfyUIWorkflowTemplate } from '@proj-airi/stage-ui/stores/modules/artistry'
import { defineInvoke } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/renderer'
import { errorMessageFrom } from '@moeru/std'
import { artistryTestComfyUIConnection, isStageTamagotchi } from '@proj-airi/stage-shared'
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
import { Button, FieldInput } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const artistryStore = useArtistryStore()
const { t } = useI18n()
const {
comfyuiServerUrl,
comfyuiSavedWorkflows,
comfyuiActiveWorkflow,
} = storeToRefs(artistryStore)
const expandedWorkflow = ref<string | null>(null)
// TODO: perhaps electron-vueuse should be ported for this?
function getElectronIpcRenderer() {
return (window as Window & {
electron?: { ipcRenderer?: unknown }
}).electron?.ipcRenderer
}
// --- Connection test ---
const connectionStatus = ref<'idle' | 'testing' | 'connected' | 'failed'>('idle')
const connectionInfo = ref('')
const isCorsError = ref(false)
async function testConnection() {
connectionStatus.value = 'testing'
connectionInfo.value = ''
isCorsError.value = false
try {
if (isStageTamagotchi()) {
const ipcRenderer = getElectronIpcRenderer()
if (!ipcRenderer)
throw new Error('Electron IPC is not available in this renderer context')
// Proxy through main process to bypass CORS.
const { context } = createContext(ipcRenderer as Parameters<typeof createContext>[0])
const invokeTestComfyUIConnection = defineInvoke(context, artistryTestComfyUIConnection)
const result = await invokeTestComfyUIConnection({
url: comfyuiServerUrl.value,
})
if (result.ok) {
connectionInfo.value = result.info || t('settings.pages.providers.provider.comfyui.settings.connection.connected')
connectionStatus.value = 'connected'
}
else {
connectionInfo.value = result.info || t('settings.pages.providers.provider.comfyui.settings.connection.failed')
connectionStatus.value = 'failed'
}
}
else {
// Browser fallback (subject to CORS)
const url = comfyuiServerUrl.value.replace(/\/+$/, '')
const resp = await fetch(`${url}/system_stats`, { mode: 'cors' })
if (!resp.ok)
throw new Error(`HTTP ${resp.status}`)
const data = await resp.json() as { devices?: Array<{ name?: string }> }
const gpus = data.devices?.map(d => d.name).join(', ') || t('settings.pages.providers.provider.comfyui.settings.connection.unknown_gpu')
connectionInfo.value = `${t('settings.pages.providers.provider.comfyui.settings.connection.connected')}${gpus}`
connectionStatus.value = 'connected'
}
}
catch (e: unknown) {
const errorMessage = errorMessageFrom(e) ?? t('settings.pages.providers.provider.comfyui.settings.connection.unknown_error')
connectionInfo.value = `${t('settings.pages.providers.provider.comfyui.settings.connection.error_prefix')}: ${errorMessage}`
connectionStatus.value = 'failed'
if (errorMessage.includes('fetch') || errorMessage.includes('CORS')) {
isCorsError.value = true
}
}
}
// --- Workflow Manager ---
const showUploadSection = ref(false)
const uploadError = ref('')
const parsedWorkflow = ref<{ nodes: Array<{ id: string, title: string, type: string, inputs: Record<string, any> }> } | null>(null)
const pendingWorkflowName = ref('')
const pendingWorkflowRaw = ref<Record<string, any> | null>(null)
const selectedFields = ref<Record<string, Set<string>>>({})
function handleFileUpload(event: Event) {
uploadError.value = ''
parsedWorkflow.value = null
pendingWorkflowRaw.value = null
selectedFields.value = {}
const input = event.target as HTMLInputElement
const file = input?.files?.[0]
if (!file)
return
const reader = new FileReader()
reader.onload = (e) => {
try {
const json = JSON.parse(e.target?.result as string)
pendingWorkflowRaw.value = json
pendingWorkflowName.value = file.name.replace(/.json$/, '')
// Parse nodes from API format (flat object of nodeId -> node)
const nodes: Array<{ id: string, title: string, type: string, inputs: Record<string, any> }> = []
for (const [nodeId, node] of Object.entries(json as Record<string, any>)) {
const title = node._meta?.title || node.class_type || `Node ${nodeId}`
const type = node.class_type || 'Unknown'
const inputs: Record<string, any> = {}
for (const [key, val] of Object.entries(node.inputs || {})) {
// Skip link arrays (connections to other nodes)
if (!Array.isArray(val)) {
inputs[key] = val
}
}
if (Object.keys(inputs).length > 0) {
nodes.push({ id: nodeId, title, type, inputs })
selectedFields.value[title] = new Set()
}
}
parsedWorkflow.value = { nodes }
}
catch (err: unknown) {
uploadError.value = `${t('settings.pages.providers.provider.comfyui.settings.upload.invalid_json')}: ${errorMessageFrom(err)}`
}
}
reader.readAsText(file)
}
function toggleField(nodeTitle: string, fieldName: string) {
const set = selectedFields.value[nodeTitle]
if (!set)
return
if (set.has(fieldName)) {
set.delete(fieldName)
}
else {
set.add(fieldName)
}
}
function isFieldSelected(nodeTitle: string, fieldName: string): boolean {
return selectedFields.value[nodeTitle]?.has(fieldName) ?? false
}
const totalExposed = computed(() => {
let count = 0
for (const set of Object.values(selectedFields.value)) {
count += set.size
}
return count
})
function saveWorkflow() {
if (!pendingWorkflowRaw.value || !pendingWorkflowName.value.trim())
return
const exposedFields: Record<string, string[]> = {}
for (const [title, fields] of Object.entries(selectedFields.value)) {
const arr = Array.from(fields)
if (arr.length > 0) {
exposedFields[title] = arr
}
}
const id = pendingWorkflowName.value.toLowerCase().replace(/[^a-z0-9]+/g, '-')
const template: ComfyUIWorkflowTemplate = {
id,
name: pendingWorkflowName.value.trim(),
workflow: pendingWorkflowRaw.value,
exposedFields,
}
const existing = comfyuiSavedWorkflows.value.findIndex(w => w.id === id)
if (existing >= 0) {
comfyuiSavedWorkflows.value[existing] = template
}
else {
comfyuiSavedWorkflows.value = [...comfyuiSavedWorkflows.value, template]
}
// Auto-set as active if it's the first one
if (!comfyuiActiveWorkflow.value) {
comfyuiActiveWorkflow.value = id
}
// Reset upload state
showUploadSection.value = false
parsedWorkflow.value = null
pendingWorkflowRaw.value = null
selectedFields.value = {}
pendingWorkflowName.value = ''
}
function removeWorkflow(id: string) {
comfyuiSavedWorkflows.value = comfyuiSavedWorkflows.value.filter(w => w.id !== id)
if (comfyuiActiveWorkflow.value === id) {
comfyuiActiveWorkflow.value = comfyuiSavedWorkflows.value[0]?.id || ''
}
}
function formatValue(val: any): string {
if (typeof val === 'string')
return val.length > 40 ? `"${val.slice(0, 37)}..."` : `"${val}"`
if (typeof val === 'number')
return String(val)
if (typeof val === 'boolean')
return String(val)
return JSON.stringify(val)
}
function generateExampleJson(wf: ComfyUIWorkflowTemplate) {
const example: Record<string, any> = {
template: wf.id,
}
for (const [nodeTitle, fields] of Object.entries(wf.exposedFields)) {
example[nodeTitle] = {}
for (const field of fields) {
const nodeId = Object.keys(wf.workflow).find(id => (wf.workflow[id]._meta?.title || wf.workflow[id].class_type) === nodeTitle)
const val = nodeId ? wf.workflow[nodeId].inputs[field] : '...'
example[nodeTitle][field] = val
}
}
return JSON.stringify(example, null, 2)
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text)
}
</script>
<template>
<div class="flex flex-col gap-6">
<!-- Header -->
<div class="rounded-xl bg-indigo-500/8 p-5 dark:bg-indigo-500/12">
<div class="mb-3 flex items-center gap-3">
<div class="i-solar:gallery-bold-duotone text-3xl text-indigo-500" />
<div>
<h2 class="text-xl text-neutral-800 font-semibold dark:text-neutral-100">
{{ t('settings.pages.providers.provider.comfyui.settings.heading') }}
</h2>
<p class="text-sm text-neutral-500 dark:text-neutral-400">
{{ t('settings.pages.providers.provider.comfyui.settings.description') }}
</p>
</div>
</div>
<div class="grid grid-cols-1 mt-4 gap-3 sm:grid-cols-3">
<div class="rounded-lg bg-white/60 p-3 dark:bg-neutral-800/60">
<div class="mb-1 text-xs text-neutral-400 font-medium dark:text-neutral-500">
{{ t('settings.pages.providers.provider.comfyui.settings.info.what_you_need.label') }}
</div>
<div class="text-sm text-neutral-700 dark:text-neutral-300">
{{ t('settings.pages.providers.provider.comfyui.settings.info.what_you_need.value') }}
</div>
</div>
<div class="rounded-lg bg-white/60 p-3 dark:bg-neutral-800/60">
<div class="mb-1 text-xs text-neutral-400 font-medium dark:text-neutral-500">
{{ t('settings.pages.providers.provider.comfyui.settings.info.how_to_export.label') }}
</div>
<div class="text-sm text-neutral-700 dark:text-neutral-300">
{{ t('settings.pages.providers.provider.comfyui.settings.info.how_to_export.value') }}
</div>
</div>
<div class="rounded-lg bg-white/60 p-3 dark:bg-neutral-800/60">
<div class="mb-1 text-xs text-neutral-400 font-medium dark:text-neutral-500">
{{ t('settings.pages.providers.provider.comfyui.settings.info.scope_boundary.label') }}
</div>
<div class="text-sm text-neutral-700 dark:text-neutral-300">
{{ t('settings.pages.providers.provider.comfyui.settings.info.scope_boundary.value') }}
</div>
</div>
</div>
</div>
<!-- Connection -->
<div class="flex flex-col gap-4">
<h3 class="text-lg text-neutral-700 font-medium dark:text-neutral-300">
{{ t('settings.pages.providers.provider.comfyui.settings.connection.title') }}
</h3>
<div class="flex items-end gap-3">
<div class="flex-1">
<FieldInput
v-model="comfyuiServerUrl"
:label="t('settings.pages.providers.provider.comfyui.settings.connection.server_url.label')"
:description="t('settings.pages.providers.provider.comfyui.settings.connection.server_url.description')"
:placeholder="t('settings.pages.providers.provider.comfyui.settings.connection.server_url.placeholder')"
/>
</div>
<Button
class="mb-0.5"
variant="primary"
size="md"
:icon="connectionStatus === 'testing' ? undefined : 'i-solar:plug-circle-bold-duotone'"
:loading="connectionStatus === 'testing'"
:disabled="connectionStatus === 'testing'"
@click="testConnection"
>
{{ connectionStatus === 'testing'
? t('settings.pages.providers.provider.comfyui.settings.connection.testing')
: t('settings.pages.providers.provider.comfyui.settings.connection.test') }}
</Button>
</div>
<div
v-if="connectionInfo"
class="rounded-lg px-3 py-2 text-sm"
:class="{
'bg-green-500/10 text-green-600 dark:text-green-400': connectionStatus === 'connected',
'bg-red-500/10 text-red-600 dark:text-red-400': connectionStatus === 'failed',
}"
>
{{ connectionInfo }}
</div>
<!-- CORS Troubleshooting -->
<div
v-if="isCorsError"
class="flex flex-col gap-2 border-2 border-amber-500/20 rounded-xl bg-amber-500/10 p-4"
>
<div class="flex items-center gap-2 text-sm text-amber-600 font-bold dark:text-amber-400">
<div i-solar:shield-warning-bold-duotone />
{{ t('settings.pages.providers.provider.comfyui.settings.cors.title') }}
</div>
<p class="text-xs text-neutral-600 leading-relaxed dark:text-neutral-400">
{{ t('settings.pages.providers.provider.comfyui.settings.cors.description') }}
</p>
<div class="break-all rounded bg-black/5 p-2 text-[10px] text-neutral-500 font-mono dark:bg-black/20 dark:text-neutral-400">
{{ t('settings.pages.providers.provider.comfyui.settings.cors.command') }}
</div>
</div>
</div>
<!-- Saved Workflows -->
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<h3 class="text-lg text-neutral-700 font-medium dark:text-neutral-300">
{{ t('settings.pages.providers.provider.comfyui.settings.workflows.title') }}
</h3>
<Button
variant="secondary"
size="sm"
@click="showUploadSection = !showUploadSection"
>
{{ showUploadSection
? t('settings.pages.providers.provider.comfyui.settings.workflows.cancel_upload')
: t('settings.pages.providers.provider.comfyui.settings.workflows.upload') }}
</Button>
</div>
<!-- Workflow List -->
<div v-if="comfyuiSavedWorkflows.length === 0 && !showUploadSection" class="text-sm text-neutral-400 italic dark:text-neutral-500">
{{ t('settings.pages.providers.provider.comfyui.settings.workflows.empty') }}
</div>
<div v-for="wf in comfyuiSavedWorkflows" :key="wf.id" class="flex flex-col gap-2 border border-neutral-200 rounded-lg p-3 dark:border-neutral-700">
<div class="flex items-center gap-3">
<input
type="radio"
:checked="comfyuiActiveWorkflow === wf.id"
name="active-workflow"
class="accent-indigo-500"
@change="comfyuiActiveWorkflow = wf.id"
>
<div class="flex-1 cursor-pointer" @click="expandedWorkflow = (expandedWorkflow === wf.id ? null : wf.id)">
<div class="flex items-center gap-2 text-sm text-neutral-800 font-medium dark:text-neutral-200">
{{ wf.name }}
<div v-if="expandedWorkflow === wf.id" class="i-solar:alt-arrow-down-linear text-xs opacity-50" />
<div v-else class="i-solar:alt-arrow-right-linear text-xs opacity-50" />
</div>
<div class="text-xs text-neutral-400 dark:text-neutral-500">
{{ t('settings.pages.providers.provider.comfyui.settings.workflows.summary', {
nodes: Object.keys(wf.workflow).length,
fields: Object.values(wf.exposedFields).reduce((n, arr) => n + arr.length, 0),
}) }}
</div>
</div>
<Button
variant="ghost"
size="sm"
class="!text-red-400 hover:!text-red-500"
@click="removeWorkflow(wf.id)"
>
{{ t('settings.pages.providers.provider.comfyui.settings.workflows.remove') }}
</Button>
</div>
<!-- Expanded Details -->
<div v-if="expandedWorkflow === wf.id" class="mt-2 flex flex-col gap-5 border-t border-neutral-100 pb-2 pl-7 pt-4 dark:border-neutral-800">
<!-- Exposed Fields Visualization -->
<div class="flex flex-col gap-2">
<div class="text-[10px] text-neutral-400 font-bold tracking-wider uppercase dark:text-neutral-500">
{{ t('settings.pages.providers.provider.comfyui.settings.workflows.exposed_parameters') }}
</div>
<div class="flex flex-wrap gap-3">
<div v-for="(fields, nodeTitle) in wf.exposedFields" :key="nodeTitle" class="flex flex-col gap-1.5">
<div class="self-start rounded bg-neutral-100 px-1.5 py-0.5 text-[9px] text-neutral-500 font-mono dark:bg-neutral-800 dark:text-neutral-400">
{{ nodeTitle }}
</div>
<div class="flex flex-wrap gap-1 pl-1">
<div v-for="f in fields" :key="f" class="group relative flex items-center gap-1.5 text-[10px] text-indigo-600 font-medium dark:text-indigo-400">
<div class="size-1 rounded-full bg-indigo-400" />
{{ f }}
</div>
</div>
</div>
</div>
</div>
<!-- Integration Snippet -->
<div class="flex flex-col gap-3 border border-indigo-500/10 rounded-xl bg-neutral-900/5 p-4 dark:bg-indigo-500/5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-xs text-indigo-600 font-bold dark:text-indigo-400">
<div i-solar:code-bold-duotone />
{{ t('settings.pages.providers.provider.comfyui.settings.workflows.config_snippet') }}
</div>
<Button
variant="secondary"
size="sm"
@click="copyToClipboard(generateExampleJson(wf))"
>
{{ t('settings.pages.providers.provider.comfyui.settings.workflows.copy_json') }}
</Button>
</div>
<div class="text-[11px] text-neutral-700 leading-relaxed font-mono dark:text-neutral-300">
<div class="flex gap-2">
<span class="text-indigo-500 dark:text-indigo-400">{</span>
</div>
<div class="pl-4">
<span class="text-emerald-600 dark:text-emerald-400">"template"</span>: <span class="text-amber-600">"{{ wf.id }}"</span>,
</div>
<div v-for="(fields, nodeTitle, index) in wf.exposedFields" :key="nodeTitle" class="pl-4">
<span class="text-emerald-600 dark:text-emerald-400">"{{ nodeTitle }}"</span>: {
<div v-for="(f, fIndex) in fields" :key="f" class="pl-4">
<span class="text-emerald-600 dark:text-emerald-400">"{{ f }}"</span>: <span class="text-blue-500">"..."</span>{{ fIndex < fields.length - 1 ? ',' : '' }}
</div>
}<span>{{ index < Object.keys(wf.exposedFields).length - 1 ? ',' : '' }}</span>
</div>
<div class="flex gap-2">
<span class="text-indigo-500 dark:text-indigo-400">}</span>
</div>
</div>
<div class="mt-1 flex items-center gap-2 pb-1 text-[10px] text-neutral-400 italic">
<div i-solar:info-circle-linear />
{{ t('settings.pages.providers.provider.comfyui.settings.workflows.paste_hint') }}
</div>
</div>
</div>
</div>
<!-- Upload Section -->
<div v-if="showUploadSection" class="flex flex-col gap-4 border-2 border-indigo-300 rounded-xl border-dashed p-5 dark:border-indigo-700">
<div class="flex flex-col items-center gap-2">
<div class="text-3xl text-indigo-400">
📋
</div>
<div class="text-sm text-neutral-600 dark:text-neutral-400">
{{ t('settings.pages.providers.provider.comfyui.settings.upload.prompt') }}
</div>
<input
type="file"
accept=".json"
class="text-sm"
@change="handleFileUpload"
>
</div>
<div v-if="uploadError" class="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-500">
{{ uploadError }}
</div>
<!-- Field Picker -->
<div v-if="parsedWorkflow" class="flex flex-col gap-3">
<FieldInput
v-model="pendingWorkflowName"
:label="t('settings.pages.providers.provider.comfyui.settings.upload.workflow_name.label')"
:description="t('settings.pages.providers.provider.comfyui.settings.upload.workflow_name.description')"
:placeholder="t('settings.pages.providers.provider.comfyui.settings.upload.workflow_name.placeholder')"
/>
<div class="text-sm text-neutral-600 font-medium dark:text-neutral-400">
{{ t('settings.pages.providers.provider.comfyui.settings.upload.select_fields') }}
</div>
<div class="max-h-80 flex flex-col gap-2 overflow-y-auto">
<div
v-for="node in parsedWorkflow.nodes"
:key="node.id"
class="border border-neutral-200 rounded-lg p-3 dark:border-neutral-700"
>
<div class="mb-1 text-sm text-neutral-700 font-medium dark:text-neutral-300">
{{ node.title }}
<span class="ml-1 text-xs text-neutral-400">({{ node.type }})</span>
</div>
<div class="flex flex-col gap-1 pl-3">
<label
v-for="(val, field) in node.inputs"
:key="String(field)"
class="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-xs hover:bg-neutral-50 dark:hover:bg-neutral-800"
>
<input
type="checkbox"
class="accent-indigo-500"
:checked="isFieldSelected(node.title, String(field))"
@change="toggleField(node.title, String(field))"
>
<span class="text-neutral-600 font-mono dark:text-neutral-400">{{ field }}</span>
<span class="truncate text-neutral-400 dark:text-neutral-500">= {{ formatValue(val) }}</span>
</label>
</div>
</div>
</div>
<div class="mt-2 flex items-center justify-between">
<span class="text-xs text-neutral-400">{{ t('settings.pages.providers.provider.comfyui.settings.upload.fields_exposed', { count: totalExposed }) }}</span>
<Button
variant="primary"
size="sm"
:disabled="!pendingWorkflowName.trim() || totalExposed === 0"
@click="saveWorkflow"
>
{{ t('settings.pages.providers.provider.comfyui.settings.upload.save') }}
</Button>
</div>
</div>
</div>
</div>
</div>
</template>
<route lang="yaml">
meta:
layout: settings
titleKey: settings.pages.providers.provider.comfyui.settings.title
subtitleKey: settings.title
stageTransition:
name: slide
</route>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
import { FieldInput, FieldSelect } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const artistryStore = useArtistryStore()
const { t } = useI18n()
const {
nanobananaApiKey,
nanobananaModel,
nanobananaResolution,
} = storeToRefs(artistryStore)
const modelOptions = computed(() => [
{ label: t('settings.pages.providers.provider.nanobanana.settings.model_options.nano_banana_2'), value: 'gemini-3.1-flash-image-preview' },
{ label: t('settings.pages.providers.provider.nanobanana.settings.model_options.nano_banana_pro'), value: 'gemini-3-pro-image-preview' },
{ label: t('settings.pages.providers.provider.nanobanana.settings.model_options.nano_banana'), value: 'gemini-2.5-flash-image' },
])
const resolutionOptions = computed(() => [
{ label: '1K', value: '1K' },
{ label: '2K', value: '2K' },
{ label: '4K', value: '4K' },
])
</script>
<template>
<div class="flex flex-col gap-6">
<div class="rounded-xl bg-amber-500/8 p-5 dark:bg-amber-500/12">
<div class="mb-3 flex items-center gap-3">
<div class="i-solar:gallery-round-bold-duotone text-3xl text-amber-500" />
<div>
<h2 class="text-xl text-neutral-800 font-semibold dark:text-neutral-100">
{{ t('settings.pages.providers.provider.nanobanana.settings.heading') }}
</h2>
<p class="text-sm text-neutral-500 dark:text-neutral-400">
{{ t('settings.pages.providers.provider.nanobanana.settings.description') }}
</p>
</div>
</div>
</div>
<div class="flex flex-col gap-4">
<FieldInput
v-model="nanobananaApiKey"
:label="t('settings.pages.providers.provider.nanobanana.settings.api_key.label')"
:description="t('settings.pages.providers.provider.nanobanana.settings.api_key.description')"
:placeholder="t('settings.pages.providers.provider.nanobanana.settings.api_key.placeholder')"
type="password"
/>
<FieldSelect
v-model="nanobananaModel"
:label="t('settings.pages.providers.provider.nanobanana.settings.preferred_model.label')"
:description="t('settings.pages.providers.provider.nanobanana.settings.preferred_model.description')"
:options="modelOptions"
/>
<FieldSelect
v-model="nanobananaResolution"
:label="t('settings.pages.providers.provider.nanobanana.settings.default_resolution.label')"
:description="t('settings.pages.providers.provider.nanobanana.settings.default_resolution.description')"
:options="resolutionOptions"
/>
</div>
</div>
</template>
<route lang="yaml">
meta:
layout: settings
titleKey: settings.pages.providers.provider.nanobanana.settings.title
subtitleKey: settings.title
stageTransition:
name: slide
</route>
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
import { FieldInput, FieldRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
const artistryStore = useArtistryStore()
const { t } = useI18n()
const {
replicateApiKey,
replicateDefaultModel,
replicateAspectRatio,
replicateInferenceSteps,
} = storeToRefs(artistryStore)
</script>
<template>
<div class="flex flex-col gap-6">
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
{{ t('settings.pages.providers.provider.replicate.settings.heading') }}
</h2>
<div class="text-neutral-400 dark:text-neutral-500">
{{ t('settings.pages.providers.provider.replicate.settings.description') }}
</div>
</div>
<div class="flex flex-col gap-4">
<FieldInput
v-model="replicateApiKey"
:label="t('settings.pages.providers.provider.replicate.settings.api_key.label')"
:description="t('settings.pages.providers.provider.replicate.settings.api_key.description')"
:placeholder="t('settings.pages.providers.provider.replicate.settings.api_key.placeholder')"
type="password"
/>
<FieldInput
v-model="replicateDefaultModel"
:label="t('settings.pages.providers.provider.replicate.settings.default_model.label')"
:description="t('settings.pages.providers.provider.replicate.settings.default_model.description')"
:placeholder="t('settings.pages.providers.provider.replicate.settings.default_model.placeholder')"
/>
<FieldInput
v-model="replicateAspectRatio"
:label="t('settings.pages.providers.provider.replicate.settings.aspect_ratio.label')"
:description="t('settings.pages.providers.provider.replicate.settings.aspect_ratio.description')"
:placeholder="t('settings.pages.providers.provider.replicate.settings.aspect_ratio.placeholder')"
/>
<FieldRange
v-model="replicateInferenceSteps"
:label="t('settings.pages.providers.provider.replicate.settings.inference_steps.label')"
:description="t('settings.pages.providers.provider.replicate.settings.inference_steps.description')"
:min="1"
:max="50"
:step="1"
/>
</div>
</div>
</template>
<route lang="yaml">
meta:
layout: settings
titleKey: settings.pages.providers.provider.replicate.settings.title
subtitleKey: settings.title
stageTransition:
name: slide
</route>
@@ -1,14 +1,19 @@
<script setup lang="ts">
import { IconStatusItem, RippleGrid } from '@proj-airi/stage-ui/components'
import { useAnalytics, useScrollToHash } from '@proj-airi/stage-ui/composables'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useRippleGridState } from '@proj-airi/stage-ui/composables/use-ripple-grid-state'
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const providersStore = useProvidersStore()
const artistryStore = useArtistryStore()
const { lastClickedIndex, setLastClickedIndex } = useRippleGridState()
const { trackProviderClick } = useAnalytics()
@@ -18,51 +23,136 @@ const {
allAudioTranscriptionProvidersMetadata,
} = storeToRefs(providersStore)
const allArtistryProvidersMetadata = computed(() => {
return [
{
id: 'comfyui',
category: 'artistry',
icon: 'i-solar:gallery-bold-duotone',
iconColor: 'text-indigo-500',
name: 'ComfyUI',
localizedName: 'ComfyUI',
description: t('settings.pages.providers.categories.artistry.items.comfyui.description'),
localizedDescription: t('settings.pages.providers.categories.artistry.items.comfyui.description'),
configured: !!artistryStore.comfyuiServerUrl,
to: '/settings/providers/artistry/comfyui',
pricing: 'free',
deployment: 'local',
beginnerRecommended: true,
iconImage: undefined,
},
{
id: 'replicate',
category: 'artistry',
icon: 'i-lobe-icons:replicate',
iconColor: 'i-lobe-icons:replicate-color',
name: 'Replicate',
localizedName: 'Replicate',
description: t('settings.pages.providers.categories.artistry.items.replicate.description'),
localizedDescription: t('settings.pages.providers.categories.artistry.items.replicate.description'),
configured: !!artistryStore.replicateApiKey,
to: '/settings/providers/artistry/replicate',
pricing: 'paid',
deployment: 'cloud',
beginnerRecommended: false,
iconImage: undefined,
},
{
id: 'nanobanana',
category: 'artistry',
icon: 'i-solar:gallery-round-bold-duotone',
iconColor: 'text-amber-500',
name: 'Nano Banana',
localizedName: 'Nano Banana',
description: t('settings.pages.providers.categories.artistry.items.nanobanana.description'),
localizedDescription: t('settings.pages.providers.categories.artistry.items.nanobanana.description'),
configured: !!artistryStore.nanobananaApiKey,
to: '/settings/providers/artistry/nanobanana',
pricing: 'free',
deployment: 'cloud',
beginnerRecommended: false,
iconImage: undefined,
},
]
})
const providerBlocksConfig = [
{
id: 'chat',
icon: 'i-solar:chat-square-like-bold-duotone',
title: 'Chat',
description: 'Text generation model providers. e.g. OpenRouter, OpenAI, Ollama.',
title: t('settings.pages.providers.categories.chat.title'),
description: t('settings.pages.providers.categories.chat.description'),
providersRef: allChatProvidersMetadata,
},
{
id: 'speech',
icon: 'i-solar:user-speak-rounded-bold-duotone',
title: 'Speech',
description: 'Speech (text-to-speech) model providers. e.g. ElevenLabs, Azure Speech.',
title: t('settings.pages.providers.categories.speech.title'),
description: t('settings.pages.providers.categories.speech.description'),
providersRef: allAudioSpeechProvidersMetadata,
},
{
id: 'transcription',
icon: 'i-solar:microphone-3-bold-duotone',
title: 'Transcription',
description: 'Transcription (speech-to-text) model providers. e.g. Whisper.cpp, OpenAI, Azure Speech',
title: t('settings.pages.providers.categories.transcription.title'),
description: t('settings.pages.providers.categories.transcription.description'),
providersRef: allAudioTranscriptionProvidersMetadata,
},
{
id: 'artistry',
icon: 'i-solar:palette-bold-duotone',
title: t('settings.pages.providers.categories.artistry.title'),
description: t('settings.pages.providers.categories.artistry.description'),
providersRef: allArtistryProvidersMetadata,
},
]
const providerBlocks = computed(() => {
let globalIndex = 0
return providerBlocksConfig.map(block => ({
id: block.id,
icon: block.icon,
title: block.title,
description: block.description,
providers: block.providersRef.value.map(provider => ({
...provider,
renderIndex: globalIndex++,
})),
}))
const activeTabId = ref(providerBlocksConfig[0].id)
const filterPricing = ref<'all' | 'free' | 'paid'>('all')
const filterDeployment = ref<'all' | 'local' | 'cloud'>('all')
onMounted(() => {
if (route.hash) {
const hashId = route.hash.replace('#', '')
if (providerBlocksConfig.some(b => b.id === hashId)) {
activeTabId.value = hashId
}
}
})
useScrollToHash(() => route.hash, {
auto: true, // automatically react to route hash
offset: 16, // header + margin spacing
behavior: 'smooth', // smooth scroll animation
maxRetries: 15, // retry if target element isn't ready
retryDelay: 150, // wait between retries
scrollContainer: '#settings-scroll-container',
function setActiveTab(id: string) {
activeTabId.value = id
filterPricing.value = 'all'
filterDeployment.value = 'all'
router.replace({ hash: `#${id}` }).catch(() => {})
}
const providerBlocks = computed(() => {
let globalIndex = 0
return providerBlocksConfig
.filter(block => block.id === activeTabId.value)
.map((block) => {
const filteredProviders = block.providersRef.value
.filter((p: any) => {
if (filterPricing.value !== 'all' && p.pricing !== filterPricing.value)
return false
if (filterDeployment.value !== 'all' && p.deployment !== filterDeployment.value)
return false
return true
})
.map(provider => ({
...provider,
renderIndex: globalIndex++,
}))
return {
id: block.id,
icon: block.icon,
title: block.title,
description: block.description,
providers: filteredProviders,
}
})
})
</script>
@@ -84,6 +174,53 @@ useScrollToHash(() => route.hash, {
</div>
</div>
<!-- Tabs Container -->
<div class="flex flex-row flex-wrap gap-2 pb-2">
<button
v-for="block in providerBlocksConfig"
:key="block.id"
class="flex items-center gap-2 rounded-xl px-4 py-2 outline-none transition-colors duration-200"
:class="activeTabId === block.id ? 'bg-primary-500/15 text-primary-700 dark:bg-primary-500/20 dark:text-primary-300 font-semibold' : 'hover:bg-neutral-200/50 dark:hover:bg-neutral-800 text-neutral-500 dark:text-neutral-400'"
@click="setActiveTab(block.id)"
>
<div :class="block.icon" class="text-xl" />
{{ block.title }}
</button>
</div>
<!-- Filters Container -->
<div flex="~ row items-center gap-4 wrap" pb-2 text-xs>
<div flex="~ row items-center gap-2">
<span text="neutral-400 dark:neutral-500" font-medium>{{ $t('settings.pages.providers.filters.pricing') }}:</span>
<div flex="~ row items-center gap-1" bg="neutral-100 dark:neutral-800" rounded-lg p-0.5>
<button
v-for="opt in ['all', 'free', 'paid'] as const"
:key="opt"
rounded-md px-2 py-0.5 transition-all
:class="filterPricing === opt ? 'bg-white dark:bg-neutral-700 shadow-sm text-primary-600 dark:text-primary-400 font-semibold' : 'text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'"
@click="filterPricing = opt"
>
{{ $t(`settings.pages.providers.filters.${opt}`) }}
</button>
</div>
</div>
<div flex="~ row items-center gap-2">
<span text="neutral-400 dark:neutral-500" font-medium>{{ $t('settings.pages.providers.filters.deployment') }}:</span>
<div flex="~ row items-center gap-1" bg="neutral-100 dark:neutral-800" rounded-lg p-0.5>
<button
v-for="opt in ['all', 'local', 'cloud'] as const"
:key="opt"
rounded-md px-2 py-0.5 transition-all
:class="filterDeployment === opt ? 'bg-white dark:bg-neutral-700 shadow-sm text-primary-600 dark:text-primary-400 font-semibold' : 'text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'"
@click="filterDeployment = opt"
>
{{ $t(`settings.pages.providers.filters.${opt}`) }}
</button>
</div>
</div>
</div>
<RippleGrid
:sections="providerBlocks"
:get-items="block => block.providers"
@@ -116,6 +253,9 @@ useScrollToHash(() => route.hash, {
:icon-image="provider.iconImage"
:to="`/settings/providers/${provider.category}/${provider.id}`"
:configured="provider.configured"
:pricing="provider.pricing as any"
:deployment="provider.deployment as any"
:beginner-recommended="provider.beginnerRecommended"
@click="trackProviderClick(provider.id, provider.category)"
/>
</template>
@@ -1,30 +1,212 @@
<script setup lang="ts">
import { Callout } from '@proj-airi/ui'
import { Section } from '@proj-airi/stage-ui/components'
import { useAiriCardStore, useBackgroundStore } from '@proj-airi/stage-ui/stores'
import { Button, Callout } from '@proj-airi/ui'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const backgroundStore = useBackgroundStore()
const cardStore = useAiriCardStore()
const fileInputRef = ref<HTMLInputElement>()
const sceneEntries = computed(() => {
return backgroundStore.availableBackgrounds
.filter(e => e.type === 'scene' || e.type === 'builtin')
})
const activeBackgroundId = computed({
get: () => cardStore.activeCard?.extensions?.airi?.modules?.activeBackgroundId || 'none',
set: (val: string) => {
if (!cardStore.activeCard)
return
const extension = JSON.parse(JSON.stringify(cardStore.activeCard.extensions))
if (!extension.airi.modules)
extension.airi.modules = {}
extension.airi.modules.activeBackgroundId = val
cardStore.updateCard(cardStore.activeCardId, {
...cardStore.activeCard,
extensions: extension,
})
},
})
function triggerUpload() {
fileInputRef.value?.click()
}
async function handleFileChange(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file)
return
await backgroundStore.addBackground('scene', file, file.name)
}
function setAsBackground(id: string) {
activeBackgroundId.value = id
}
function removeBackground(id: string) {
if (confirm(t('settings.pages.scene.gallery.delete_confirm', 'Are you sure you want to delete this background?'))) {
backgroundStore.removeBackground(id)
}
}
function clearDefault() {
activeBackgroundId.value = 'none'
}
</script>
<template>
<div>
<div :class="['flex flex-col gap-6', 'mx-auto max-w-2xl', 'p-4 pb-20']">
<Callout
label="In development, needs your help!"
:label="t('settings.pages.scene.beta_label')"
theme="orange"
icon="i-solar:star-fall-bold-duotone"
>
<div>
This functionality is still under development. If you have any suggestions or would like to contribute, please reach out to us on our <a underline decoration-dotted href="https://github.com/moeru-ai/airi/issues">GitHub issues page</a>.
The source code of this page is located at <a underline decoration-dotted href="https://github.com/moeru-ai/airi/tree/main/apps/stage-web/src/pages/settings/scene/index.vue">here</a>.
{{ t('settings.pages.scene.beta_description') }}
</div>
</Callout>
<Section
:title="t('settings.pages.scene.background_image.title')"
icon="i-solar:gallery-bold-duotone"
:class="['rounded-2xl', 'bg-white/80 dark:bg-black/75', 'backdrop-blur-lg']"
>
<div :class="['flex flex-col gap-4', 'p-4']">
<!-- Upload Controls -->
<div :class="['flex gap-2']">
<input
ref="fileInputRef"
type="file"
accept="image/*"
hidden
@change="handleFileChange"
>
<Button
variant="primary"
class="flex-1"
@click="triggerUpload"
>
<div :class="['i-solar:upload-bold-duotone', 'mr-2']" />
{{ t('settings.pages.scene.background_image.upload') }}
</Button>
<Button
v-if="activeBackgroundId !== 'none'"
variant="secondary"
@click="clearDefault"
>
<div :class="['i-solar:trash-bin-trash-bold-duotone', 'mr-2']" />
{{ t('settings.pages.scene.background_image.clear') }}
</Button>
</div>
<!-- Gallery Grid -->
<div v-if="sceneEntries.length > 0" :class="['grid grid-cols-2 sm:grid-cols-3 gap-3']">
<div
v-for="bg in sceneEntries"
:key="bg.id"
:class="[
'relative aspect-square overflow-hidden rounded-xl border-2 group transition-all',
bg.id === activeBackgroundId ? 'border-primary shadow-lg' : 'border-transparent bg-neutral-100 dark:bg-neutral-900',
]"
>
<!-- Background Image -->
<div
:class="['absolute inset-0 z-0']"
:style="{
backgroundImage: `url(${backgroundStore.getBackgroundUrl(bg.id)})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}"
/>
<div
:class="[
'absolute bottom-0 left-0 right-0 z-1',
'bg-black/60 px-2 py-1.5 text-xs text-white font-medium',
'truncate',
]"
:title="bg.title"
>
{{ bg.title }}
</div>
<!-- Badges -->
<div :class="['absolute top-2 left-2', 'flex flex-col gap-1']">
<div
v-if="bg.id === activeBackgroundId"
:class="['bg-primary text-white text-xs px-1.5 py-0.5 rounded-md shadow-sm font-bold']"
>
{{ t('settings.pages.scene.gallery.active_badge') }}
</div>
</div>
<!-- Hover Overlay -->
<div
:class="[
'absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity',
'flex items-center justify-center gap-2',
]"
>
<Button
v-if="bg.id !== activeBackgroundId"
size="sm"
variant="primary"
@click="setAsBackground(bg.id)"
>
<div :class="['i-solar:check-read-bold-duotone']" />
</Button>
<Button
v-if="bg.type !== 'builtin'"
size="sm"
variant="secondary"
:class="['!bg-red-500 hover:!bg-red-600 !text-white']"
@click="removeBackground(bg.id)"
>
<div :class="['i-solar:trash-bin-trash-bold-duotone']" />
</Button>
</div>
</div>
</div>
<!-- Empty State -->
<div
v-else
:class="['border-2 border-dashed border-neutral-200 dark:border-neutral-800', 'p-12 text-center text-neutral-400 rounded-xl']"
>
<div :class="['i-solar:gallery-wide-bold-duotone', 'mx-auto mb-2 text-4xl opacity-50']" />
<p :class="['text-sm']">
{{ t('settings.pages.scene.gallery.empty') }}
</p>
</div>
</div>
</Section>
<Callout theme="lime" :label="t('settings.pages.scene.tip.label')">
<div v-html="t('settings.pages.scene.tip.description')" />
</Callout>
</div>
<!-- Background Icon Decoration -->
<div
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
:class="[
'text-neutral-200/50 dark:text-neutral-600/20',
'pointer-events-none fixed bottom-0 right--5 z--1',
'size-60 flex items-center justify-center',
]"
:style="{ top: 'calc(100dvh - 15rem)' }"
:initial="{ scale: 0.9, opacity: 0, y: 20 }"
:enter="{ scale: 1, opacity: 1, y: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div text="60" i-solar:armchair-2-bold-duotone />
<div :class="['text-6xl', 'i-solar:armchair-2-bold-duotone']" />
</div>
</template>
+200
View File
@@ -0,0 +1,200 @@
import { defineInvokeEventa } from '@moeru/eventa'
export interface ArtistrySyncPayload {
provider: string
globals: any
// Card-level defaults to ensure widget triggers respect character settings
model?: string
promptPrefix?: string
options?: Record<string, any>
}
export const ARTISTRY_SYNC_CONFIG_ADDRESS = 'eventa:invoke:electron:artistry:sync-config'
export const ARTISTRY_TEST_COMFYUI_CONNECTION_ADDRESS = 'eventa:invoke:electron:artistry:test-comfyui-connection'
export const artistrySyncConfig = defineInvokeEventa<void, ArtistrySyncPayload>(ARTISTRY_SYNC_CONFIG_ADDRESS)
export interface ArtistryTestComfyUIResult {
ok: boolean
info?: string
isCors?: boolean
}
export const artistryTestComfyUIConnection = defineInvokeEventa<ArtistryTestComfyUIResult, { url: string }>(ARTISTRY_TEST_COMFYUI_CONNECTION_ADDRESS)
export const artistryGenerateHeadless = defineInvokeEventa<{ imageUrl?: string, base64?: string, error?: string }, { prompt: string, model?: string, provider?: string, options?: Record<string, any>, globals?: Record<string, any> }>('eventa:invoke:electron:artistry:generate-headless')
export const REPLICATE_IMAGEGEN_PRESETS = [
{
id: 'prunaai/p-image',
label: 'p-image',
cost: '$1 / 200 imgs',
prompt: 'A high-quality anime-style illustration with professional shading, vibrant colors, hand-drawn aesthetic, highly detailed,',
preset: {
aspect_ratio: '16:9',
},
},
{
id: 'prunaai/z-image-turbo',
label: 'z-turbo',
cost: '$1 / 200 imgs',
prompt: 'A highly detailed anime illustration, crisp lines, vibrant color palette, professional digital art style, nicely shaded,',
preset: {
width: 1024,
height: 768,
output_format: 'jpg',
guidance_scale: 0,
output_quality: 80,
num_inference_steps: 8,
},
},
{
id: 'black-forest-labs/flux-schnell',
label: 'flux-schnell',
cost: '$1 / 333 imgs',
prompt: 'A stunning, high-definition anime scene, professional cel-shading, vibrant atmosphere, hand-drawn quality,',
preset: {
go_fast: true,
num_outputs: 1,
aspect_ratio: '1:1',
output_format: 'webp',
output_quality: 80,
},
},
{
id: 'prunaai/z-image-turbo-lora:197b2db2015aa366d2bc61a941758adf4c31ac66b18573f5c66dc388ab081ca2',
label: 'z-turbo-lora',
cost: '$1 / 217 imgs',
prompt: 'A beautifully rendered anime illustration in a classic hand-drawn style, rich textures, vibrant colors, masterpiece quality,',
preset: {
width: 1024,
height: 1024,
lora_scales: [1],
lora_weights: ['https://huggingface.co/renderartist/Technically-Color-Z-Image-Turbo/resolve/main/Technically_Color_Z_Image_Turbo_v1_renderartist_2000.safetensors'],
output_format: 'jpg',
guidance_scale: 0,
output_quality: 80,
num_inference_steps: 8,
},
},
{
id: 'aisha-ai-official/wai-nsfw-illustrious-v11:c1d5b02687df6081c7953c74bcc527858702e8c153c9382012ccc3906752d3ec',
label: 'wai-ilx',
cost: '$1 / 151 imgs',
prompt: 'high quality, masterpiece, hirez, absurdres, anime style, highly detailed, vibrant colors, aesthetic,',
preset: {
vae: 'default',
seed: -1,
model: 'WAI-NSFW-illustrious-SDXL-v11',
steps: 30,
width: 1024,
height: 1024,
cfg_scale: 7,
clip_skip: 2,
pag_scale: 3,
scheduler: 'Euler a',
batch_size: 1,
negative_prompt: 'nsfw, naked',
guidance_rescale: 0.5,
prepend_preprompt: true,
},
},
{
id: 'aisha-ai-official/anillustrious-v4:80441e2c32a55f2fcf9b77fa0a74c6c86ad7deac51eed722b9faedb253265cb4',
label: 'anillustrious',
cost: '$1 / 188 imgs',
prompt: 'high quality, masterpiece, hirez, absurdres, anime style, detailed background, atmospheric, beautifully shaded,',
preset: {
vae: 'default',
seed: -1,
model: 'Anillustrious-v4',
steps: 30,
width: 1024,
height: 1024,
refiner: false,
upscale: 'Original',
cfg_scale: 7,
clip_skip: 2,
pag_scale: 0,
scheduler: 'Euler a beta',
adetailer_face: false,
adetailer_hand: false,
refiner_prompt: '',
negative_prompt: 'nsfw, naked',
adetailer_person: false,
guidance_rescale: 1,
refiner_strength: 0.8,
prepend_preprompt: true,
prompt_conjunction: true,
adetailer_face_prompt: '',
adetailer_hand_prompt: '',
adetailer_person_prompt: '',
negative_prompt_conjunction: false,
adetailer_face_negative_prompt: '',
adetailer_hand_negative_prompt: '',
adetailer_person_negative_prompt: '',
},
},
]
export const REPLICATE_IMAGEEDIT_PRESETS = [
{
id: 'prunaai/p-image-edit',
label: 'P-Image-Edit (Texture Swapper)',
cost: 'Turbo',
prompt: 'The woman\'s dress is changed to black',
preset: {
turbo: true,
images: [{ value: '{{IMAGE}}' }],
aspect_ratio: '1:1',
},
},
]
export const ARTISTRY_PRESET_GROUPS = [
{
id: 'fabrics',
label: 'Fabric Lab',
icon: 'i-solar:palette-bold-duotone',
presets: [
{ id: 'gold', label: 'Gold Leaf', icon: 'i-solar:star-bold-duotone', text: 'Divine Golden transformation. Pure white velvet fabric with thick 24k gold leaf embroidery and glowing white celestial patterns.' },
{ id: 'gothic', label: 'Midnight Gothic', icon: 'i-solar:ghost-bold-duotone', text: 'Midnight Gothic style. Deep matte black fabric, crimson lace ruffles, dark leather straps, silver scrollwork embroidery.' },
{ id: 'royal', label: 'Royal Porcelain', icon: 'i-solar:crown-minimalistic-bold-duotone', text: 'Royal Porcelain style. White silk base, hand-painted cobalt blue patterns, golden silk sashes, jade ornaments.' },
{ id: 'denim', label: 'Raw Indigo Denim', icon: 'i-solar:t-shirt-bold-duotone', text: 'Heavyweight dark indigo denim with thick orange contrast stitching and realistic weathered fading.' },
{ id: 'plaid', label: 'Classic Tartan Plaid', icon: 'i-solar:widget-bold-duotone', text: 'Traditional red and green Scottish wool plaid with a visible woven texture and cozy feel.' },
{ id: 'satin', label: 'Powder Blue Satin', icon: 'i-solar:water-drops-bold-duotone', text: 'Highly reflective, pale baby blue silk with smooth flowing "liquid" highlights and high luster.' },
{ id: 'hex', label: 'Tactical Hex-Grid', icon: 'i-solar:shield-bold-duotone', text: 'Matte olive drab fabric with a subtle hexagonal heat-pressed grid pattern and dark grey utility straps.' },
{ id: 'camo', label: 'Cyber Pink Camo', icon: 'i-solar:skateboarding-bold-duotone', text: 'Vibrant hot pink and charcoal grey urban camouflage with a slight tech-fabric sheen.' },
],
},
{
id: 'hair',
label: 'Hair Salon',
icon: 'i-solar:scissors-bold-duotone',
presets: [
{ id: 'silver', label: 'Iridescent Silver', icon: 'i-solar:snowflake-bold-duotone', text: 'Pure white hair with subtle prismatic "oil-slick" highlights that catch the light.' },
{ id: 'onyx', label: 'Onyx Gloss', icon: 'i-solar:moon-bold-duotone', text: 'Pitch black hair with a high-mirror shine and sharp, high-contrast highlights.' },
{ id: 'sunset', label: 'Sunset Ombre', icon: 'i-solar:sun-2-bold-duotone', text: 'Vibrant gradient from deep copper roots to fiery orange and golden blonde tips.' },
{ id: 'mint', label: 'Ghost Mint', icon: 'i-solar:leaf-bold-duotone', text: 'Soft, matte pastel mint green with a "cloud-like" ethereal texture.' },
{ id: 'pink', label: 'Bubblegum Pop', icon: 'i-solar:heart-bold-duotone', text: 'High-gloss, vibrant candy pink with a plastic-like shine and white "rim" highlights.' },
{ id: 'rainbow', label: 'Retrowave Rainbow', icon: 'i-solar:filters-bold-duotone', text: 'Multi-colored "raver girl" hair; dark roots with glowing neon streaks of cyan, magenta, and lime green.' },
],
},
{
id: 'eyes',
label: 'Iris Forge',
icon: 'i-solar:eye-bold-duotone',
presets: [
{ id: 'dragon', label: 'Dragon Slit', icon: 'i-solar:fire-bold-duotone', text: 'Glowing orange irises with vertical black slit pupils and a subtle reptilian texture.' },
{ id: 'heart', label: 'Succubus Heart', icon: 'i-solar:heart-angle-bold-duotone', text: 'Soft pink irises with glowing white heart-shaped pupils and a "love-struck" aura.' },
{ id: 'star', label: 'Celestial Star', icon: 'i-solar:star-fall-bold-duotone', text: 'Deep violet eyes with white star-shaped pupils and a subtle ring of stardust.' },
{ id: 'galaxy', label: 'Nebula Galaxy', icon: 'i-solar:atom-bold-duotone', text: 'Deep space irises containing tiny sparkling stars and purple nebula clusters.' },
{ id: 'cyber-eye', label: 'Cyber Scan', icon: 'i-solar:scanner-2-bold-duotone', text: 'Glowing cyan HUD-style eyes with digital scanning rings and data-stream pupils.' },
],
},
{
id: 'special',
label: 'Special Motifs',
icon: 'i-solar:magic-stick-bold-duotone',
presets: [
{ id: 'lotus', label: 'Argent Lotus', icon: 'i-solar:flower-bold-duotone', text: 'The Argent Lotus motif. Translucent white silk petal layers over heavy silver brocade, with delicate silver filigree lotus accents.' },
],
},
]
@@ -1,5 +1,6 @@
import type { EventContext } from '@moeru/eventa'
import type { Analyser, AnalyserBeatEvent, AnalyserWorkletParameters } from '@nekopaw/tempora'
import type { SerializableDesktopCapturerSource } from '@proj-airi/electron-screen-capture'
import type { BeatSyncDetectorEventMap, BeatSyncDetectorState } from './types'
@@ -165,7 +166,7 @@ export function createBeatSyncDetector(options: CreateBeatSyncDetectorOptions):
const { selectWithSource } = setupElectronScreenCapture(createContext(window.electron.ipcRenderer).context)
const stream = await selectWithSource(
(sources) => {
(sources: SerializableDesktopCapturerSource[]) => {
if (sources.length === 0)
throw new Error('No screen source available')
return sources[0].id
@@ -179,14 +180,14 @@ export function createBeatSyncDetector(options: CreateBeatSyncDetectorOptions):
const videoTracks = stream.getVideoTracks()
videoTracks.forEach((track) => {
videoTracks.forEach((track: MediaStreamTrack) => {
track.stop()
stream.removeTrack(track)
})
const node = ctx.createMediaStreamSource(stream)
stopSource = () => {
stream.getTracks().forEach(track => track.stop())
stream.getTracks().forEach((track: MediaStreamTrack) => track.stop())
}
return node
+1
View File
@@ -1,3 +1,4 @@
export * from './artistry'
export * from './env-vars'
export * from './environment'
export * from './export-csv'
@@ -64,6 +64,9 @@ defineExpose({
canvasElement: () => {
return live2dCanvasRef.value?.canvasElement()
},
captureFrame: () => {
return live2dCanvasRef.value?.captureFrame()
},
})
</script>
@@ -693,6 +693,17 @@ defineExpose({
renderer: () => tresCanvasRef.value?.renderer.instance,
scene: () => modelRef.value?.scene,
readRenderTargetRegionAtClientPoint,
captureFrame: async () => {
if (!tresCanvasRef.value)
return null
const { renderer, scene } = tresCanvasRef.value
renderer.instance.render(scene.value, camera.value)
return new Promise<Blob | null>((resolve) => {
renderer.instance.domElement.toBlob(resolve)
})
},
})
</script>
+8
View File
@@ -1,3 +1,5 @@
import Info from 'unplugin-info/vite'
import { HstVue } from '@histoire/plugin-vue'
import { defineConfig } from 'histoire'
@@ -71,6 +73,12 @@ export default defineConfig({
],
vite: {
base: '/ui/',
plugins: [
Info(),
],
build: {
target: 'esnext',
},
},
setupFile: {
browser: 'stories/setup.ts',
+1
View File
@@ -64,6 +64,7 @@
"@formkit/auto-animate": "^0.9.0",
"@huggingface/transformers": "^3.8.1",
"@moeru/eventa": "catalog:",
"@moeru/std": "catalog:",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/core": "^2.7.0",
"@opentelemetry/sdk-trace-base": "^2.7.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

@@ -7,6 +7,9 @@ const props = defineProps<{
iconImage?: string
to: string
configured?: boolean
pricing?: 'free' | 'paid' | 'internal'
deployment?: 'local' | 'cloud'
beginnerRecommended?: boolean
}>()
</script>
@@ -43,6 +46,27 @@ const props = defineProps<{
>
<span>{{ props.description || '' }}</span>
</div>
<div v-if="props.pricing || props.deployment || props.beginnerRecommended" mt-2 flex flex-wrap gap-1.5>
<div
v-if="props.beginnerRecommended"
text="[10px] white" rounded-md bg-green-500 px-1.5 py-0.5 font-bold tracking-wider uppercase
>
{{ $t('settings.pages.providers.labels.recommended') }}
</div>
<div
v-if="props.pricing"
text="[10px] neutral-600 dark:neutral-300" border="1 neutral-200 dark:neutral-700" rounded-md px-1.5 py-0.5 font-bold tracking-wider uppercase
>
{{ $t(`settings.pages.providers.filters.${props.pricing}`) }}
</div>
<div
v-if="props.deployment"
text="[10px] neutral-600 dark:neutral-300" border="1 neutral-200 dark:neutral-700" rounded-md px-1.5 py-0.5 font-bold tracking-wider uppercase
>
{{ $t(`settings.pages.providers.filters.${props.deployment}`) }}
</div>
</div>
</div>
<template v-if="props.icon">
<div
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useJournalPreviewStore } from '../../../stores/journal-preview'
import { MarkdownRenderer } from '../../markdown'
const store = useJournalPreviewStore()
const { previewModal } = storeToRefs(store)
const { closePreview, downloadImage } = store
</script>
<template>
<Teleport to="body">
<Transition name="modal-fade">
<div
v-if="previewModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click.self="closePreview"
>
<div
:class="[
'relative mx-4 max-h-[80vh] max-w-md w-full overflow-hidden rounded-2xl',
'bg-white shadow-2xl dark:bg-neutral-900',
'animate-scale-in',
]"
>
<!-- Header -->
<div :class="['flex items-center justify-between border-b border-neutral-200/50 px-4 py-3', 'dark:border-neutral-700/50']">
<div :class="['flex items-center gap-2 text-sm font-bold', 'text-neutral-800 dark:text-neutral-100']">
<div :class="previewModal.type === 'text' ? 'i-solar:notebook-bold-duotone' : 'i-solar:gallery-bold-duotone'" />
<span class="truncate">{{ previewModal.title }}</span>
</div>
<div class="flex items-center gap-1">
<button
v-if="previewModal.type === 'image'"
:class="['rounded-full p-1 text-neutral-400 transition-colors', 'hover:bg-neutral-100 hover:text-neutral-600', 'dark:hover:bg-neutral-800 dark:hover:text-neutral-200']"
title="Download image"
@click="downloadImage(previewModal.content, previewModal.title)"
>
<div i-solar:download-minimalistic-bold-duotone class="text-lg" />
</button>
<button
:class="['rounded-full p-1 text-neutral-400 transition-colors', 'hover:bg-neutral-100 hover:text-neutral-600', 'dark:hover:bg-neutral-800 dark:hover:text-neutral-200']"
@click="closePreview"
>
<div i-solar:close-circle-bold-duotone class="text-lg" />
</button>
</div>
</div>
<!-- Content -->
<div v-if="previewModal.type === 'text'" class="max-h-[60vh] overflow-y-auto px-4 py-3">
<MarkdownRenderer
:content="previewModal.content"
class="max-w-none prose prose-sm dark:prose-invert"
/>
</div>
<div v-else class="flex items-center justify-center p-2">
<img :src="previewModal.content" class="max-h-[60vh] w-auto rounded-lg object-contain">
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.2s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
.animate-scale-in {
animation: scale-in 0.2s ease-out;
}
@keyframes scale-in {
from {
transform: scale(0.95);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
</style>
@@ -1,12 +1,93 @@
<script setup lang="ts">
import { MarkdownRenderer } from '@proj-airi/stage-ui/components'
import { useJournalPreviewStore } from '@proj-airi/stage-ui/stores/journal-preview'
import { Collapsible } from '@proj-airi/ui'
import { computed } from 'vue'
const props = defineProps<{
toolName: string
args: string
state?: 'executing' | 'done' | 'error'
result?: any
}>()
const journalPreviewStore = useJournalPreviewStore()
const { openImagePreview } = journalPreviewStore
interface TextJournalArgs {
action?: string
title?: string
content?: string
}
interface ImageJournalArgs {
action?: string
prompt?: string
title?: string
mode?: 'inline' | 'widget' | 'bg'
}
const parsedArgs = computed<TextJournalArgs | null>(() => {
try {
return JSON.parse(props.args) as TextJournalArgs
}
catch {
return null
}
})
const isTextJournalCreate = computed(() => {
return props.toolName === 'text_journal'
&& parsedArgs.value?.action === 'create'
&& !!parsedArgs.value?.content?.trim()
})
const isImageJournalCreate = computed(() => {
return props.toolName === 'image_journal'
&& parsedArgs.value?.action === 'create'
&& !!(parsedArgs.value as ImageJournalArgs)?.prompt?.trim()
})
const textJournalMarkdown = computed(() => {
if (!isTextJournalCreate.value)
return ''
const title = parsedArgs.value?.title?.trim() || 'Journal Entry'
const content = parsedArgs.value?.content?.trim() || ''
return `# ${title}\n\n${content}`
})
const imageJournalMarkdown = computed(() => {
if (!isImageJournalCreate.value)
return ''
const args = parsedArgs.value as ImageJournalArgs
const title = args?.title?.trim() || 'Untitled Image'
const prompt = args?.prompt?.trim() || ''
const mode = args?.mode || 'inline'
let footer = ''
if (mode === 'bg')
footer = '\n\n> **Scene Shift**: Setting this as the active background...'
else if (mode === 'widget')
footer = '\n\n> **Canvas Created**: Spawning an artistry widget for you...'
else
footer = '\n\n> **Sharing**: Sending a quick sketch to our chat history...'
return `### ${title}\n\n*${prompt}*${footer}`
})
const imageJournalResult = computed(() => {
if (props.toolName !== 'image_journal' || !props.result)
return null
try {
return typeof props.result === 'string' ? JSON.parse(props.result) : props.result
}
catch {
return null
}
})
const formattedArgs = computed(() => {
try {
const parsed = JSON.parse(props.args)
@@ -32,8 +113,26 @@ const formattedArgs = computed(() => {
]"
@click="setVisible(!visible)"
>
<div i-solar:sledgehammer-bold-duotone class="mr-1 inline-block translate-y-1 op-50" />
<div
v-if="state === 'executing'"
i-eos-icons:loading class="mr-1 inline-block translate-y-0.5 op-50"
/>
<div
v-else-if="state === 'error'"
i-ph:warning-circle-duotone class="mr-1 inline-block translate-y-0.5 text-red-500"
/>
<div
v-else-if="state === 'done'"
i-ph:check-circle-duotone class="mr-1 inline-block translate-y-0.5 text-emerald-500"
/>
<div
v-else
i-solar:sledgehammer-bold-duotone class="mr-1 inline-block translate-y-1 op-50"
/>
<code>{{ toolName }}</code>
<span v-if="state === 'error' && result" class="ml-2 text-xs text-red-500 op-80">
({{ result }})
</span>
</button>
</template>
<div
@@ -42,7 +141,41 @@ const formattedArgs = computed(() => {
'bg-neutral-100/80 text-sm text-neutral-800 dark:bg-neutral-900/80 dark:text-neutral-200',
]"
>
<div class="whitespace-pre-wrap break-words font-mono">
<template v-if="isTextJournalCreate">
<div class="mb-2 flex items-center gap-2">
<div class="i-solar:notebook-bookmark-bold-duotone text-base text-emerald-500" />
<div class="rounded-full bg-emerald-500/12 px-2.5 py-1 text-xs text-emerald-700 dark:text-emerald-300">
Saved to long-term memory
</div>
</div>
<MarkdownRenderer :content="textJournalMarkdown" />
</template>
<template v-else-if="isImageJournalCreate">
<div class="mb-2 flex items-center gap-2">
<div :class="[(parsedArgs as ImageJournalArgs)?.mode === 'bg' ? 'i-solar:gallery-wide-bold-duotone text-emerald-500' : 'i-solar:camera-bold-duotone text-violet-500']" class="text-base" />
<div
class="rounded-full px-2.5 py-1 text-xs"
:class="[
(parsedArgs as ImageJournalArgs)?.mode === 'bg'
? 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
: 'bg-violet-500/12 text-violet-700 dark:text-violet-300',
]"
>
{{ (parsedArgs as ImageJournalArgs)?.mode === 'bg' ? 'Updating Scene' : 'Generating image' }}
</div>
</div>
<MarkdownRenderer :content="imageJournalMarkdown" />
<!-- Result Rendering (for inline mode) -->
<div v-if="imageJournalResult?.imageUrl" class="mt-4 overflow-hidden border border-primary-500/20 rounded-xl shadow-lg">
<img
:src="imageJournalResult.imageUrl"
class="w-full cursor-pointer object-contain transition-all active:scale-[0.98] hover:ring-2 hover:ring-primary-500/50"
@click="openImagePreview({ title: (parsedArgs as ImageJournalArgs)?.title || 'Generated Image', url: imageJournalResult.imageUrl })"
>
</div>
</template>
<div v-else class="whitespace-pre-wrap break-words font-mono">
{{ formattedArgs }}
</div>
</div>
@@ -3,3 +3,4 @@ export { default as ChatAssistantItem } from './components/assistant-item.vue'
export { default as ChatErrorItem } from './components/error-item.vue'
export { default as ChatHistory } from './components/history.vue'
export { default as ChatUserItem } from './components/user-item.vue'
export { default as JournalPreviewModal } from './JournalPreviewModal.vue'
@@ -29,6 +29,7 @@ import { initIOTracer } from '../../composables/use-io-tracer'
import { llmInferenceEndToken } from '../../constants'
import { EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '../../constants/emotions'
import { useAudioContext, useSpeakingStore } from '../../stores/audio'
import { useBackgroundStore } from '../../stores/background'
import { useChatOrchestratorStore } from '../../stores/chat'
import { useAiriCardStore } from '../../stores/modules'
import { useSpeechStore } from '../../stores/modules/speech'
@@ -118,6 +119,8 @@ const speechStore = useSpeechStore()
const { ssmlEnabled, activeSpeechProvider, activeSpeechModel, activeSpeechVoice, pitch } = storeToRefs(speechStore)
const activeCardId = computed(() => activeCard.value?.name ?? 'default')
const speechRuntimeStore = useSpeechRuntimeStore()
const backgroundStore = useBackgroundStore()
const { activeBackgroundUrl } = storeToRefs(backgroundStore)
const { currentMotion } = storeToRefs(useLive2d())
@@ -552,6 +555,56 @@ function readRenderTargetRegionAtClientPoint(clientX: number, clientY: number, r
return vrmViewerRef.value?.readRenderTargetRegionAtClientPoint?.(clientX, clientY, radius) ?? null
}
async function captureFrame() {
const charBlob = await (stageModelRenderer.value === 'live2d'
? live2dSceneRef.value?.captureFrame()
: vrmViewerRef.value?.captureFrame())
if (!activeBackgroundUrl.value || !charBlob)
return charBlob
try {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx)
return charBlob
// Load background image
const bgImg = new Image()
bgImg.crossOrigin = 'anonymous'
bgImg.src = activeBackgroundUrl.value
await new Promise((resolve, reject) => {
bgImg.onload = resolve
bgImg.onerror = reject
})
// Load character frame
const charImg = await createImageBitmap(charBlob)
// Match canvas size to the captured frame (respects DPI/Render Scale)
canvas.width = charImg.width
canvas.height = charImg.height
// Draw background with "cover" logic
const scale = Math.max(canvas.width / bgImg.width, canvas.height / bgImg.height)
const w = bgImg.width * scale
const h = bgImg.height * scale
const x = (canvas.width - w) / 2
const y = (canvas.height - h) / 2
ctx.drawImage(bgImg, x, y, w, h)
// Draw character on top
ctx.drawImage(charImg, 0, 0)
return new Promise<Blob | null>(resolve => canvas.toBlob(resolve, 'image/png'))
}
catch (error) {
console.error('[Stage] Failed to composite photo with background:', error)
return charBlob // Fallback to character-only
}
}
onUnmounted(() => {
resetLive2dLipSync()
chatHookCleanups.forEach(dispose => dispose?.())
@@ -560,13 +613,29 @@ onUnmounted(() => {
defineExpose({
canvasElement,
captureFrame,
readRenderTargetRegionAtClientPoint,
})
</script>
<template>
<div relative h-full w-full>
<div h-full w-full>
<!-- Scene Background Layer -->
<div
v-if="activeBackgroundUrl"
:class="[
'absolute left-0 top-0 z-0 h-full w-full',
'transition-opacity duration-500',
]"
:style="{
backgroundImage: `url(${activeBackgroundUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}"
/>
<div relative h-full w-full>
<Live2DScene
v-if="stageModelRenderer === 'live2d' && showStage"
ref="live2dSceneRef"
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n'
import factorioIcon from '../assets/factorio-simple.png'
import { useArtistryStore } from '../stores/modules/artistry'
import { useConsciousnessStore } from '../stores/modules/consciousness'
import { useDiscordStore } from '../stores/modules/discord'
import { useFactorioStore } from '../stores/modules/gaming-factorio'
@@ -39,6 +40,7 @@ export function useModulesList() {
const twitterStore = useTwitterStore()
const minecraftStore = useMinecraftStore()
const factorioStore = useFactorioStore()
const artistryStore = useArtistryStore()
const beatSyncState = ref<BeatSyncDetectorState>()
minecraftStore.initialize()
@@ -80,6 +82,15 @@ export function useModulesList() {
configured: visionStore.configured,
category: 'essential',
},
{
id: 'artistry',
name: t('settings.pages.modules.artistry.title'),
description: t('settings.pages.modules.artistry.description'),
icon: 'i-solar:palette-bold-duotone',
to: '/settings/modules/artistry',
configured: artistryStore.configured,
category: 'essential',
},
{
id: 'memory-short-term',
name: t('settings.pages.modules.memory-short-term.title'),
@@ -0,0 +1,25 @@
export const DEFAULT_ARTISTRY_WIDGET_INSTRUCTION = `## Instruction: Widget Spawning (Image Generation)
You have the ability to spawn visual widgets on screen. You can create pictures by using the **artistry** widget system.
### How to Use
**Step 1: Spawn a canvas (do this once)**
Include a tool call to spawn a widget. Pick any unique ID you like and remember it.
- Component name: \`artistry\`
- Size: \`m\` (or \`l\` for bigger)
- Give it an ID like \`my-art-01\`
**Step 2: Generate an image**
Update your widget with a \`prompt\` and set \`status\` to \`"generating"\`:
- id: the same ID you picked in Step 1
- \`componentProps\`: \`{ "status": "generating", "prompt": "your image description here" }\`
The system will automatically generate the image and display it in the overlay. You will see progress updates and the final image will appear when done. The status will change to \`"done"\` automatically.
**Step 3: Generate another image (optional)**
To make a new image on the same canvas, just update it again with a new prompt and \`status: "generating"\`. You do not need to spawn a new widget.
### Rules
- Always use \`"artistry"\` as the component name
- Always include a descriptive \`prompt\` when generating
- Always set \`status\` to \`"generating"\` to trigger generation
- You can have multiple canvases by using different IDs
- Canvases stay on screen until removed — you do not need to re-spawn them`
@@ -0,0 +1,29 @@
export const DEFAULT_ARTISTRY_WIDGET_SPAWNING_PROMPT = `## Instruction: Widget Spawning (Legacy/Manual)
You have the ability to spawn visual widgets on screen using the **artistry** system.
### How to Use
**Step 1: Spawn a canvas**
- Component name: \`artistry\`
- Size: \`m\` (or \`l\`)
- ID: \`my-art-01\`
**Step 2: Generate**
Update the widget with \`status: "generating"\` and a \`prompt\`.
> [!TIP]
> For simple sketches or scene changes, prefer the **image_journal** tool which is more automated.
`
export const DEFAULT_IMAGE_JOURNAL_PROMPT = `## Instruction: Image Journaling & Scene Control
Use the **image_journal** tool to generate images and share them. You must choose a **mode** to determine where the image appears.
### Available Modes
- **inline**: Renders the image directly in our chat history. Perfect for sharing a "selfie", a sketch, or a visual reaction.
- **widget**: Spawns an interactive canvas over the UI. Good for detailed "creations" you want the user to keep on screen.
- **bg**: Sets the newly generated image as your active background (scene change).
### How to Use
- **Action**: Always use \`"create"\`.
- **Prompt**: A detailed description of the image.
- **Mode**: Choose \`"inline"\`, \`"widget"\`, or \`"bg"\` based on your intent.
`
@@ -27,4 +27,16 @@ export const chatSessionsRepo = {
async deleteSession(sessionId: string) {
await storage.removeItem(`local:chat/sessions/${sessionId}`)
},
async clear(userId: string) {
const index = await this.getIndex(userId)
if (index) {
for (const charIndex of Object.values(index.characters)) {
for (const sessionId of Object.keys(charIndex.sessions)) {
await this.deleteSession(sessionId)
}
}
await storage.removeItem(`local:chat/index/${userId}`)
}
},
}
+362
View File
@@ -0,0 +1,362 @@
import localforage from 'localforage'
import { useBroadcastChannel } from '@vueuse/core'
import { nanoid } from 'nanoid'
import { defineStore } from 'pinia'
import { computed, onScopeDispose, reactive, ref, watch } from 'vue'
import cozyTeaCornerInPastelHuesUrl from '../assets/backgrounds/cozy-tea-corner-in-pastel-hues.png'
import cuteStreamingRoomWithPastelDecorUrl from '../assets/backgrounds/cute-streaming-room-with-pastel-decor.png'
import { useAiriCardStore } from './modules/airi-card'
export interface BackgroundEntry {
id: string
type: 'builtin' | 'scene' | 'journal' | 'selfie'
characterId: string | null // null for shared
title: string
blob: Blob
url?: string
prompt?: string // only for journal
remixId?: string // only for ComfyUI journal entries
createdAt: number
}
const BUILTIN_BACKGROUNDS = [
{
id: 'builtin:cozy-tea-corner',
url: cozyTeaCornerInPastelHuesUrl,
title: 'Cozy tea corner in pastel hues',
},
{
id: 'builtin:cute-streaming-room',
url: cuteStreamingRoomWithPastelDecorUrl,
title: 'Cute streaming room with pastel decor',
},
]
export const useBackgroundStore = defineStore('background', () => {
const STORAGE_PREFIX = 'bg-'
const entries = ref<Map<string, BackgroundEntry>>(new Map())
const loading = ref(true)
// Track object URLs to prevent leaks
const blobRefs = new Map<string, any>()
const backgroundUrls = reactive<Record<string, string | null>>({})
function ensureObjectUrl(id: string, blob: Blob) {
if (backgroundUrls[id])
return backgroundUrls[id]
try {
const url = URL.createObjectURL(blob)
backgroundUrls[id] = url
console.log(`[BackgroundStore] Created ObjectURL for ${id}`)
return url
}
catch (e) {
console.error(`[BackgroundStore] Failed to create ObjectURL for ${id}`, e)
return null
}
}
onScopeDispose(() => {
Object.values(backgroundUrls).forEach((url) => {
if (url)
URL.revokeObjectURL(url)
})
for (const key in backgroundUrls) {
delete backgroundUrls[key]
}
})
// Helper to fetch an asset as a blob
async function fetchAssetAsBlob(url: string): Promise<Blob> {
const res = await fetch(url)
return await res.blob()
}
async function initializeStore() {
if (loading.value && entries.value.size > 0)
return // Already initializing
console.log('[BackgroundStore] Initializing store...')
loading.value = true
try {
const loadedEntries = new Map<string, BackgroundEntry>()
// 1. Read existing backgrounds from IndexedDB
await localforage.iterate<BackgroundEntry, void>((val, key) => {
if (key.startsWith(STORAGE_PREFIX) || key.startsWith('builtin:')) {
const entry = { ...val, id: key }
if (entry.blob instanceof Blob) {
ensureObjectUrl(key, entry.blob)
}
loadedEntries.set(key, entry)
}
})
// 2. Migration: check for legacy image-journal entries
const legacyPrefix = 'image-journal-'
const legacyEntriesToMigrate: BackgroundEntry[] = []
const legacyKeysToDelete: string[] = []
await localforage.iterate<any, void>((val, key) => {
if (key.startsWith(legacyPrefix)) {
legacyKeysToDelete.push(key)
const newId = key.replace(legacyPrefix, STORAGE_PREFIX)
if (!loadedEntries.has(newId)) {
const migrated: BackgroundEntry = {
id: newId,
type: 'journal',
characterId: val.characterId,
title: val.title || 'Migrated Journal Image',
blob: val.blob,
prompt: val.prompt,
createdAt: val.createdAt || Date.now(),
}
if (migrated.blob instanceof Blob) {
ensureObjectUrl(newId, migrated.blob)
}
legacyEntriesToMigrate.push(migrated)
loadedEntries.set(newId, migrated)
}
}
})
for (const entry of legacyEntriesToMigrate) {
await localforage.setItem(entry.id, entry)
}
for (const key of legacyKeysToDelete) {
await localforage.removeItem(key)
}
// 3. Seeding logic for defaults
const hasAnyScenesOrBuiltins = Array.from(loadedEntries.values()).some((e) => {
return e.type === 'scene' || e.type === 'builtin'
})
if (!hasAnyScenesOrBuiltins) {
for (const builtin of BUILTIN_BACKGROUNDS) {
try {
const blob = await fetchAssetAsBlob(builtin.url)
const entry: BackgroundEntry = {
id: builtin.id,
type: 'builtin',
characterId: null,
title: builtin.title,
blob,
createdAt: Date.now(),
}
ensureObjectUrl(entry.id, blob)
await localforage.setItem(entry.id, entry)
loadedEntries.set(entry.id, entry)
}
catch (e) {
console.error('[BackgroundStore] Failed to seed builtin:', builtin.id, e)
}
}
}
entries.value = loadedEntries
// Reconciliation: Purge stale URLs from the reactive map and revoke them to prevent leaks
Object.keys(backgroundUrls).forEach((id) => {
if (!loadedEntries.has(id)) {
const url = backgroundUrls[id]
if (url) {
URL.revokeObjectURL(url)
console.log(`[BackgroundStore] Revoked stale ObjectURL for ${id}`)
}
delete backgroundUrls[id]
}
})
console.log(`[BackgroundStore] Store initialized with ${loadedEntries.size} entries.`)
}
catch (error) {
console.error('[BackgroundStore] Initialization failed:', error)
}
finally {
loading.value = false
}
}
// Cross-window synchronization
const { data: syncSignal, post: broadcastSync } = useBroadcastChannel({ name: 'airi:background-sync' })
watch(syncSignal, (val) => {
console.log(`[BackgroundStore] Received sync signal (${val}), re-initializing...`)
initializeStore()
})
async function sync() {
const timestamp = Date.now()
console.log(`[BackgroundStore] Sending sync signal: ${timestamp}`)
broadcastSync(timestamp)
}
// Auto-init once
initializeStore()
// Find the active background URL for the current character
const activeBackgroundUrl = computed(() => {
const airiCardStore = useAiriCardStore()
if (!airiCardStore.activeCard)
return null
const bgId = airiCardStore.activeCard.extensions?.airi?.modules?.activeBackgroundId
if (!bgId || bgId === 'none') {
console.log('[BackgroundStore] activeBackgroundUrl: No ID or "none"')
return null
}
// Normalize prefix just in case they stored 'image-journal-xyz'
let lookupId = bgId
if (bgId.startsWith('image-journal-')) {
lookupId = bgId.replace('image-journal-', STORAGE_PREFIX)
}
// Return the reactive URL from our map if it exists and the entry is still valid
const entryExists = entries.value.has(lookupId)
const url = backgroundUrls[lookupId] ?? null
// NOTICE: We gate the return on entry existence to ensure deleted backgrounds
// (removed from other windows) do not keep rendering via a stale cached URL.
if (url && entryExists) {
console.log(`[BackgroundStore] activeBackgroundUrl resolved for "${lookupId}" (from URL map)`)
return url
}
const entry = entries.value.get(lookupId)
if (!entry) {
console.warn(`[BackgroundStore] activeBackgroundUrl: No entry or URL found for ID "${lookupId}"`)
return null
}
return null // Should have been caught by backgroundUrls check above if entry is valid
})
// List of available backgrounds for the current character
const availableBackgrounds = computed(() => {
const airiCardStore = useAiriCardStore()
return getCharacterBackgrounds.value(airiCardStore.activeCardId)
})
const getCharacterBackgrounds = computed(() => (characterId?: string) => {
const list = Array.from(entries.value.values()).filter((e) => {
// Shared (builtin/scene) or Journal/Selfie for specific character
return e.type === 'scene' || e.type === 'builtin' || ((e.type === 'journal' || e.type === 'selfie') && characterId && e.characterId === characterId)
})
return list.map(e => ({
...e,
url: backgroundUrls[e.id] ?? null,
})).sort((a, b) => b.createdAt - a.createdAt)
})
// The 'journal' store functionality needs to access just the journal entries for the active char
const journalEntries = computed(() => {
const airiCardStore = useAiriCardStore()
return getCharacterJournalEntries.value(airiCardStore.activeCardId)
})
const getCharacterJournalEntries = computed(() => (characterId?: string) => {
return Array.from(entries.value.values()).filter((e) => {
return (e.type === 'journal' || e.type === 'selfie') && characterId && e.characterId === characterId
}).map(e => ({
...e,
url: backgroundUrls[e.id] ?? null,
})).sort((a, b) => b.createdAt - a.createdAt)
})
async function addBackground(
type: 'scene' | 'journal' | 'selfie',
blob: Blob,
title: string,
prompt?: string,
characterId?: string | null,
remixId?: string,
) {
const airiCardStore = useAiriCardStore()
const id = `${STORAGE_PREFIX}${nanoid()}`
// Default to active card if journal and no charId provided
const resolvedCharacterId = characterId !== undefined
? characterId
: ((type === 'journal' || type === 'selfie') ? airiCardStore.activeCardId : null)
const entry: BackgroundEntry = {
id,
type,
characterId: resolvedCharacterId,
title: title.trim() || 'Untitled Background',
blob,
prompt,
remixId,
createdAt: Date.now(),
}
try {
await localforage.setItem(id, entry)
ensureObjectUrl(id, blob)
const nextEntries = new Map(entries.value)
nextEntries.set(id, entry)
entries.value = nextEntries
initializeStore()
await sync()
console.log(`[BackgroundStore] Successfully added background: ${id} (${type})`)
return id
}
catch (error) {
console.error('[BackgroundStore] Failed to save entry:', error)
throw error
}
}
async function removeBackground(id: string) {
try {
await localforage.removeItem(id)
const nextEntries = new Map(entries.value)
nextEntries.delete(id)
entries.value = nextEntries
const blobRef = blobRefs.get(id)
if (blobRef)
blobRef.value = undefined
blobRefs.delete(id)
const url = backgroundUrls[id]
if (url) {
URL.revokeObjectURL(url)
console.log(`[BackgroundStore] Revoked ObjectURL for ${id}`)
}
delete backgroundUrls[id]
broadcastSync(Date.now())
}
catch (error) {
console.error('[BackgroundStore] Failed to remove entry:', error)
throw error
}
}
const journalRecentEntries = computed(() => {
return journalEntries.value.slice(0, 5)
})
return {
entries,
loading,
availableBackgrounds,
getCharacterBackgrounds,
journalEntries,
getCharacterJournalEntries,
activeBackgroundUrl,
journalRecentEntries,
addBackground,
removeBackground,
getBackgroundUrl: (id: string) => backgroundUrls[id] ?? null,
initializeStore,
}
})
+20
View File
@@ -23,6 +23,8 @@ import { useChatSessionStore } from './chat/session-store'
import { useChatStreamStore } from './chat/stream-store'
import { useContextObservabilityStore } from './devtools/context-observability'
import { useLLM } from './llm'
import { useAiriCardStore } from './modules/airi-card'
import { useAutonomousArtistryStore } from './modules/artistry-autonomous'
import { useConsciousnessStore } from './modules/consciousness'
function cloneStreamingMessage(message: StreamingAssistantMessage): StreamingAssistantMessage {
@@ -74,12 +76,14 @@ export interface QueuedSendSnapshot {
export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
const llmStore = useLLM()
const consciousnessStore = useConsciousnessStore()
const artistryAutonomousStore = useAutonomousArtistryStore()
const { activeProvider } = storeToRefs(consciousnessStore)
const { trackFirstMessage } = useAnalytics()
const chatSession = useChatSessionStore()
const chatStream = useChatStreamStore()
const chatContext = useChatContextStore()
const cardStore = useAiriCardStore()
const contextObservability = useContextObservabilityStore()
const { activeSessionId } = storeToRefs(chatSession)
const { streamingMessage } = storeToRefs(chatStream)
@@ -218,6 +222,15 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
})
const sessionMessagesForSend = chatSession.getSessionMessages(sessionId)
// --------------------------------
// Cinematic Autonomy (Autonomous Artist)
// Trigger now only if in user-centric mode. Assistant-centric runs after response is complete.
const autonomousTarget = cardStore.activeCard?.extensions?.airi?.modules?.artistry?.autonomousTarget || 'user'
if (autonomousTarget === 'user') {
void artistryAutonomousStore.runArtistTask(sendingMessage, sessionMessagesForSend as any)
}
// --------------------------------
const categorizer = createStreamingCategorizer(activeProvider.value)
let streamPosition = 0
@@ -449,6 +462,13 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
toolCalls: sessionMessagesForSend.filter(msg => msg.role === 'tool') as ToolMessage[],
}, streamingMessageContext)
// --- AUTONOMOUS ARTISTRY HOOK (ASSISTANT-CENTRIC) ---
const artistry = cardStore.activeCard?.extensions?.airi?.modules?.artistry
if (artistry?.autonomousEnabled && artistry?.autonomousTarget === 'assistant') {
void artistryAutonomousStore.runArtistTask(fullText, sessionMessagesForSend as any)
}
// ---------------------------------------------------
if (isForegroundSession()) {
streamingMessage.value = { role: 'assistant', content: '', slices: [], tool_results: [] }
}
+8
View File
@@ -0,0 +1,8 @@
export * from './background'
export * from './display-models'
export * from './modules/airi-card'
export * from './modules/artistry'
export * from './modules/consciousness'
export * from './modules/speech'
export * from './providers'
export * from './settings'
@@ -0,0 +1,47 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface PreviewModalState {
type: 'text' | 'image'
title: string
content: string // text content or image URL
}
export const useJournalPreviewStore = defineStore('journal-preview', () => {
const previewModal = ref<PreviewModalState | null>(null)
function openTextPreview(entry: { title: string, content: string }) {
previewModal.value = { type: 'text', title: entry.title, content: entry.content }
}
function openImagePreview(entry: { title: string, url: string | null }) {
if (!entry.url)
return
previewModal.value = { type: 'image', title: entry.title, content: entry.url }
}
function closePreview() {
previewModal.value = null
}
function downloadImage(url: string, title?: string) {
if (!url)
return
const link = document.createElement('a')
link.href = url
// Sanitizing the filename for OS compatibility
const safeTitle = (title || 'Image').replace(/[<>:"/\\|?*]/g, '_')
link.download = `AIRI-Journal-${safeTitle}.png`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
return {
previewModal,
openTextPreview,
openImagePreview,
closePreview,
downloadImage,
}
})
@@ -430,7 +430,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
await chatOrchestrator.emitBeforeSendHooks(event.message, event.context)
remoteStreamGuard = {
sessionId: chatSession.activeSessionId,
generation: chatSession.getSessionGenerationValue(),
generation: chatSession.getSessionGenerationValue(chatSession.activeSessionId),
}
chatOrchestrator.sending = true
chatStream.beginStream()
@@ -1,14 +1,17 @@
import type { Card, ccv3 } from '@proj-airi/ccc'
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
import { watchDebounced } from '@vueuse/core'
import { nanoid } from 'nanoid'
import { defineStore, storeToRefs } from 'pinia'
import { computed, watch } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SystemPromptV2 from '../../constants/prompts/system-v2'
import { DEFAULT_ARTISTRY_WIDGET_SPAWNING_PROMPT } from '../../constants/prompts/character-defaults'
import { useSettingsStageModel } from '../settings/stage-model'
import { useArtistryStore } from './artistry'
import { useConsciousnessStore } from './consciousness'
import { useSpeechStore } from './speech'
@@ -44,6 +47,21 @@ export interface AiriExtension {
// ID from display-models store (e.g. 'preset-live2d-1', 'display-model-<nanoid>')
displayModelId?: string
activeBackgroundId?: string
artistry?: {
enabled?: boolean
provider?: string
model?: string
promptPrefix?: string
workflowId?: string
widgetInstruction?: string
spawnMode?: 'bg' | 'widget' | 'inline' | 'bg_widget'
options?: Record<string, any>
autonomousEnabled?: boolean
autonomousThreshold?: number
autonomousTarget?: 'user' | 'assistant'
}
}
agents: {
@@ -70,6 +88,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
const consciousnessStore = useConsciousnessStore()
const speechStore = useSpeechStore()
const artistryStore = useArtistryStore()
const stageModelStore = useSettingsStageModel()
const {
@@ -129,7 +148,19 @@ export const useAiriCardStore = defineStore('airi-card', () => {
voice_id: activeSpeechVoiceId.value,
},
displayModelId: stageModelStore.stageModelSelected,
}
artistry: {
enabled: false,
provider: artistryStore.globalProvider,
model: artistryStore.globalModel,
promptPrefix: artistryStore.globalPromptPrefix,
widgetInstruction: DEFAULT_ARTISTRY_WIDGET_SPAWNING_PROMPT,
spawnMode: 'bg_widget' as const,
options: artistryStore.globalProviderOptions,
autonomousEnabled: false,
autonomousThreshold: 70,
autonomousTarget: 'assistant' as const,
},
} as const
// Return default if no extension exists
if (!existingExtension) {
@@ -158,6 +189,20 @@ export const useAiriCardStore = defineStore('airi-card', () => {
vrm: existingExtension.modules?.vrm,
live2d: existingExtension.modules?.live2d,
displayModelId: existingExtension.modules?.displayModelId ?? defaultModules.displayModelId,
activeBackgroundId: existingExtension.modules?.activeBackgroundId,
artistry: {
enabled: existingExtension.modules?.artistry?.enabled ?? (existingExtension as any).artistry?.enabled ?? defaultModules.artistry.enabled,
provider: existingExtension.modules?.artistry?.provider ?? (existingExtension as any).artistry?.provider ?? defaultModules.artistry.provider,
model: existingExtension.modules?.artistry?.model ?? (existingExtension as any).artistry?.model ?? defaultModules.artistry.model,
promptPrefix: existingExtension.modules?.artistry?.promptPrefix ?? (existingExtension as any).artistry?.promptPrefix ?? (existingExtension as any).artistry?.prompt_prefix ?? defaultModules.artistry.promptPrefix,
workflowId: existingExtension.modules?.artistry?.workflowId ?? (existingExtension as any).artistry?.workflowId ?? (existingExtension as any).artistry?.remixId,
widgetInstruction: existingExtension.modules?.artistry?.widgetInstruction ?? (existingExtension as any).artistry?.widgetInstruction ?? defaultModules.artistry.widgetInstruction,
spawnMode: existingExtension.modules?.artistry?.spawnMode ?? (existingExtension as any).artistry?.spawnMode ?? defaultModules.artistry.spawnMode,
options: existingExtension.modules?.artistry?.options ?? (existingExtension as any).artistry?.options ?? defaultModules.artistry.options,
autonomousEnabled: existingExtension.modules?.artistry?.autonomousEnabled ?? (existingExtension as any).artistry?.autonomousEnabled ?? defaultModules.artistry.autonomousEnabled,
autonomousThreshold: existingExtension.modules?.artistry?.autonomousThreshold ?? (existingExtension as any).artistry?.autonomousThreshold ?? defaultModules.artistry.autonomousThreshold,
autonomousTarget: existingExtension.modules?.artistry?.autonomousTarget ?? (existingExtension as any).artistry?.autonomousTarget ?? defaultModules.artistry.autonomousTarget,
},
},
agents: existingExtension.agents ?? {},
}
@@ -226,7 +271,9 @@ export const useAiriCardStore = defineStore('airi-card', () => {
activeCardId.value = 'default'
}
watch(activeCard, (newCard: AiriCard | undefined) => {
watchDebounced(activeCard, (newCard: AiriCard | undefined) => {
artistryStore.resetToGlobal()
if (!newCard)
return
@@ -248,7 +295,18 @@ export const useAiriCardStore = defineStore('airi-card', () => {
if (extension.modules?.displayModelId) {
stageModelStore.stageModelSelected = extension.modules.displayModelId
}
})
if (extension.modules?.artistry) {
if (extension.modules.artistry.provider)
artistryStore.activeProvider = extension.modules.artistry.provider
if (extension.modules.artistry.model)
artistryStore.activeModel = extension.modules.artistry.model
if (extension.modules.artistry.promptPrefix)
artistryStore.defaultPromptPrefix = extension.modules.artistry.promptPrefix
if (extension.modules.artistry.options)
artistryStore.providerOptions = extension.modules.artistry.options
}
}, { debounce: 300, maxWait: 1000 })
function resetState() {
activeCardId.reset()
@@ -278,6 +336,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
voice_id: activeSpeechVoiceId.value,
},
displayModelId: stageModelStore.stageModelSelected,
activeBackgroundId: activeCard.value?.extensions?.airi?.modules?.activeBackgroundId,
} satisfies AiriExtension['modules']
}),
@@ -290,9 +349,10 @@ export const useAiriCardStore = defineStore('airi-card', () => {
card.systemPrompt,
card.description,
card.personality,
card.extensions?.airi?.modules?.artistry?.widgetInstruction,
].filter(Boolean)
return components.join('\n')
return components.join('\n\n')
}),
}
})
@@ -0,0 +1,369 @@
import type { Message } from '@xsai/shared-chat'
import { defineInvoke, defineInvokeEventa } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/renderer'
import { artistryGenerateHeadless } from '@proj-airi/stage-shared'
import { generateText } from '@xsai/generate-text'
import { defineStore } from 'pinia'
import { ref, toRaw } from 'vue'
import { toast } from 'vue-sonner'
import { useBackgroundStore } from '../background'
import { useChatSessionStore } from '../chat/session-store'
import { useProvidersStore } from '../providers'
import { useAiriCardStore } from './airi-card'
import { useArtistryStore } from './artistry'
import { useConsciousnessStore } from './consciousness'
const artistLog = import.meta.env.DEV ? console.info.bind(console, '[AutonomousArtist]') : () => {}
export const useAutonomousArtistryStore = defineStore('artistry-autonomous', () => {
const cardStore = useAiriCardStore()
const backgroundStore = useBackgroundStore()
const artistryStore = useArtistryStore()
const consciousnessStore = useConsciousnessStore()
const providersStore = useProvidersStore()
const chatSessionStore = useChatSessionStore()
const isProcessing = ref(false)
/**
* Safe IPC Invoker for headless generation
*/
const widgetsAdd = defineInvokeEventa<string | undefined, any>('eventa:invoke:electron:windows:widgets:add')
const getGenerateHeadless = () => {
const win = window as any
if (typeof window !== 'undefined' && win.electron?.ipcRenderer) {
const { context } = createContext(win.electron.ipcRenderer as any)
return {
generate: defineInvoke(context, artistryGenerateHeadless),
addWidget: defineInvoke(context, widgetsAdd),
}
}
return null
}
/**
* Analyzes the context in parallel and triggers a visual if threshold is met.
*/
async function runArtistTask(inputText: string, history: Message[] = [], targetOverride?: 'user' | 'assistant') {
if (isProcessing.value) {
artistLog('Skipping task: Already processing another task.')
return
}
const { activeCard } = cardStore
const artistry = activeCard?.extensions?.airi?.modules?.artistry
const autonomousEnabled = artistry?.autonomousEnabled ?? false
const target = targetOverride || artistry?.autonomousTarget || 'user'
artistLog('Triggered runArtistTask. State:', {
cardId: cardStore.activeCardId,
cardName: activeCard?.name,
autonomousEnabled,
target,
})
if (!activeCard || !artistry || !autonomousEnabled) {
return
}
const threshold = artistry.autonomousThreshold ?? 70
const cardId = cardStore.activeCardId
isProcessing.value = true
artistLog('Starting analysis task...', { threshold, cardId, target })
try {
// 0. Guard: If the text is empty, skip analysis (Director cannot analyze silence)
if (!inputText || inputText.trim() === '') {
artistLog('Skipping analysis: Input text is empty.')
return
}
// 1. Compose the "Director" prompt based on target
const systemPrompt = target === 'assistant'
? `You are the Cinematic Director for AIRI.
Your job is to analyze the character's response and reaction to the user, and decide if it warrants a visual manifestation (a generative image).
Manifestation is warranted for:
- Descriptions of beautiful scenery or environment changes in the response
- Expressive emotional reactions or body language from the character
- Direct mentions of food, items, or gifts in the narrative
- Narrative actions that would look stunning as a manga/anime scene
- Changes in the character's clothing or appearance
Character Personality: ${activeCard.personality}
Output EXACTLY this JSON format and nothing else:
{
"reasoning": "Quick explanation of why this reaction warrants/doesn't warrant a visual",
"intensity": 0-100,
"prompt": "Highly detailed, illustrative prompt for the image generator capturing the character's reaction and scene. Use Mori's style (masterpiece, high quality, manga style, intricate details)",
"title": "Short descriptive title for the scene"
}`
: `You are the Cinematic Director for AIRI.
Your job is to analyze the user's input and decide if it warrants a visual manifestation (a generative image).
Manifestation is warranted for:
- Descriptions of beautiful scenery or environment changes
- Direct mentions of food, items, or gifts
- Narrative actions that would look stunning as a manga/anime scene
- Changes in the character's clothing or appearance
Character Personality: ${activeCard.personality}
Output EXACTLY this JSON format and nothing else:
{
"reasoning": "Quick explanation of why this warrants/doesn't warrant a visual",
"intensity": 0-100,
"prompt": "Highly detailed, illustrative prompt for the image generator. Use Mori's style (masterpiece, high quality, manga style, intricate details)",
"title": "Short descriptive title for the scene"
}`
// 2. Rollup history and text into a single prompt to help the LLM "see" the full context
const recentHistory = history.slice(-3)
const historyText = recentHistory.map(m => `[${m.role === 'assistant' ? 'Companion' : 'User'}]: ${m.content}`).join('\n\n')
const analysisPrompt = `Consider the recent history between the user and the character for context and inspiration, then analyze the latest ${target === 'assistant' ? 'response from the companion' : 'input from the user'} to decide if a visual manifestation is needed.
---
CONTEXT HISTORY:
${historyText || '(No previous history)'}
---
LATEST ${target === 'assistant' ? 'COMPANION RESPONSE' : 'USER INPUT'}:
"${inputText}"`
const messages: Message[] = [
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: analysisPrompt,
},
]
const modelId = consciousnessStore.activeModel
const providerId = consciousnessStore.activeProvider
artistLog('Sending rolled-up prompt to Director LLM...', {
model: modelId,
provider: providerId,
historyCount: recentHistory.length,
textSubstring: inputText.substring(0, 50),
target,
})
if (!modelId || !providerId) {
throw new Error(`Missing LLM configuration (Model: ${modelId}, Provider: ${providerId})`)
}
const chatProvider = await providersStore.getProviderInstance(providerId) as any
if (!chatProvider) {
throw new Error(`Failed to resolve chat provider instance for: ${providerId}`)
}
// NOTICE: Artificial 10s delay for USER target to avoid race conditions/429s.
// Skipped for ASSISTANT target as the main response is already finalized.
if (target === 'user') {
artistLog('User target detected. Applying 10s safety delay...')
await new Promise(resolve => setTimeout(resolve, 10000))
}
// 2. Call LLM (Non-streaming for structured data)
const chatConfig = chatProvider.chat(modelId)
const response = await generateText({
...chatConfig,
messages,
headers: { 'Accept-Encoding': 'identity' },
})
const rawContent = (response.text || '').trim()
artistLog('Received raw response from Director LLM:', rawContent)
// 3. Parse and analyze
// Handle potential markdown fences: ```json ... ```
let jsonContent = rawContent
const fenceMatch = rawContent.match(/```(?:json)?\s*([\s\S]*?)```/)
if (fenceMatch) {
jsonContent = fenceMatch[1].trim()
artistLog('Extracted JSON from fences:', jsonContent)
}
if (!jsonContent) {
throw new Error('LLM returned empty content')
}
const analysis = JSON.parse(jsonContent)
artistLog('Parsed Analysis Result:', {
intensity: analysis.intensity,
reasoning: analysis.reasoning,
title: analysis.title,
prompt: analysis.prompt,
})
const thresholdMet = (analysis.intensity ?? 0) >= threshold
toast('Director\'s Decision', {
description: `${thresholdMet ? '✅' : '❌'} Grade: ${analysis.intensity}/${threshold}\nReason: ${analysis.reasoning?.substring(0, 130)}${analysis.reasoning?.length > 130 ? '...' : ''}`,
duration: 7000,
})
// 3. Evaluate Threshold
if (analysis.intensity >= threshold) {
artistLog(`Threshold met (${analysis.intensity} >= ${threshold}). Triggering generation...`)
const invoker = getGenerateHeadless()
if (!invoker) {
artistLog('IPC Invoker not available (non-electron environment). Skipping generation.')
return
}
const artistryGlobals = artistryStore.artistryGlobals
const generationPayload = {
prompt: artistry.promptPrefix ? `${artistry.promptPrefix} ${analysis.prompt}` : analysis.prompt,
model: artistry.model || artistryStore.activeModel,
provider: artistry.provider || artistryStore.activeProvider,
options: artistry.options || artistryStore.providerOptions,
globals: artistryGlobals,
}
artistLog('Triggering Headless Generation with payload:', generationPayload)
const invokers = getGenerateHeadless()
if (!invokers) {
throw new Error('IPC invokers not available')
}
// Safety: ensure payload is a plain object for IPC serialization
const plainPayload = JSON.parse(JSON.stringify(toRaw(generationPayload)))
const result = await invokers.generate(plainPayload)
if (result.error) {
throw new Error(result.error)
}
artistLog('Headless Generation Success!', { hasUrl: !!result.imageUrl, hasBase64: !!result.base64 })
// 4. Save to journal
if (result.base64 || result.imageUrl) {
let blob: Blob
if (result.base64) {
const response = await fetch(result.base64)
blob = await response.blob()
}
else {
const response = await fetch(result.imageUrl!)
blob = await response.blob()
}
const entryId = await backgroundStore.addBackground('journal', blob, analysis.title || 'Autonomous Scene', analysis.prompt, cardId)
artistLog('Generation complete and added to journal.', { entryId })
// 5. Route based on spawnMode
const spawnMode = artistry.spawnMode || 'bg_widget'
artistLog(`Routing image with mode: ${spawnMode}`)
switch (spawnMode) {
case 'bg':
// Update character's active background
cardStore.updateCard(cardId, {
extensions: {
...activeCard.extensions,
airi: {
...activeCard.extensions.airi,
modules: {
...activeCard.extensions.airi.modules,
activeBackgroundId: entryId,
},
},
},
} as any)
break
case 'inline': {
const imageUrl = result.imageUrl || result.base64
const content = `![${analysis.title || 'Generated Image'}](${imageUrl})`
chatSessionStore.appendSessionMessage(chatSessionStore.activeSessionId, {
role: 'assistant',
content,
slices: [{ type: 'text', text: content }],
tool_results: [],
createdAt: Date.now(),
})
break
}
case 'widget':
try {
await invokers.addWidget({
componentName: 'artistry',
componentProps: {
status: 'done',
entryId,
imageUrl: result.imageUrl || result.base64,
prompt: analysis.prompt,
title: analysis.title || 'Autonomous Scene',
_skipIngestion: true,
},
size: 'm',
ttlMs: 0,
})
}
catch (widgetErr) {
console.warn('[AutonomousArtist] Failed to spawn Result widget', widgetErr)
}
break
case 'bg_widget':
default:
// Both: Update background AND spawn widget
cardStore.updateCard(cardId, {
extensions: {
...activeCard.extensions,
airi: {
...activeCard.extensions.airi,
modules: {
...activeCard.extensions.airi.modules,
activeBackgroundId: entryId,
},
},
},
} as any)
try {
await invokers.addWidget({
componentName: 'artistry',
componentProps: {
status: 'done',
entryId,
imageUrl: result.imageUrl || result.base64,
prompt: analysis.prompt,
title: analysis.title || 'Autonomous Scene',
_skipIngestion: true,
},
size: 'm',
ttlMs: 0,
})
}
catch (widgetErr) {
console.warn('[AutonomousArtist] Failed to spawn Result widget', widgetErr)
}
break
}
}
}
else {
artistLog(`Intensity (${analysis.intensity}) below threshold (${threshold}). No action taken.`)
}
}
catch (err) {
artistLog('Task failed with error:', err)
}
finally {
isProcessing.value = false
}
}
return {
isProcessing,
runArtistTask,
}
})
@@ -0,0 +1,215 @@
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
import { defineStore } from 'pinia'
import { computed, isRef, ref, watch } from 'vue'
export interface ResolvedArtistryConfig {
provider?: string
model?: string
promptPrefix?: string
options?: Record<string, any>
globals: Record<string, any>
}
export interface ComfyUIWorkflowTemplate {
id: string
name: string
workflow: Record<string, any>
exposedFields: Record<string, string[]>
}
export const useArtistryStore = defineStore('artistry', () => {
// --- Persistent Global Settings (User Preferences) ---
const globalProvider = useLocalStorageManualReset<string>('artistry-provider', 'comfyui')
const globalModel = useLocalStorageManualReset<string>('artistry-model', '')
const globalPromptPrefix = useLocalStorageManualReset<string>('artistry-prompt-prefix', '')
const globalProviderOptions = useLocalStorageManualReset<Record<string, any> | undefined>('artistry-provider-options', undefined)
// --- Active settings (transient, can be overridden by cards) ---
const activeProvider = ref(globalProvider.value)
const activeModel = ref(globalModel.value)
const defaultPromptPrefix = ref(globalPromptPrefix.value)
const providerOptions = ref(globalProviderOptions.value)
// --- ComfyUI provider settings ---
const comfyuiServerUrl = useLocalStorageManualReset<string>(
'artistry-comfyui-server-url',
'http://localhost:8188',
)
const comfyuiSavedWorkflows = useLocalStorageManualReset<ComfyUIWorkflowTemplate[]>(
'artistry-comfyui-saved-workflows',
[],
)
const comfyuiActiveWorkflow = useLocalStorageManualReset<string>(
'artistry-comfyui-active-workflow',
'',
)
// --- Replicate provider settings ---
const replicateApiKey = useLocalStorageManualReset<string>('artistry-replicate-api-key', '')
const replicateDefaultModel = useLocalStorageManualReset<string>(
'artistry-replicate-default-model',
'black-forest-labs/flux-schnell',
)
const replicateAspectRatio = useLocalStorageManualReset<string>(
'artistry-replicate-aspect-ratio',
'16:9',
)
const replicateInferenceSteps = useLocalStorageManualReset<number>(
'artistry-replicate-inference-steps',
4,
)
// --- Nano Banana (Google AI Studio) provider settings ---
const nanobananaApiKey = useLocalStorageManualReset<string>('artistry-nanobanana-api-key', '')
const nanobananaModel = useLocalStorageManualReset<string>(
'artistry-nanobanana-model',
'gemini-3.1-flash-image-preview',
)
const nanobananaResolution = useLocalStorageManualReset<string>(
'artistry-nanobanana-resolution',
'1K',
)
/**
* Resets active settings to match current global user preferences.
* This is typically called when switching to a card with no overrides.
*/
function resetToGlobal() {
activeProvider.value = globalProvider.value
activeModel.value = globalModel.value
defaultPromptPrefix.value = globalPromptPrefix.value
providerOptions.value = globalProviderOptions.value
}
/**
* Hard resets both global persistent settings and active transient state.
*/
function resetState() {
// Reset persistent globals
globalProvider.reset()
globalModel.reset()
globalPromptPrefix.reset()
globalProviderOptions.reset()
comfyuiServerUrl.reset()
comfyuiSavedWorkflows.reset()
comfyuiActiveWorkflow.reset()
replicateApiKey.reset()
replicateDefaultModel.reset()
replicateAspectRatio.reset()
replicateInferenceSteps.reset()
nanobananaApiKey.reset()
nanobananaModel.reset()
nanobananaResolution.reset()
// Sync active state
resetToGlobal()
}
// Sync active state when global state changes (e.g. from Settings page)
// NOTICE: We only sync if the active state currently matches the global state (i.e. no card override is active),
// OR we just sync anyway and let airi-card's watch override it again if a card is active.
// The latter is simpler and more predictable.
watch(globalProvider, val => activeProvider.value = val)
watch(globalModel, val => activeModel.value = val)
watch(globalPromptPrefix, val => defaultPromptPrefix.value = val)
watch(globalProviderOptions, val => providerOptions.value = val)
const configured = computed(() => {
if (!activeProvider.value)
return false
if (activeProvider.value === 'replicate') {
return !!replicateApiKey.value
}
if (activeProvider.value === 'comfyui') {
return !!comfyuiServerUrl.value
}
if (activeProvider.value === 'nanobanana') {
return !!nanobananaApiKey.value
}
return true
})
const artistryGlobals = computed(() => ({
comfyuiServerUrl: comfyuiServerUrl.value,
comfyuiSavedWorkflows: comfyuiSavedWorkflows.value,
comfyuiActiveWorkflow: comfyuiActiveWorkflow.value,
replicateApiKey: replicateApiKey.value,
replicateDefaultModel: replicateDefaultModel.value,
replicateAspectRatio: replicateAspectRatio.value,
replicateInferenceSteps: replicateInferenceSteps.value,
nanobananaApiKey: nanobananaApiKey.value,
nanobananaModel: nanobananaModel.value,
nanobananaResolution: nanobananaResolution.value,
}))
return {
configured,
artistryGlobals,
// Active settings (transient, resolved per card)
activeProvider,
activeModel,
defaultPromptPrefix,
providerOptions,
// Global settings (persistent user preferences)
globalProvider,
globalModel,
globalPromptPrefix,
globalProviderOptions,
// ComfyUI provider config
comfyuiServerUrl,
comfyuiSavedWorkflows,
comfyuiActiveWorkflow,
// Replicate provider config
replicateApiKey,
replicateDefaultModel,
replicateAspectRatio,
replicateInferenceSteps,
// Nano Banana provider config
nanobananaApiKey,
nanobananaModel,
nanobananaResolution,
resetToGlobal,
resetState,
}
})
/**
* Resolves Artistry configuration from a Pinia store instance.
*
* This utility handles the divergence between Vue components (where Pinia state is auto-unwrapped)
* and headless service/tool contexts (where state properties remain as Refs).
*
* @param store - The artistry store instance (from useArtistryStore())
*/
export function resolveArtistryConfigFromStore(store: any): ResolvedArtistryConfig {
const unwrap = (val: any) => (isRef(val) ? val.value : val)
return {
provider: unwrap(store.activeProvider),
model: unwrap(store.activeModel),
promptPrefix: unwrap(store.defaultPromptPrefix),
options: unwrap(store.providerOptions),
globals: {
comfyuiServerUrl: unwrap(store.comfyuiServerUrl),
comfyuiSavedWorkflows: unwrap(store.comfyuiSavedWorkflows),
comfyuiActiveWorkflow: unwrap(store.comfyuiActiveWorkflow),
replicateApiKey: unwrap(store.replicateApiKey),
replicateDefaultModel: unwrap(store.replicateDefaultModel),
replicateAspectRatio: unwrap(store.replicateAspectRatio),
replicateInferenceSteps: unwrap(store.replicateInferenceSteps),
nanobananaApiKey: unwrap(store.nanobananaApiKey),
nanobananaModel: unwrap(store.nanobananaModel),
nanobananaResolution: unwrap(store.nanobananaResolution),
},
}
}
@@ -175,6 +175,9 @@ export interface ProviderMetadata {
supportsStreamOutput: boolean
supportsStreamInput: boolean
}
pricing?: 'free' | 'paid' | 'internal'
deployment?: 'local' | 'cloud'
beginnerRecommended?: boolean
}
export interface ModelInfo {
+205 -192
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -121,6 +121,7 @@ catalog:
pinia: ^3.0.4
posthog-js: 1.306.1
reka-ui: ^2.9.2
replicate: ^1.4.0
splitpanes: ^4.0.4
std-env: ^4.1.0
stockfish: ^18.0.7
BIN
View File
Binary file not shown.
View File
+16
View File
@@ -0,0 +1,16 @@
[
{
"id": "PRRT_kwDONXX6d859HYhL",
"isResolved": false,
"comments": {
"nodes": [
{
"body": "**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Keep prefixed prompt from being overwritten by extra fields**\n\n`generate()` first writes `inputOptions.prompt` from `request.prompt` (which already includes card/global `promptPrefix`), but then immediately spreads `rest` from `request.extra` over it. In widget-triggered flows, `request.extra` includes the raw `componentProps.prompt`, so this overwrite drops the normalized/prefixed prompt and silently bypasses configured style prefixes for Replicate outputs. Preserve the computed prompt (or strip `prompt` from `rest`) before merging provider extras.\n\nUseful? React with 👍 / 👎.",
"author": {
"login": "chatgpt-codex-connector"
}
}
]
}
}
]