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 {