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
+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 {