feat(stage-*): port artistry & chatbox enhancements (#1636)
Co-authored-by-agent: Unknown <unknown@example.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
+127
-3
@@ -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
|
||||
|
||||
+293
@@ -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>
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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: [] }
|
||||
}
|
||||
|
||||
@@ -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 = ``
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user