diff --git a/apps/webuiapps/src/lib/appRegistry.ts b/apps/webuiapps/src/lib/appRegistry.ts index a5613b5..9c64ea9 100644 --- a/apps/webuiapps/src/lib/appRegistry.ts +++ b/apps/webuiapps/src/lib/appRegistry.ts @@ -148,6 +148,16 @@ const APP_STATIC_REGISTRY: AppStaticDef[] = [ color: '#FAEA5F', defaultSize: { width: 1100, height: 750 }, }, + { + appId: 15, + appName: 'tavern', + route: '/tavern', + displayName: 'Tavern', + sourceDir: 'Tavern', + icon: 'MessageCircle', + color: '#7660FF', + defaultSize: { width: 960, height: 680 }, + }, ]; // OS actions are built-in system actions, not from meta.yaml diff --git a/apps/webuiapps/src/pages/Tavern/actions/constants.ts b/apps/webuiapps/src/pages/Tavern/actions/constants.ts new file mode 100644 index 0000000..71b0d8e --- /dev/null +++ b/apps/webuiapps/src/pages/Tavern/actions/constants.ts @@ -0,0 +1,45 @@ +export const APP_ID = 15; +export const APP_NAME = 'tavern'; + +export const CHARACTERS_DIR = '/characters'; +export const SESSIONS_DIR = '/sessions'; +export const STATE_FILE = '/state.json'; + +export const OperationActions = { + SEND_MESSAGE: 'SEND_MESSAGE', + NEW_SESSION: 'NEW_SESSION', + SWITCH_CHARACTER: 'SWITCH_CHARACTER', + IMPORT_CHARACTER: 'IMPORT_CHARACTER', +} as const; + +export const MutationActions = { + CREATE_CHARACTER: 'CREATE_CHARACTER', + UPDATE_CHARACTER: 'UPDATE_CHARACTER', + DELETE_CHARACTER: 'DELETE_CHARACTER', + CREATE_SESSION: 'CREATE_SESSION', + ADD_MESSAGE: 'ADD_MESSAGE', + DELETE_SESSION: 'DELETE_SESSION', +} as const; + +export const RefreshActions = { + REFRESH_CHARACTERS: 'REFRESH_CHARACTERS', + REFRESH_SESSIONS: 'REFRESH_SESSIONS', + REFRESH_MESSAGES: 'REFRESH_MESSAGES', +} as const; + +export const SystemActions = { + SYNC_STATE: 'SYNC_STATE', +} as const; + +export const ActionTypes = { + ...OperationActions, + ...MutationActions, + ...RefreshActions, + ...SystemActions, +} as const; + +export const DEFAULT_STATE = { + activeCharacterId: null as string | null, + activeSessionId: null as string | null, + userName: 'User', +}; diff --git a/apps/webuiapps/src/pages/Tavern/assets/icon.jpg b/apps/webuiapps/src/pages/Tavern/assets/icon.jpg new file mode 100644 index 0000000..9b929d8 Binary files /dev/null and b/apps/webuiapps/src/pages/Tavern/assets/icon.jpg differ diff --git a/apps/webuiapps/src/pages/Tavern/components/ImportModal.tsx b/apps/webuiapps/src/pages/Tavern/components/ImportModal.tsx new file mode 100644 index 0000000..5a3039a --- /dev/null +++ b/apps/webuiapps/src/pages/Tavern/components/ImportModal.tsx @@ -0,0 +1,251 @@ +import React, { useState, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Upload, Image, FileJson, BookOpen, Code } from 'lucide-react'; +import type { CharacterCard, QuickReply } from '../types'; +import { parseCharacterCardPng, fileToDataUrl } from './cardParser'; +import { generateId } from '@/lib'; +import styles from '../index.module.scss'; + +interface ImportModalProps { + visible: boolean; + onClose: () => void; + onImport: ( + card: CharacterCard, + spriteMap?: Record, + quickReplies?: QuickReply[], + ) => void; +} + +const ImportModal: React.FC = ({ visible, onClose, onImport }) => { + const { t } = useTranslation('tavern'); + const [parsedCard, setParsedCard] = useState(null); + const [error, setError] = useState(''); + const [parsing, setParsing] = useState(false); + const [spriteMap, setSpriteMap] = useState>({}); + const [quickReplies, setQuickReplies] = useState([]); + const [dragOver, setDragOver] = useState(false); + const fileInputRef = useRef(null); + const spriteInputRef = useRef(null); + const qrInputRef = useRef(null); + + const handleFile = useCallback( + async (file: File) => { + if (!file.name.toLowerCase().endsWith('.png')) { + setError('Please select a PNG file'); + return; + } + setParsing(true); + setError(''); + try { + const card = await parseCharacterCardPng(file); + setParsedCard(card); + } catch (err) { + setError(err instanceof Error ? err.message : t('import.error')); + } finally { + setParsing(false); + } + }, + [t], + ); + + const handleFileSelect = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) handleFile(file); + }, + [handleFile], + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); + }, + [handleFile], + ); + + const handleSpriteFiles = useCallback(async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files) return; + const map: Record = {}; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (file.type.startsWith('image/')) { + const name = file.name.replace(/\.[^.]+$/, ''); + map[name] = await fileToDataUrl(file); + } + } + setSpriteMap(map); + }, []); + + const handleQuickRepliesFile = useCallback(async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + const text = await file.text(); + const data = JSON.parse(text); + const qrs: QuickReply[] = []; + const items = Array.isArray(data) ? data : data.quickReplies || data.quick_replies || []; + for (const item of items) { + qrs.push({ + id: generateId(), + label: item.label || item.name || '', + message: item.message || item.mes || '', + requiresInput: item.requiresInput ?? item.requires_input ?? false, + inputPlaceholder: item.inputPlaceholder || item.input_placeholder || '', + }); + } + setQuickReplies(qrs); + } catch { + // Ignore parse errors + } + }, []); + + const handleConfirm = useCallback(() => { + if (!parsedCard) return; + const card = { ...parsedCard }; + if (Object.keys(spriteMap).length > 0) { + card.sprite_map = spriteMap; + } + if (quickReplies.length > 0) { + card.quick_replies = quickReplies; + } + onImport(card, spriteMap, quickReplies); + setParsedCard(null); + setSpriteMap({}); + setQuickReplies([]); + setError(''); + }, [parsedCard, spriteMap, quickReplies, onImport]); + + const handleClose = useCallback(() => { + setParsedCard(null); + setSpriteMap({}); + setQuickReplies([]); + setError(''); + onClose(); + }, [onClose]); + + if (!visible) return null; + + return ( +
+
e.stopPropagation()}> +
{t('import.title')}
+ + {/* Drop Zone */} +
fileInputRef.current?.click()} + onDragOver={(e) => { + e.preventDefault(); + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + > + +
{parsing ? t('import.parsing') : t('import.dragDrop')}
+
+ + + {error && ( +
+ {error} +
+ )} + + {/* Optional: Sprites */} +
+ + +
+ + {/* Optional: Quick Replies */} +
+ + +
+ + {/* Preview */} + {parsedCard && ( +
+
{t('import.preview')}
+
{parsedCard.name}
+
+ + + {t('import.worldBookEntries')}: {parsedCard.world_book.length} + + + + {t('import.regexScripts')}: {parsedCard.regex_scripts.length} + +
+
+ )} + + {/* Actions */} +
+ + +
+
+
+ ); +}; + +export default ImportModal; diff --git a/apps/webuiapps/src/pages/Tavern/components/InputBar.tsx b/apps/webuiapps/src/pages/Tavern/components/InputBar.tsx new file mode 100644 index 0000000..59dfe2e --- /dev/null +++ b/apps/webuiapps/src/pages/Tavern/components/InputBar.tsx @@ -0,0 +1,63 @@ +import React, { useState, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { SendHorizontal } from 'lucide-react'; +import styles from '../index.module.scss'; + +interface InputBarProps { + onSend: (message: string) => void; + disabled: boolean; +} + +const InputBar: React.FC = ({ onSend, disabled }) => { + const { t } = useTranslation('tavern'); + const [value, setValue] = useState(''); + const textareaRef = useRef(null); + + const handleSend = useCallback(() => { + const trimmed = value.trim(); + if (!trimmed) return; + onSend(trimmed); + setValue(''); + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + } + }, [value, onSend]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend], + ); + + const handleInput = useCallback(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${Math.min(el.scrollHeight, 120)}px`; + }, []); + + return ( +
+