From 0ed6c80ea37dac8a671af1f44e1ef93e49184f60 Mon Sep 17 00:00:00 2001 From: NBD1138 <192027633+NBD-1138@users.noreply.github.com> Date: Thu, 23 Oct 2025 13:10:06 -0600 Subject: [PATCH 1/3] Added support to import Sillytavern cards. Card is placed in /avatar/ and a button is generated to choose a live2d model from the model_dict.yaml. Character\NAME.yaml is created. Requires a change to backend src/open_llm_vtuber/routes.py --- package-lock.json | 16 +- package.json | 1 + .../sidebar/setting/import-card-dialog.tsx | 311 +++++++++ .../src/components/sidebar/setting/live2d.tsx | 43 +- .../hooks/sidebar/setting/use-import-card.ts | 600 ++++++++++++++++++ src/renderer/src/locales/en/translation.json | 34 +- src/renderer/src/locales/zh/translation.json | 36 +- 7 files changed, 1021 insertions(+), 20 deletions(-) create mode 100644 src/renderer/src/components/sidebar/setting/import-card-dialog.tsx create mode 100644 src/renderer/src/hooks/sidebar/setting/use-import-card.ts diff --git a/package-lock.json b/package-lock.json index 6bd2ca1f..64ecdf29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-llm-vtuber", - "version": "1.2.0", + "version": "1.2.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "open-llm-vtuber", - "version": "1.2.0", + "version": "1.2.1", "hasInstallScript": true, "dependencies": { "@chakra-ui/react": "^3.2.3", @@ -26,6 +26,7 @@ "i18next-scanner": "^4.6.0", "next-themes": "^0.4.4", "onnxruntime-web": "1.14.0", + "pako": "^2.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^15.5.1", @@ -8439,6 +8440,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -16887,6 +16894,11 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true }, + "pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" + }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", diff --git a/package.json b/package.json index c77387a5..04a5f441 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "i18next-scanner": "^4.6.0", "next-themes": "^0.4.4", "onnxruntime-web": "1.14.0", + "pako": "^2.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^15.5.1", diff --git a/src/renderer/src/components/sidebar/setting/import-card-dialog.tsx b/src/renderer/src/components/sidebar/setting/import-card-dialog.tsx new file mode 100644 index 00000000..02b2f7e5 --- /dev/null +++ b/src/renderer/src/components/sidebar/setting/import-card-dialog.tsx @@ -0,0 +1,311 @@ +import { + Box, + Button, + CloseButton, + Flex, + Image, + Input, + Select, + Spinner, + Stack, + Switch, + Text, +} from '@chakra-ui/react'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import useImportCard from '@/hooks/sidebar/setting/use-import-card'; + +interface ImportCardDialogProps { + open: boolean; + onClose: () => void; +} + +interface InfoSectionProps { + label: string; + value?: string | string[]; + placeholder?: string; +} + +function InfoSection({ label, value, placeholder }: InfoSectionProps) { + console.debug('[ImportCardDialog] Rendering section:', label, value); + + const normalizeItem = (item: unknown, path: string): string => { + if (item === null || item === undefined) return ''; + if (typeof item === 'string') return item; + if (typeof item === 'number' || typeof item === 'boolean') return String(item); + if (Array.isArray(item)) { + console.debug('[ImportCardDialog] Normalizing array for', path, item); + return item.map((subItem, index) => normalizeItem(subItem, `${path}[${index}]`)).join('\n'); + } + console.debug('[ImportCardDialog] Normalizing object for', path, item); + try { + return JSON.stringify(item, null, 2); + } catch (error) { + console.warn('[ImportCardDialog] Failed to stringify value for InfoSection:', path, item, error); + return String(item); + } + }; + + const normalizedValue = Array.isArray(value) + ? value.map((item, index) => normalizeItem(item, `${label}[${index}]`)).filter((item) => item !== '') + : normalizeItem(value, label); + + const hasContent = Array.isArray(normalizedValue) + ? normalizedValue.length > 0 + : Boolean(normalizedValue && normalizedValue.trim()); + + if (!hasContent && !placeholder) return null; + + return ( + + + {label} + + {Array.isArray(normalizedValue) ? ( + normalizedValue.length > 0 ? ( + + {normalizedValue.map((item, index) => ( + + + {item} + + + ))} + + ) : ( + placeholder && ( + + {placeholder} + + ) + ) + ) : hasContent ? ( + + {normalizedValue} + + ) : ( + placeholder && ( + + {placeholder} + + ) + )} + + ); +} + +export function ImportCardDialog({ open, onClose }: ImportCardDialogProps) { + console.log('[ImportCardDialog] rendering', { open }); + const { + modelOptions, + selectedModel, + setSelectedModel, + modelLoading, + parsing, + saving, + cardPreview, + cardError, + fileName, + canSaveAvatar, + saveAvatar, + setSaveAvatar, + avatarPreviewUrl, + handleFile, + createCharacter, + reset, + } = useImportCard(); + const { t } = useTranslation(); + + const [fileKey, setFileKey] = useState(0); + + useEffect(() => { + console.log('[ImportCardDialog] effect: open changed', open); + if (!open) { + reset(); + setFileKey((prev) => prev + 1); + } + }, [open, reset]); + + const isCreateDisabled = useMemo( + () => !cardPreview || !selectedModel || parsing || saving, + [cardPreview, parsing, saving, selectedModel], + ); + + const handleDialogClose = () => { + reset(); + setFileKey((prev) => prev + 1); + onClose(); + }; + + const handleCreate = async () => { + const success = await createCharacter(); + if (success) { + handleDialogClose(); + } + }; + + const descriptionBlock = useMemo(() => { + if (!cardPreview) return ''; + const parts = [cardPreview.description, cardPreview.personality] + .map((part) => (part ? part.trim() : '')) + .filter(Boolean); + return parts.join('\n\n'); + }, [cardPreview]); + + if (!open) { + console.log('[ImportCardDialog] not open -> null'); + return null; + } + + console.log('[ImportCardDialog] ready to render dialog (debug placeholder)', { + cardPreview, + cardError, + modelOptionsLength: modelOptions.length, + parsing, + saving, + fileName, + avatarPreviewUrl, + }); + + return ( + + + + + + {t('settings.live2d.import.title')} + + + + + + {t('settings.live2d.import.description')} + + + + + + {t('settings.live2d.import.fileLabel')} + + { + const file = event.target.files?.[0] ?? null; + await handleFile(file); + event.target.value = ''; + }} + bg="gray.800" + borderColor="whiteAlpha.300" + /> + {fileName && ( + + {t('settings.live2d.import.fileName', { name: fileName })} + + )} + {parsing && ( + + + + {t('settings.live2d.import.parsing')} + + + )} + {cardError && !parsing && ( + + {cardError} + + )} + + + + + {t('settings.live2d.import.model')} + + {modelLoading ? ( + + + + {t('settings.live2d.import.loadingModels')} + + + ) : ( + + {modelOptions.map((model) => { + const isSelected = model.name === selectedModel; + return ( + + ); + })} + {modelOptions.length === 0 && ( + + {t('settings.live2d.import.modelLoadError')} + + )} + + )} + + + + + + + + + + + ); +} + +export default ImportCardDialog; diff --git a/src/renderer/src/components/sidebar/setting/live2d.tsx b/src/renderer/src/components/sidebar/setting/live2d.tsx index 96b27d6b..8089d164 100644 --- a/src/renderer/src/components/sidebar/setting/live2d.tsx +++ b/src/renderer/src/components/sidebar/setting/live2d.tsx @@ -1,11 +1,12 @@ /* eslint-disable import/no-extraneous-dependencies */ /* eslint-disable react-hooks/rules-of-hooks */ -import { Stack } from '@chakra-ui/react'; -import { useEffect } from 'react'; +import { Button, Stack } from '@chakra-ui/react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { settingStyles } from './setting-styles'; import { useLive2dSettings } from '@/hooks/sidebar/setting/use-live2d-settings'; import { SwitchField } from './common'; +import ImportCardDialog from './import-card-dialog'; interface live2DProps { onSave?: (callback: () => void) => () => void @@ -14,6 +15,7 @@ interface live2DProps { function live2D({ onSave, onCancel }: live2DProps): JSX.Element { const { t } = useTranslation(); + const [importOpen, setImportOpen] = useState(false); const { modelInfo, handleInputChange, @@ -34,19 +36,34 @@ function live2D({ onSave, onCancel }: live2DProps): JSX.Element { }, [onSave, onCancel]); return ( - - handleInputChange('pointerInteractive', checked)} - /> + <> + + + + handleInputChange('pointerInteractive', checked)} + /> + + handleInputChange('scrollToResize', checked)} + /> + - handleInputChange('scrollToResize', checked)} + setImportOpen(false)} /> - + ); } diff --git a/src/renderer/src/hooks/sidebar/setting/use-import-card.ts b/src/renderer/src/hooks/sidebar/setting/use-import-card.ts new file mode 100644 index 00000000..7938cbf3 --- /dev/null +++ b/src/renderer/src/hooks/sidebar/setting/use-import-card.ts @@ -0,0 +1,600 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { inflate } from 'pako'; +import { useTranslation } from 'react-i18next'; +import { toaster } from '@/components/ui/toaster'; +import { useWebSocket } from '@/context/websocket-context'; + +interface ModelInfo { + name: string; + description?: string; + url?: string; +} + +export interface CardPreview { + name: string; + description?: string; + personality?: string; + scenario?: string; + firstMessage?: string; + exampleMessages: string[]; + systemPrompt?: string; + postHistoryInstructions?: string; + personaPrompt: string; +} + +interface UseImportCardResult { + modelOptions: ModelInfo[]; + selectedModel: string; + setSelectedModel: (value: string) => void; + modelLoading: boolean; + parsing: boolean; + saving: boolean; + cardPreview: CardPreview | null; + cardError: string | null; + fileName: string; + sourceType: 'png' | 'json' | null; + canSaveAvatar: boolean; + saveAvatar: boolean; + setSaveAvatar: (value: boolean) => void; + avatarPreviewUrl: string | null; + handleFile: (file: File | null) => Promise; + createCharacter: () => Promise; + reset: () => void; +} + +const DEFAULT_MODEL = 'mao_pro'; +const JSON_KEYWORDS = [ + 'chara', + 'json', + 'ai_card', + 'card', + 'character', + 'chara_card_v2', +]; + +const textDecoder = new TextDecoder('utf-8'); + +const sanitizeName = (value: string | undefined | null, fallback = 'character') => { + if (!value) return fallback; + const sanitized = value + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^[-_]+|[-_]+$/g, ''); + return sanitized || fallback; +}; + +const toStringValue = (value: unknown): string => { + if (typeof value === 'string') return value; + if (value === null || value === undefined) return ''; + if (Array.isArray(value)) { + return value + .map((item) => toStringValue(item).trim()) + .filter(Boolean) + .join('\n'); + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + if (typeof value === 'object') { + try { + return JSON.stringify(value); + } catch { + return ''; + } + } + return ''; +}; + +const normalizeExampleMessages = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value + .map((item) => toStringValue(item).trim()) + .filter(Boolean); + } + + const asString = toStringValue(value).trim(); + if (!asString) return []; + + const segments = asString + .split(/\r?\n\s*\r?\n/) + .map((segment) => segment.trim()) + .filter(Boolean); + + return segments.length > 0 ? segments : [asString]; +}; + +const tryParseJsonString = (candidate: string): unknown | null => { + const trimmed = candidate.trim(); + if (!trimmed) return null; + + try { + return JSON.parse(trimmed); + } catch { + // ignore direct parse failure + } + + try { + const decoded = atob(trimmed); + try { + return JSON.parse(decoded); + } catch { + const binary = Uint8Array.from(decoded, (char) => char.charCodeAt(0)); + try { + const inflated = inflate(binary, { to: 'string' }); + if (inflated) { + return JSON.parse(inflated); + } + } catch { + // ignore inflate failure + } + } + } catch { + // not base64 encoded, ignore + } + + return null; +}; + +const extractTextChunk = ( + chunkType: string, + chunkData: Uint8Array, +): Array<{ keyword: string; text: string }> => { + if (chunkType === 'tEXt') { + const nullIndex = chunkData.indexOf(0); + if (nullIndex === -1) return []; + const keyword = textDecoder.decode(chunkData.slice(0, nullIndex)); + const text = textDecoder.decode(chunkData.slice(nullIndex + 1)); + return [{ keyword, text }]; + } + + if (chunkType === 'zTXt') { + const nullIndex = chunkData.indexOf(0); + if (nullIndex === -1 || nullIndex + 2 > chunkData.length) return []; + const keyword = textDecoder.decode(chunkData.slice(0, nullIndex)); + const compressionMethod = chunkData[nullIndex + 1]; + if (compressionMethod !== 0) return []; + const compressed = chunkData.slice(nullIndex + 2); + const text = textDecoder.decode(inflate(compressed)); + return [{ keyword, text }]; + } + + if (chunkType === 'iTXt') { + let offset = 0; + + const keywordEnd = chunkData.indexOf(0, offset); + if (keywordEnd === -1) return []; + const keyword = textDecoder.decode(chunkData.slice(0, keywordEnd)); + offset = keywordEnd + 1; + + const compressionFlag = chunkData[offset]; + const compressionMethod = chunkData[offset + 1]; + offset += 2; + + const languageEnd = chunkData.indexOf(0, offset); + if (languageEnd === -1) return []; + offset = languageEnd + 1; + + const translatedEnd = chunkData.indexOf(0, offset); + if (translatedEnd === -1) return []; + offset = translatedEnd + 1; + + const textBytes = chunkData.slice(offset); + let text: string; + if (compressionFlag === 1) { + if (compressionMethod !== 0) return []; + text = textDecoder.decode(inflate(textBytes)); + } else { + text = textDecoder.decode(textBytes); + } + return [{ keyword, text }]; + } + + return []; +}; + +const extractJsonFromPng = async (file: File): Promise => { + const buffer = await file.arrayBuffer(); + const data = new Uint8Array(buffer); + const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + + if (!signature.every((byte, index) => data[index] === byte)) { + throw new Error('Invalid PNG file'); + } + + const candidates: string[] = []; + let offset = 8; + + while (offset + 8 <= data.length) { + const length = ( + (data[offset] << 24) + | (data[offset + 1] << 16) + | (data[offset + 2] << 8) + | data[offset + 3] + ) >>> 0; + offset += 4; + + if (offset + 4 > data.length) break; + const chunkType = String.fromCharCode( + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ); + offset += 4; + + if (offset + length > data.length) break; + const chunkData = data.slice(offset, offset + length); + offset += length; + + // Skip CRC + offset += 4; + + if (['tEXt', 'zTXt', 'iTXt'].includes(chunkType)) { + const entries = extractTextChunk(chunkType, chunkData); + entries.forEach(({ keyword, text }) => { + const lowerKey = keyword.toLowerCase(); + if ( + JSON_KEYWORDS.includes(lowerKey) + || text.trim().startsWith('{') + ) { + candidates.push(text); + } + }); + } + } + + for (const candidate of candidates) { + const parsed = tryParseJsonString(candidate); + if (parsed) return parsed; + } + + throw new Error('No embedded character JSON found in PNG'); +}; + +const extractJsonFromJsonFile = async (file: File): Promise => { + const text = await file.text(); + const parsed = tryParseJsonString(text); + if (!parsed) { + throw new Error('Invalid character card JSON file'); + } + return parsed; +}; + +const normalizeCard = (raw: any, fallbackName: string): CardPreview => { + const dataSection = raw?.data ?? raw?.chara ?? raw?.char ?? raw ?? {}; + + const name = toStringValue(dataSection.name).trim() || fallbackName; + const description = toStringValue( + dataSection.description ?? dataSection.desc ?? dataSection.short_description, + ).trim(); + const personality = toStringValue( + dataSection.personality ?? dataSection.character ?? dataSection.traits, + ).trim(); + const scenario = toStringValue( + dataSection.scenario ?? dataSection.setting ?? dataSection.world, + ).trim(); + const systemPrompt = toStringValue( + dataSection.system_prompt ?? dataSection.system ?? dataSection.prompt ?? dataSection.systemPrompt, + ).trim(); + const postHistoryInstructions = toStringValue( + dataSection.post_history_instructions ?? dataSection.post_history ?? dataSection.extra_instructions, + ).trim(); + const firstMessage = toStringValue( + dataSection.first_mes + ?? dataSection.first_message + ?? dataSection.greeting + ?? dataSection.firstMessage + ?? dataSection.opening_message, + ).trim(); + const exampleMessages = normalizeExampleMessages( + dataSection.mes_example + ?? dataSection.example_messages + ?? dataSection.mesExamples + ?? dataSection.example_dialogue + ?? dataSection.conversation_examples, + ); + + let personaPrompt = ''; + if (systemPrompt) { + personaPrompt = systemPrompt; + if (postHistoryInstructions) { + personaPrompt = `${personaPrompt}\n\n${postHistoryInstructions}`; + } + } else { + const parts = [description, personality, scenario] + .map((part) => part.trim()) + .filter(Boolean); + personaPrompt = parts.join('\n\n'); + } + + personaPrompt = personaPrompt.trim(); + + return { + name, + description, + personality, + scenario, + firstMessage, + exampleMessages, + systemPrompt, + postHistoryInstructions, + personaPrompt, + }; +}; + +export const useImportCard = (): UseImportCardResult => { + const { t } = useTranslation(); + const { baseUrl, sendMessage } = useWebSocket(); + + const [modelOptions, setModelOptions] = useState([]); + const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL); + const [modelLoading, setModelLoading] = useState(false); + const [parsing, setParsing] = useState(false); + const [saving, setSaving] = useState(false); + const [cardPreview, setCardPreview] = useState(null); + const [cardError, setCardError] = useState(null); + const [fileName, setFileName] = useState(''); + const [sourceType, setSourceType] = useState<'png' | 'json' | null>(null); + const [saveAvatar, setSaveAvatar] = useState(false); + const [avatarFile, setAvatarFile] = useState(null); + const [avatarPreviewUrl, setAvatarPreviewUrl] = useState(null); + + const avatarPreviewRef = useRef(null); + + const canSaveAvatar = useMemo( + () => sourceType === 'png' && !!cardPreview, + [sourceType, cardPreview], + ); + + useEffect(() => { + let cancelled = false; + + const fetchModels = async () => { + setModelLoading(true); + try { + const response = await fetch(`${baseUrl}/api/live2d/models`); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + const data = await response.json(); + if (cancelled) return; + + const models: ModelInfo[] = Array.isArray(data.models) ? data.models : []; + setModelOptions(models); + if (models.length > 0) { + const hasDefault = models.some((model: ModelInfo) => model.name === DEFAULT_MODEL); + setSelectedModel(hasDefault ? DEFAULT_MODEL : models[0].name); + } + } catch (error) { + if (!cancelled) { + toaster.create({ + title: t('settings.live2d.import.toastModelError'), + description: error instanceof Error ? error.message : String(error), + type: 'error', + duration: 4000, + }); + setModelOptions([]); + } + } finally { + if (!cancelled) { + setModelLoading(false); + } + } + }; + + fetchModels(); + return () => { + cancelled = true; + }; + }, [baseUrl, t]); + + const resetAvatarPreview = useCallback(() => { + if (avatarPreviewRef.current) { + URL.revokeObjectURL(avatarPreviewRef.current); + avatarPreviewRef.current = null; + } + setAvatarPreviewUrl(null); + }, []); + + const reset = useCallback(() => { + setCardPreview(null); + setCardError(null); + setFileName(''); + setSourceType(null); + setSaveAvatar(false); + setAvatarFile(null); + resetAvatarPreview(); + }, [resetAvatarPreview]); + + useEffect(() => () => { + resetAvatarPreview(); + }, [resetAvatarPreview]); + + const handleFile = useCallback(async (file: File | null) => { + if (!file) { + reset(); + return; + } + + setParsing(true); + setCardError(null); + + const extension = file.name.split('.').pop()?.toLowerCase(); + + try { + let rawJson: unknown; + if (extension === 'png') { + rawJson = await extractJsonFromPng(file); + setSourceType('png'); + setAvatarFile(file); + + resetAvatarPreview(); + const previewUrl = URL.createObjectURL(file); + avatarPreviewRef.current = previewUrl; + setAvatarPreviewUrl(previewUrl); + } else if (extension === 'json') { + rawJson = await extractJsonFromJsonFile(file); + setSourceType('json'); + setAvatarFile(null); + setSaveAvatar(false); + resetAvatarPreview(); + } else { + throw new Error('Unsupported file type. Please choose a .png or .json card.'); + } + + const preview = normalizeCard(rawJson, file.name.replace(/\.[^.]+$/, '')); + if (!preview.personaPrompt) { + throw new Error(t('settings.live2d.import.toastPersonaPromptError')); + } + + setCardPreview(preview); + setFileName(file.name); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setCardPreview(null); + setCardError(message); + setSaveAvatar(false); + setAvatarFile(null); + resetAvatarPreview(); + + toaster.create({ + title: t('settings.live2d.import.toastParseError'), + description: message, + type: 'error', + duration: 4000, + }); + } finally { + setParsing(false); + } + }, [reset, resetAvatarPreview, t]); + + const createCharacter = useCallback(async () => { + if (!cardPreview) return false; + if (!selectedModel) { + toaster.create({ + title: t('settings.live2d.import.toastModelError'), + type: 'error', + duration: 3000, + }); + return false; + } + if (!cardPreview.personaPrompt.trim()) { + toaster.create({ + title: t('settings.live2d.import.toastPersonaPromptError'), + type: 'error', + duration: 3000, + }); + return false; + } + + setSaving(true); + try { + let avatarFilename = ''; + const shouldSaveAvatar = sourceType === 'png' && avatarFile; + if (shouldSaveAvatar) { + const baseName = sanitizeName(cardPreview.name); + const formData = new FormData(); + formData.append('file', avatarFile, `${baseName}.png`); + formData.append('base_name', baseName); + + const uploadResponse = await fetch(`${baseUrl}/api/avatars/upload`, { + method: 'POST', + body: formData, + }); + if (!uploadResponse.ok) { + const errorBody = await uploadResponse.json().catch(() => ({})); + throw new Error( + errorBody.detail + ?? `${uploadResponse.status} ${uploadResponse.statusText}`, + ); + } + const uploadJson = await uploadResponse.json(); + avatarFilename = uploadJson.filename ?? ''; + } + + const characterName = cardPreview.name.trim() + || sanitizeName(fileName.replace(/\.[^.]+$/, '')); + + const payload = { + character_name: characterName, + persona_prompt: cardPreview.personaPrompt, + live2d_model_name: selectedModel, + avatar: avatarFilename, + human_name: 'Human', + }; + + const response = await fetch(`${baseUrl}/api/characters/create`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + throw new Error( + errorBody.detail + ?? `${response.status} ${response.statusText}`, + ); + } + + toaster.create({ + title: t('settings.live2d.import.toastSuccess', { name: characterName }), + type: 'success', + duration: 3000, + }); + + sendMessage({ type: 'fetch-configs' }); + return true; + } catch (error) { + toaster.create({ + title: t('settings.live2d.import.toastCreateError'), + description: error instanceof Error ? error.message : String(error), + type: 'error', + duration: 4000, + }); + return false; + } finally { + setSaving(false); + } + }, [ + avatarFile, + baseUrl, + cardPreview, + fileName, + saveAvatar, + selectedModel, + sendMessage, + t, + ]); + + return { + modelOptions, + selectedModel, + setSelectedModel, + modelLoading, + parsing, + saving, + cardPreview, + cardError, + fileName, + sourceType, + canSaveAvatar, + saveAvatar: canSaveAvatar ? saveAvatar : false, + setSaveAvatar: (value: boolean) => setSaveAvatar(canSaveAvatar ? value : false), + avatarPreviewUrl, + handleFile, + createCharacter, + reset, + }; +}; + +export default useImportCard; diff --git a/src/renderer/src/locales/en/translation.json b/src/renderer/src/locales/en/translation.json index 96cb2c82..4627367c 100644 --- a/src/renderer/src/locales/en/translation.json +++ b/src/renderer/src/locales/en/translation.json @@ -34,7 +34,37 @@ }, "live2d": { "pointerInteractive": "Pointer Interactive", - "scrollToResize": "Enable Scroll to Resize" + "scrollToResize": "Enable Scroll to Resize", + "import": { + "button": "Import Character from Card", + "title": "Import SillyTavern Card", + "description": "Select a SillyTavern Character Card (.png or .json) to preview and create a new character.", + "fileLabel": "Character Card", + "fileName": "Selected file: {{name}}", + "parsing": "Parsing card...", + "preview": "Preview", + "name": "Name", + "descriptionPersonality": "Description / Personality", + "scenario": "Scenario", + "firstMessage": "First Message", + "examples": "Example Messages", + "noExamples": "No example messages", + "system": "System / Post-history Instructions", + "noSystem": "No system instructions", + "avatarPreview": "Avatar Preview", + "model": "Live2D Model", + "loadingModels": "Loading models...", + "saveAvatar": "Save PNG as avatar", + "creating": "Creating...", + "create": "Create Character", + "toastModelError": "Failed to load Live2D models", + "toastPersonaPromptError": "Unable to compose a persona prompt from the card", + "toastParseError": "Unable to parse character card", + "toastSuccess": "Character created: {{name}}", + "toastCreateError": "Failed to create character", + "saveAvatarHint": "PNG cards can save their embedded avatar. Select a PNG card to enable this option.", + "modelLoadError": "No Live2D models found. Check model_dict.json or server configuration." + } }, "asr": { "autoStopMic": "Auto Stop Mic When AI Start Speaking", @@ -136,4 +166,4 @@ "connecting": "Connecting", "clickToReconnect": "Click to Reconnect" } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/renderer/src/locales/zh/translation.json b/src/renderer/src/locales/zh/translation.json index 575a7e93..c1138dd0 100644 --- a/src/renderer/src/locales/zh/translation.json +++ b/src/renderer/src/locales/zh/translation.json @@ -31,8 +31,38 @@ "imageMaxWidthHelp": "图片缩放的最大宽度。超过此宽度的图片会按比例缩小。设为0表示不限制大小。此功能存在的原因是某些AI模型在处理超大尺寸图片时可能存在限制。但是大部分AI模型都能自动处理图片,因此默认值为0(不限制),以保持您原始图片的完整性。" }, "live2d": { - "pointerInteractive": "鼠标交互", - "scrollToResize": "启用滚轮缩放" + "pointerInteractive": "指针交互", + "scrollToResize": "启用滚轮缩放", + "import": { + "button": "从卡片导入角色", + "title": "导入 SillyTavern 角色卡", + "description": "选择一个 SillyTavern 角色卡(.png 或 .json),预览信息并创建新的角色配置。", + "fileLabel": "角色卡文件", + "fileName": "已选择文件:{{name}}", + "parsing": "正在解析角色卡…", + "preview": "角色预览", + "name": "名称", + "descriptionPersonality": "描述 / 人设", + "scenario": "场景设定", + "firstMessage": "首次问候", + "examples": "对话示例", + "noExamples": "没有示例消息", + "system": "系统 / 历史追加提示", + "noSystem": "没有系统提示", + "avatarPreview": "头像预览", + "model": "Live2D 模型", + "loadingModels": "正在加载模型…", + "saveAvatar": "保存 PNG 作为头像", + "creating": "正在创建…", + "create": "创建角色", + "toastModelError": "加载 Live2D 模型失败", + "toastPersonaPromptError": "无法从角色卡生成人格提示", + "toastParseError": "解析角色卡失败", + "toastSuccess": "角色已创建:{{name}}", + "toastCreateError": "创建角色失败", + "saveAvatarHint": "仅 PNG 角色卡可保存内置头像,选择 PNG 卡片后可启用此选项。", + "modelLoadError": "未找到可用的 Live2D 模型,请检查 model_dict.json 或服务器配置。" + } }, "asr": { "autoStopMic": "AI开始说话时自动关闭麦克风", @@ -133,4 +163,4 @@ "connecting": "连接中", "clickToReconnect": "点击重新连接" } -} \ No newline at end of file +} \ No newline at end of file From 1708589089e0d5993545df88577211608b4cc769 Mon Sep 17 00:00:00 2001 From: NBD-1138 <192027633+NBD-1138@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:14:04 -0600 Subject: [PATCH 2/3] Update src/renderer/src/hooks/sidebar/setting/use-import-card.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/renderer/src/hooks/sidebar/setting/use-import-card.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/hooks/sidebar/setting/use-import-card.ts b/src/renderer/src/hooks/sidebar/setting/use-import-card.ts index 7938cbf3..17da750f 100644 --- a/src/renderer/src/hooks/sidebar/setting/use-import-card.ts +++ b/src/renderer/src/hooks/sidebar/setting/use-import-card.ts @@ -497,7 +497,7 @@ export const useImportCard = (): UseImportCardResult => { setSaving(true); try { let avatarFilename = ''; - const shouldSaveAvatar = sourceType === 'png' && avatarFile; + const shouldSaveAvatar = saveAvatar && sourceType === 'png' && avatarFile; if (shouldSaveAvatar) { const baseName = sanitizeName(cardPreview.name); const formData = new FormData(); From cad8301bda99b92c1e27e4751e2377959799d52b Mon Sep 17 00:00:00 2001 From: NBD-1138 <192027633+NBD-1138@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:14:19 -0600 Subject: [PATCH 3/3] Update src/renderer/src/components/sidebar/setting/live2d.tsx Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/renderer/src/components/sidebar/setting/live2d.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/sidebar/setting/live2d.tsx b/src/renderer/src/components/sidebar/setting/live2d.tsx index 8089d164..7acfa0fb 100644 --- a/src/renderer/src/components/sidebar/setting/live2d.tsx +++ b/src/renderer/src/components/sidebar/setting/live2d.tsx @@ -40,7 +40,7 @@ function live2D({ onSave, onCancel }: live2DProps): JSX.Element {