diff --git a/apps/stage-web/src/pages/settings/airi-card/components/CardDetailDialog.vue b/apps/stage-web/src/pages/settings/airi-card/components/CardDetailDialog.vue
index a904406d2..40fc0c161 100644
--- a/apps/stage-web/src/pages/settings/airi-card/components/CardDetailDialog.vue
+++ b/apps/stage-web/src/pages/settings/airi-card/components/CardDetailDialog.vue
@@ -16,6 +16,7 @@ import {
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
+import CardEditDialog from './CardEditDialog.vue'
import DeleteCardDialog from './DeleteCardDialog.vue'
interface Props {
@@ -77,6 +78,9 @@ const isActive = computed(() => props.cardId === activeCardId.value)
// Animation control for card activation
const isActivating = ref(false)
+// Edit dialog state
+const isEditDialogOpen = ref(false)
+
function handleActivate() {
isActivating.value = true
setTimeout(() => {
@@ -194,6 +198,14 @@ const activeTab = computed({
+
+
+
+
+
+
@@ -344,6 +364,12 @@ const activeTab = computed({
+
+
+
+import type { Card } from '@proj-airi/ccc'
+
+import kebabcase from '@stdlib/string-base-kebabcase'
+
+import { Button } from '@proj-airi/stage-ui/components'
+import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
+import { FieldValues } from '@proj-airi/ui'
+import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
+import { computed, onMounted, ref, toRaw, watch } from 'vue'
+import { useI18n } from 'vue-i18n'
+
+interface Props {
+ modelValue: boolean
+ cardId: string
+}
+
+const props = defineProps()
+const emit = defineEmits<{
+ (e: 'update:modelValue', value: boolean): void
+}>()
+
+// Use computed for two-way binding
+const modelValue = computed({
+ get: () => props.modelValue,
+ set: value => emit('update:modelValue', value),
+})
+
+const cardId = computed(() => props.cardId)
+
+const { t } = useI18n()
+const cardStore = useAiriCardStore()
+
+// Tab type definition
+interface Tab {
+ id: string
+ label: string
+ icon: string
+}
+
+// Active tab ID state
+const activeTabId = ref('')
+
+// Tabs for card details
+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: 'settings', label: t('settings.pages.card.creation.settings'), icon: 'i-solar:settings-bold-duotone' },
+]
+
+// 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.find(tab => tab.id === activeTabId.value))
+ return tabs[0]?.id || ''
+ return activeTabId.value
+ },
+ set: (value: string) => {
+ activeTabId.value = value
+ },
+})
+
+// Check for errors, and save built Cards :
+
+const showError = ref(false)
+const errorMessage = ref('')
+
+function saveCard(card: Card): boolean {
+ // Before saving, let's validate what the user entered :
+ const rawCard: Card = toRaw(card)
+
+ if (!(rawCard.name!.length > 0)) {
+ // No name
+ showError.value = true
+ errorMessage.value = t('settings.pages.card.creation.errors.name')
+ return false
+ }
+ else if (!/^(?:\d+\.)+\d+$/.test(rawCard.version)) {
+ // Invalid version
+ showError.value = true
+ errorMessage.value = t('settings.pages.card.creation.errors.version')
+ return false
+ }
+ else if (!(rawCard.description!.length > 0)) {
+ // No description
+ showError.value = true
+ errorMessage.value = t('settings.pages.card.creation.errors.description')
+ return false
+ }
+ else if (!(rawCard.personality!.length > 0)) {
+ // No personality
+ showError.value = true
+ errorMessage.value = t('settings.pages.card.creation.errors.personality')
+ return false
+ }
+ else if (!(rawCard.scenario!.length > 0)) {
+ // No Scenario
+ showError.value = true
+ errorMessage.value = t('settings.pages.card.creation.errors.scenario')
+ return false
+ }
+ else if (!(rawCard.systemPrompt!.length > 0)) {
+ // No sys prompt
+ showError.value = true
+ errorMessage.value = t('settings.pages.card.creation.errors.systemprompt')
+ return false
+ }
+ else if (!(rawCard.postHistoryInstructions!.length > 0)) {
+ // No post history prompt
+ showError.value = true
+ errorMessage.value = t('settings.pages.card.creation.errors.posthistoryinstructions')
+ return false
+ }
+ showError.value = false
+
+ // Update the card in the store
+ const success = cardStore.updateCard(cardId.value, rawCard)
+ if (success) {
+ modelValue.value = false // Close the dialog
+ }
+ return success
+}
+
+// Cards data holders :
+const card = ref({
+ name: '',
+ nickname: undefined,
+ version: '1.0',
+ description: '',
+ notes: undefined,
+ personality: '',
+ scenario: '',
+ systemPrompt: '',
+ postHistoryInstructions: '',
+ greetings: [],
+ messageExample: [],
+})
+
+// Initialize card data when component mounts, cardId changes, or dialog opens
+onMounted(() => {
+ if (modelValue.value && cardId.value) {
+ loadCardData()
+ }
+})
+
+// Watch both cardId and modelValue to ensure data loads when dialog opens
+watch([() => props.cardId, () => modelValue.value], ([newCardId, isOpen]) => {
+ if (isOpen && newCardId) {
+ loadCardData()
+ }
+})
+
+function loadCardData() {
+ const existingCard = cardStore.getCard(cardId.value)
+ if (existingCard) {
+ // Deep clone to avoid modifying the store directly
+ // Ensure all required properties are correctly assigned
+ card.value = {
+ name: existingCard.name || '',
+ nickname: existingCard.nickname,
+ version: existingCard.version || '1.0',
+ description: existingCard.description || '',
+ notes: existingCard.notes,
+ personality: existingCard.personality || '',
+ scenario: existingCard.scenario || '',
+ systemPrompt: existingCard.systemPrompt || '',
+ postHistoryInstructions: existingCard.postHistoryInstructions || '',
+ greetings: [...(existingCard.greetings || [])],
+ messageExample: [...(existingCard.messageExample || [])],
+ }
+ }
+}
+
+function makeComputed(
+ /*
+ Function used to generate Computed values, with an optional sanitize function
+ */
+ key: T,
+ transform?: (input: string) => string,
+) {
+ return computed({
+ get: () => {
+ return card.value[key] ?? ''
+ },
+ set: (val: string) => { // Set,
+ const input = val.trim() // We first trim the value
+ card.value[key] = (input.length > 0
+ ? (transform ? transform(input) : input) // then potentially transform it
+ : '') as Card[T]// or default to empty string value if nothing was given
+ },
+ })
+}
+
+const cardName = makeComputed('name', input => kebabcase(input))
+const cardNickname = makeComputed('nickname')
+const cardDescription = makeComputed('description')
+const cardNotes = makeComputed('notes')
+
+const cardPersonality = makeComputed('personality')
+const cardScenario = makeComputed('scenario')
+const cardGreetings = computed({
+ get: () => card.value.greetings ?? [],
+ set: (val: string[]) => {
+ card.value.greetings = val || []
+ },
+})
+
+const cardVersion = makeComputed('version')
+const cardSystemPrompt = makeComputed('systemPrompt')
+const cardPostHistoryInstructions = makeComputed('postHistoryInstructions')
+
+
+
+
+
+
+
+
+
+ {{ t("settings.pages.card.edit.title") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/nix/pnpm-deps-hash.txt b/nix/pnpm-deps-hash.txt
index d5d98e620..375dc859c 100644
--- a/nix/pnpm-deps-hash.txt
+++ b/nix/pnpm-deps-hash.txt
@@ -1 +1 @@
-sha256-N97wWXDB3Zp3fXoHoCAHnlWJRQE2e3S50IMNdt+fd6Q=
\ No newline at end of file
+sha256-kYL/5W2BUyT+90kQLpskLi67cxzfHBcumbyvQA0WBcY=
\ No newline at end of file
diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml
index 4d3e8e9f7..337fde79e 100644
--- a/packages/i18n/src/locales/zh-Hans/settings.yaml
+++ b/packages/i18n/src/locales/zh-Hans/settings.yaml
@@ -68,6 +68,7 @@ pages:
card_not_found: 未找到角色卡
character: 角色设定
close: 关闭
+ save: 保存
consciousness:
model: 意识 / 模型
created_by: 创建者
@@ -116,6 +117,9 @@ pages:
scenario: 错误:必须提供一个情境。
systemprompt: 错误:请提供系统提示。
posthistoryinstructions: 错误:必须提供消息历史后的提示。
+ edit:
+ edit: 编辑
+ title: 编辑角色卡
modules: 模块
name_asc: 名称 (A-Z)
name_desc: 名称 (Z-A)
diff --git a/packages/stage-ui/package.json b/packages/stage-ui/package.json
index 6cd6e95c5..e73a85ef5 100644
--- a/packages/stage-ui/package.json
+++ b/packages/stage-ui/package.json
@@ -96,6 +96,7 @@
"dompurify": "^3.2.6",
"gpuu": "^1.0.4",
"jszip": "^3.10.1",
+ "live2d-motionsync": "^0.0.4",
"localforage": "^1.10.0",
"mediabunny": "^1.13.0",
"nanoid": "^5.1.5",
diff --git a/packages/stage-ui/src/components/Scenes/Live2D/Model.vue b/packages/stage-ui/src/components/Scenes/Live2D/Model.vue
index 48ce1da2e..cedfe4889 100644
--- a/packages/stage-ui/src/components/Scenes/Live2D/Model.vue
+++ b/packages/stage-ui/src/components/Scenes/Live2D/Model.vue
@@ -1,18 +1,29 @@