├── src ├── tests │ ├── .gitkeep │ └── utils.test.ts ├── create │ ├── PageTabButton.tsx │ ├── QuestionTabButton.tsx │ ├── App.tsx │ ├── CodeEditor.tsx │ ├── TabContent.tsx │ ├── ScriptInputs.tsx │ ├── QuestionComponent.tsx │ └── CreateDialog.tsx ├── types │ ├── global.d.ts │ └── types.ts ├── index.tsx ├── play │ ├── App.tsx │ └── PlayDialog.tsx ├── utils │ ├── png-handlers.ts │ ├── script-utils.ts │ └── data-handlers.ts ├── config.ts ├── styles │ └── main.scss └── Popup.tsx ├── babel.config.json ├── images ├── play-icon.png ├── create-icon.png ├── play-dialog.png ├── create-dialog.png ├── created-card.png ├── question-text.png └── first-message-simple-preview.png ├── scripts ├── readme.md ├── models.py ├── get_story_cards.py └── storycard_to_lorebook.py ├── .gitignore ├── manifest.json ├── .prettierrc.json ├── jest.config.mjs ├── tsconfig.json ├── LICENSE ├── package.json ├── webpack.config.js ├── readme.md └── dist └── style.css /src/tests/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]] 3 | } 4 | -------------------------------------------------------------------------------- /images/play-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmen25124/SillyTavern-Custom-Scenario/HEAD/images/play-icon.png -------------------------------------------------------------------------------- /images/create-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmen25124/SillyTavern-Custom-Scenario/HEAD/images/create-icon.png -------------------------------------------------------------------------------- /images/play-dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmen25124/SillyTavern-Custom-Scenario/HEAD/images/play-dialog.png -------------------------------------------------------------------------------- /images/create-dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmen25124/SillyTavern-Custom-Scenario/HEAD/images/create-dialog.png -------------------------------------------------------------------------------- /images/created-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmen25124/SillyTavern-Custom-Scenario/HEAD/images/created-card.png -------------------------------------------------------------------------------- /images/question-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmen25124/SillyTavern-Custom-Scenario/HEAD/images/question-text.png -------------------------------------------------------------------------------- /images/first-message-simple-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmen25124/SillyTavern-Custom-Scenario/HEAD/images/first-message-simple-preview.png -------------------------------------------------------------------------------- /scripts/readme.md: -------------------------------------------------------------------------------- 1 | # Disclamier 2 | 3 | This scripts are helping me to create a card from AI dungeon. However I'm writing author name and source URL of the card. 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | .vscode/** 4 | !.vscode/extensions.json 5 | .idea/ 6 | public/scripts/extensions/third-party 7 | .aider* 8 | .env 9 | *.map 10 | repopack-output.txt 11 | scripts/*.json 12 | scripts/*.js 13 | scripts/temp/ 14 | __pycache__ 15 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "display_name": "Custom Scenario", 3 | "loading_order": 150, 4 | "requires": [], 5 | "optional": [], 6 | "js": "dist/index.js", 7 | "css": "dist/style.css", 8 | "author": "bmen25124", 9 | "version": "0.4.5", 10 | "homePage": "https://github.com/bmen25124/SillyTavern-Custom-Scenario" 11 | } 12 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "printWidth": 120, 6 | "overrides": [ 7 | { 8 | "files": "*.html", 9 | "options": { 10 | "tabWidth": 4, 11 | "singleQuote": false 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('jest').Config} */ 2 | export default { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | extensionsToTreatAsEsm: ['.ts'], 6 | moduleNameMapper: { 7 | '^(\\.{1,2}/.*)\\.js$': '$1', 8 | }, 9 | transform: { 10 | '^.+\\.tsx?$': [ 11 | 'ts-jest', 12 | { 13 | useESM: true, 14 | }, 15 | ], 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/create/PageTabButton.tsx: -------------------------------------------------------------------------------- 1 | interface PageTabButtonProps { 2 | page: number; 3 | onClick: () => void; 4 | isActive?: boolean; 5 | } 6 | 7 | export const PageTabButton: React.FC = ({ page, onClick, isActive = false }) => { 8 | return ( 9 |
10 | 13 |
14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/create/QuestionTabButton.tsx: -------------------------------------------------------------------------------- 1 | interface QuestionTabButtonProps { 2 | inputId: string; 3 | onSelect: () => void; 4 | onRemove: () => void; 5 | className?: string; 6 | } 7 | 8 | export const QuestionTabButton: React.FC = ({ inputId, onSelect, onRemove, className }) => { 9 | return ( 10 |
11 | 14 | 17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "es2020" 8 | ], 9 | "allowSyntheticDefaultImports": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "ES2020", 13 | "moduleResolution": "node", 14 | "strict": true, 15 | "isolatedModules": true, 16 | "noEmit": false, 17 | "esModuleInterop": true, 18 | "skipLibCheck": true, 19 | "jsx": "react-jsx", 20 | "outDir": "./dist", 21 | "rootDir": "./src" 22 | }, 23 | "include": [ 24 | "src/**/*", 25 | "types/**/*" 26 | ], 27 | "exclude": [ 28 | "node_modules", 29 | "dist" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | import { FullExportData } from './types/types.js'; 2 | 3 | declare global { 4 | interface SillyTavernContext { 5 | characters: FullExportData[]; 6 | createCharacterData: { extensions: Record }; 7 | convertCharacterBook: (characterBook: any) => { 8 | entries: {}; 9 | originalData: any; 10 | }; 11 | updateWorldInfoList: () => Promise; 12 | loadWorldInfo: (name: string) => Promise; 13 | saveWorldInfo: (name: string, data: any, immediately?: boolean) => Promise; 14 | humanizedDateTime: () => string; 15 | getCharacters: () => Promise; 16 | uuidv4: () => string; 17 | getRequestHeaders: () => { 18 | 'Content-Type': string; 19 | 'X-CSRF-Token': any; 20 | }; 21 | } 22 | 23 | const SillyTavern: { 24 | getContext(): SillyTavernContext; 25 | // Add other methods as needed 26 | }; 27 | } 28 | 29 | export {}; 30 | -------------------------------------------------------------------------------- /src/create/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Popup, POPUP_TYPE } from '../Popup'; 3 | import { CreateDialog } from './CreateDialog'; 4 | 5 | interface AppProps {} 6 | 7 | function App(props: AppProps) { 8 | const [showPopup, setShowPopup] = useState(false); 9 | 10 | const handleClick = () => { 11 | setShowPopup(true); 12 | }; 13 | 14 | const handleComplete = (value: any) => { 15 | setShowPopup(false); 16 | }; 17 | 18 | return ( 19 | <> 20 |
25 | {showPopup && ( 26 | } 28 | type={POPUP_TYPE.DISPLAY} 29 | options={{ 30 | large: true, 31 | wide: true, 32 | }} 33 | onComplete={handleComplete} 34 | /> 35 | )} 36 | 37 | ); 38 | } 39 | 40 | export default App; 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 bmen25124 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import CreateApp from './create/App'; 4 | import PlayApp from './play/App'; 5 | 6 | { 7 | const characterButtons = $('.form_create_bottom_buttons_block'); 8 | 9 | if (!characterButtons || characterButtons.length === 0) { 10 | throw new Error("Could not find root container element 'extensions_settings'"); 11 | } 12 | 13 | const rootElement = document.createElement('div'); 14 | characterButtons.prepend(rootElement); 15 | 16 | const root = ReactDOM.createRoot(rootElement); 17 | root.render( 18 | 19 | 20 | , 21 | ); 22 | } 23 | 24 | { 25 | const searchButtons = document.querySelector('#rm_buttons_container') ?? document.querySelector('#form_character_search_form'); 26 | if (!searchButtons) { 27 | throw new Error("Could not find root container elements 'rm_buttons_container'/'form_character_search_form'"); 28 | } 29 | const rootElement = document.createElement('div'); 30 | $(searchButtons).prepend(rootElement); 31 | const root = ReactDOM.createRoot(rootElement); 32 | root.render( 33 | 34 | 35 | , 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /scripts/models.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict, List, Dict, Optional 2 | 3 | class Scenario(TypedDict): 4 | id: str 5 | shortId: str 6 | title: str 7 | parentScenarioId: str 8 | deletedAt: str | None 9 | __typename: str 10 | 11 | class StoryCard(TypedDict, total=False): 12 | """Type definition for story card input format.""" 13 | keys: str 14 | value: str 15 | title: str 16 | description: str 17 | type: str 18 | originalScenario: Scenario 19 | 20 | class LoreBookEntry(TypedDict): 21 | """Type definition for lorebook entry format.""" 22 | uid: int 23 | key: List[str] 24 | keysecondary: List[str] 25 | comment: str 26 | content: str 27 | constant: bool 28 | vectorized: bool 29 | selective: bool 30 | selectiveLogic: int 31 | addMemo: bool 32 | order: int 33 | position: int 34 | disable: bool 35 | excludeRecursion: bool 36 | preventRecursion: bool 37 | delayUntilRecursion: bool 38 | probability: int 39 | useProbability: bool 40 | depth: int 41 | group: str 42 | groupOverride: bool 43 | groupWeight: int 44 | scanDepth: Optional[int] 45 | caseSensitive: Optional[bool] 46 | matchWholeWords: Optional[bool] 47 | useGroupScoring: Optional[bool] 48 | automationId: str 49 | role: int 50 | sticky: int 51 | cooldown: int 52 | delay: int 53 | displayIndex: int 54 | 55 | class LoreBook(TypedDict): 56 | """Type definition for the complete lorebook format.""" 57 | entries: Dict[str, LoreBookEntry] 58 | 59 | RecursionRule = List[tuple[str, str]] 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-web-project", 3 | "version": "1.0.0", 4 | "description": "A web application using pure JS and SCSS.", 5 | "main": "src/scripts/main.js", 6 | "scripts": { 7 | "dev": "cross-env NODE_ENV=development sass src/styles/main.scss dist/style.css --source-map & cross-env NODE_ENV=development webpack --mode development --watch", 8 | "build": "cross-env NODE_ENV=production sass src/styles/main.scss dist/style.css --no-source-map && cross-env NODE_ENV=production webpack --mode production", 9 | "test": "jest", 10 | "prettify": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "@babel/core": "^7.26.0", 17 | "@babel/plugin-proposal-private-property-in-object": "^7.21.11", 18 | "@babel/preset-env": "^7.24.0", 19 | "@babel/preset-react": "^7.23.3", 20 | "@babel/preset-typescript": "^7.26.0", 21 | "@types/jest": "^29.5.14", 22 | "@types/jquery": "^3.5.32", 23 | "@types/node": "^22.13.1", 24 | "@types/png-chunk-text": "^1.0.3", 25 | "@types/png-chunks-encode": "^1.0.2", 26 | "@types/png-chunks-extract": "^1.0.2", 27 | "@types/react": "^18.2.0", 28 | "@types/react-dom": "^18.2.0", 29 | "babel-jest": "^29.7.0", 30 | "babel-loader": "^9.1.3", 31 | "cross-env": "^7.0.3", 32 | "eslint-config-react-app": "^7.0.1", 33 | "jest": "^29.7.0", 34 | "jquery": "^3.7.1", 35 | "prettier": "^3.4.2", 36 | "sass": "^1.83.4", 37 | "terser-webpack-plugin": "^5.3.10", 38 | "ts-jest": "^29.2.5", 39 | "ts-loader": "^9.5.2", 40 | "typescript": "^5.7.3", 41 | "webpack-cli": "^5.1.4" 42 | }, 43 | "dependencies": { 44 | "png-chunk-text": "^1.0.0", 45 | "png-chunks-encode": "^1.0.0", 46 | "png-chunks-extract": "^1.0.0", 47 | "react": "^18.2.0", 48 | "react-dom": "^18.2.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/play/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import { Popup, POPUP_TYPE } from '../Popup'; 3 | import { PlayDialog, PlayDialogRef } from './PlayDialog'; 4 | 5 | interface AppProps {} 6 | 7 | function App(props: AppProps) { 8 | const [showPopup, setShowPopup] = useState(false); 9 | const dialogRef = useRef(null); 10 | const [file, setFile] = useState(null); 11 | 12 | const handleClick = () => { 13 | const fileInput = document.createElement('input'); 14 | fileInput.type = 'file'; 15 | fileInput.accept = '.json, .png'; 16 | fileInput.style.display = 'none'; 17 | fileInput.addEventListener('change', async (e) => { 18 | // @ts-ignore 19 | const file = e.target.files?.[0] as File | null; 20 | if (file) { 21 | setShowPopup(true); 22 | setFile(file); 23 | } 24 | 25 | fileInput.remove(); 26 | }); 27 | document.body.appendChild(fileInput); 28 | fileInput.click(); 29 | }; 30 | 31 | useEffect(() => { 32 | if (file && showPopup) { 33 | dialogRef.current?.handleFileSelect(file); 34 | } 35 | }, [file, showPopup]); 36 | 37 | return ( 38 | <> 39 |
40 | {showPopup && ( 41 | setShowPopup(false)} />} 43 | type={POPUP_TYPE.TEXT} 44 | options={{ 45 | okButton: true, 46 | cancelButton: true, 47 | wider: true, 48 | onClosing: async (popup) => { 49 | if (popup.result === 1 && dialogRef.current) { 50 | // OK button clicked 51 | const valid = await dialogRef.current.validateAndPlay(); 52 | return valid; 53 | } 54 | return true; // Allow closing for other cases (Cancel, X button) 55 | }, 56 | }} 57 | onComplete={() => setShowPopup(false)} 58 | /> 59 | )} 60 | 61 | ); 62 | } 63 | 64 | export default App; 65 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const TerserPlugin = require('terser-webpack-plugin'); 3 | 4 | module.exports = { 5 | mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', 6 | entry: path.join(__dirname, 'src/index.tsx'), 7 | output: { 8 | path: path.join(__dirname, 'dist/'), 9 | filename: 'index.js', 10 | library: { 11 | type: 'module' 12 | } 13 | }, 14 | experiments: { 15 | outputModule: true 16 | }, 17 | externalsType: 'module', 18 | externals: [ 19 | function({ context, request }, callback) { 20 | if (request.includes('../../../..')) { 21 | // Return the path as an external module import 22 | return callback(null, `module ${request}`); 23 | } 24 | // Continue without externalizing the import 25 | callback(); 26 | }, 27 | ], 28 | resolve: { 29 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 30 | }, 31 | devtool: process.env.NODE_ENV === 'production' ? false : 'source-map', 32 | module: { 33 | rules: [ 34 | { 35 | test: /\.(ts|tsx|js|jsx)$/, 36 | exclude: /node_modules/, 37 | use: [ 38 | { 39 | loader: 'babel-loader', 40 | options: { 41 | cacheDirectory: true, 42 | presets: [ 43 | '@babel/preset-env', 44 | ['@babel/preset-react', { runtime: 'automatic' }], 45 | '@babel/preset-typescript', 46 | ], 47 | }, 48 | }, 49 | { 50 | loader: 'ts-loader', 51 | options: { 52 | transpileOnly: process.env.NODE_ENV !== 'production', 53 | }, 54 | }, 55 | ], 56 | }, 57 | ], 58 | }, 59 | optimization: { 60 | minimize: process.env.NODE_ENV === 'production', 61 | minimizer: [new TerserPlugin({ 62 | extractComments: false, 63 | })], 64 | }, 65 | } 66 | -------------------------------------------------------------------------------- /src/tests/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { interpolateText } from '../utils/script-utils'; 2 | import { describe, expect, test } from '@jest/globals'; 3 | 4 | describe('interpolateText', () => { 5 | test('should replace variables in template string', () => { 6 | const template = 'Hello {{name}}!'; 7 | const variables = { name: 'World' }; 8 | expect(interpolateText(template, variables, 'remove')).toBe('Hello World!'); 9 | }); 10 | 11 | test('should keep variable syntax when type is variableName and value is empty', () => { 12 | const template = 'Hello {{name}}!'; 13 | const variables = { name: '' }; 14 | expect(interpolateText(template, variables, 'variableName')).toBe('Hello {{name}}!'); 15 | }); 16 | 17 | test('should remove variable when type is remove and value is empty', () => { 18 | const template = 'Hello {{name}}!'; 19 | const variables = { name: '' }; 20 | expect(interpolateText(template, variables, 'remove')).toBe('Hello !'); 21 | }); 22 | 23 | test('should handle recursive interpolation', () => { 24 | const template = 'Hello {{message}}!'; 25 | const variables = { 26 | message: 'dear {{name}}', 27 | name: 'World', 28 | }; 29 | expect(interpolateText(template, variables, 'remove')).toBe('Hello dear World!'); 30 | }); 31 | 32 | test('should handle object with label property', () => { 33 | const template = 'Selected: {{option}}'; 34 | const variables = { 35 | option: { label: 'Choice 1', value: 'choice1' }, 36 | }; 37 | expect(interpolateText(template, variables, 'remove')).toBe('Selected: Choice 1'); 38 | }); 39 | 40 | test('should handle undefined values', () => { 41 | const template = 'Hello {{name}}!'; 42 | const variables = {}; 43 | expect(interpolateText(template, variables, 'remove')).toBe('Hello !'); 44 | }); 45 | 46 | test('should handle missing values', () => { 47 | const template = 'Hello {{name}}!'; 48 | const variables = { name: undefined as unknown as string }; 49 | expect(interpolateText(template, variables, 'remove')).toBe('Hello !'); 50 | }); 51 | 52 | test('should trim string values', () => { 53 | const template = 'Hello {{name}}!'; 54 | const variables = { name: ' World ' }; 55 | expect(interpolateText(template, variables, 'remove')).toBe('Hello World!'); 56 | }); 57 | 58 | test('should handle boolean values', () => { 59 | const template = 'Value is {{flag}}'; 60 | const variables = { flag: true }; 61 | expect(interpolateText(template, variables, 'remove')).toBe('Value is true'); 62 | }); 63 | 64 | test('should handle multiple variables', () => { 65 | const template = '{{greeting}} {{name}}! How is {{location}}?'; 66 | const variables = { 67 | greeting: 'Hello', 68 | name: 'World', 69 | location: 'Earth', 70 | }; 71 | expect(interpolateText(template, variables, 'remove')).toBe('Hello World! How is Earth?'); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/create/CodeEditor.tsx: -------------------------------------------------------------------------------- 1 | import { hljs } from '../config'; 2 | import React, { useState } from 'react'; 3 | 4 | export const CodeEditor: React.FC<{ 5 | value: string; 6 | onChange: (value: string) => void; 7 | rows: number; 8 | placeholder?: string; 9 | language?: 'javascript' | 'custom-scenario-script'; 10 | isHighlightMode: boolean; 11 | onHighlightModeChange: (value: boolean) => void; 12 | }> = ({ value, onChange, rows, placeholder, language = 'javascript', isHighlightMode, onHighlightModeChange }) => { 13 | // Register custom language if not already registered 14 | if (language === 'custom-scenario-script' && !hljs.getLanguage('custom-scenario-script')) { 15 | hljs.registerLanguage('custom-scenario-script', () => ({ 16 | case_insensitive: true, 17 | contains: [ 18 | { 19 | className: 'variable', 20 | begin: '{{', 21 | end: '}}', 22 | }, 23 | ], 24 | })); 25 | } 26 | 27 | // Highlight the code using hljs 28 | const highlightedCode = React.useMemo(() => { 29 | if (!value) return `${placeholder || 'Enter your code here...'}`; 30 | const result = hljs.highlight(value, { language }); 31 | return result.value; 32 | }, [value, language, placeholder]); 33 | 34 | return ( 35 |
36 | 60 |
68 | {isHighlightMode ? ( 69 |
70 |             
71 |           
72 | ) : ( 73 | 100 |
101 |
102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /src/create/ScriptInputs.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { CoreTab, QuestionType, ScriptInputValues } from '../types/types'; 3 | 4 | export interface ScriptInput { 5 | id: string; 6 | type: QuestionType; 7 | defaultValue: string | boolean; 8 | selectOptions?: Array<{ value: string; label: string }>; 9 | } 10 | 11 | interface ScriptInputsProps { 12 | type: CoreTab; 13 | inputs: ScriptInput[]; 14 | values?: ScriptInputValues; 15 | onChange?: (inputId: string, value: string | boolean) => void; 16 | isQuestionInput?: boolean; 17 | questionId?: string; 18 | } 19 | 20 | function formatValue(value: boolean | string): string { 21 | return typeof value === 'boolean' ? value.toString() : value; 22 | } 23 | 24 | export const ScriptInputs: React.FC = ({ 25 | type, 26 | inputs, 27 | values, 28 | onChange, 29 | isQuestionInput = false, 30 | questionId, 31 | }) => { 32 | React.useEffect(() => { 33 | const initializedValues: Record = {}; 34 | inputs.forEach((input) => { 35 | if (isQuestionInput && questionId && values?.question) { 36 | const questionValues = values.question[questionId] || {}; 37 | const value = questionValues[input.id] as string | undefined; 38 | initializedValues[input.id] = value ?? formatValue(input.defaultValue); 39 | } else if (values?.[type]) { 40 | const typeValues = values[type] as Record; 41 | const value = typeValues[input.id]; 42 | initializedValues[input.id] = value ?? formatValue(input.defaultValue); 43 | } else { 44 | initializedValues[input.id] = formatValue(input.defaultValue); 45 | } 46 | }); 47 | }, [inputs, values, type, isQuestionInput, questionId]); 48 | 49 | const handleChange = (inputId: string, value: string | boolean) => { 50 | onChange?.(inputId, value); 51 | }; 52 | 53 | return ( 54 |
55 | {inputs.map((input) => { 56 | const helpText = 57 | input.type === 'select' 58 | ? `Access using: variables.${input.id}.value and variables.${input.id}.label` 59 | : `Access using: variables.${input.id}`; 60 | 61 | return ( 62 |
63 | 66 | {input.type === 'checkbox' ? ( 67 | handleChange(input.id, e.target.checked)} 76 | title={helpText} 77 | /> 78 | ) : input.type === 'select' ? ( 79 | 96 | ) : ( 97 | handleChange(input.id, e.target.value)} 106 | title={helpText} 107 | /> 108 | )} 109 |
110 | ); 111 | })} 112 |
113 | ); 114 | }; 115 | -------------------------------------------------------------------------------- /src/utils/script-utils.ts: -------------------------------------------------------------------------------- 1 | import { st_loadWorldInfo } from '../config'; 2 | 3 | /** 4 | * @param emptyStrategy if it's variableName, null/undefined/empty values would be shown as `{{variable}}`. Otherwise, it will show as empty strings. 5 | */ 6 | export async function executeMainScript( 7 | script: string, 8 | answers: Record, 9 | emptyStrategy: 'variableName' | 'remove', 10 | worldName: string | undefined, 11 | ): Promise> { 12 | if (!script) return answers; 13 | // Clone answers to avoid modifying the original object 14 | const variables = JSON.parse(JSON.stringify(answers)); 15 | 16 | // First interpolate any variables in the script 17 | const interpolatedScript = interpolateText(script, variables, emptyStrategy); 18 | 19 | // Create a function that returns all variables 20 | const scriptFunction = new Function( 21 | 'variables', 22 | 'world', 23 | ` 24 | return (async () => { 25 | ${interpolatedScript} 26 | return await Promise.resolve(variables); 27 | })(); 28 | `, 29 | ); 30 | 31 | return scriptFunction(variables, { 32 | getAll: async (params: { name?: string; keyword: string }) => 33 | await getWorldInfoContent({ 34 | name: params.name ?? worldName, 35 | keyword: params.keyword, 36 | }), 37 | getFirst: async (params: { name?: string; keyword: string }) => 38 | await getFirstWorldInfoContent({ 39 | name: params.name ?? worldName, 40 | keyword: params.keyword, 41 | }), 42 | }); 43 | } 44 | 45 | /** 46 | * @param emptyStrategy if it's variableName, null/undefined/empty values would be shown as `{{variable}}`. Otherwise, it will show as empty strings. 47 | */ 48 | export function executeShowScript( 49 | script: string, 50 | answers: Record, 51 | emptyStrategy: 'variableName' | 'remove', 52 | _worldName: string | undefined, 53 | ): boolean { 54 | if (!script) return true; 55 | // Clone answers to avoid modifying the original object 56 | const variables = JSON.parse(JSON.stringify(answers)); 57 | 58 | // First interpolate any variables in the script 59 | const interpolatedScript = interpolateText(script, variables, emptyStrategy); 60 | 61 | // Create a function that returns all variables 62 | const scriptFunction = new Function( 63 | 'variables', 64 | ` 65 | ${interpolatedScript} 66 | `, 67 | ); 68 | 69 | return scriptFunction(variables); 70 | } 71 | 72 | /** 73 | * @param emptyStrategy if it's variableName, null/undefined/empty values would be shown as `{{variable}}`. Otherwise, it will show as empty strings. 74 | */ 75 | export function interpolateText( 76 | template: string, 77 | variables: Record, 78 | emptyStrategy: 'variableName' | 'remove', 79 | ): string { 80 | const newVariables = JSON.parse(JSON.stringify(variables)); 81 | for (const [key, value] of Object.entries(variables)) { 82 | if (value && typeof value === 'object' && value.hasOwnProperty('label')) { 83 | newVariables[key] = value.label; 84 | } 85 | } 86 | 87 | let result = template; 88 | const regex = /\{\{([^}]+)\}\}/g; 89 | let maxIterations = 100; // Prevent infinite recursion 90 | let iteration = 0; 91 | 92 | while (result.includes('{{') && iteration < maxIterations) { 93 | result = result.replace(regex, (match, key) => { 94 | let value = newVariables[key]; 95 | if (typeof value === 'string') { 96 | value = value.trim(); 97 | } 98 | if (emptyStrategy === 'variableName' && (value === undefined || value === null || value === '')) { 99 | return match; // Keep original if variable is undefined, null, or empty 100 | } else if (!value) { 101 | return ''; 102 | } 103 | // Recursively interpolate if the variable contains template syntax 104 | return value.toString().includes('{{') ? interpolateText(value.toString(), newVariables, emptyStrategy) : value; 105 | }); 106 | iteration++; 107 | } 108 | 109 | return result; 110 | } 111 | 112 | interface WIEntry { 113 | id: string; 114 | keys: string[]; 115 | content: string; 116 | } 117 | 118 | /** 119 | * Checks if keyword is matching the entry keys. 120 | * @returns null if world info is not found. 121 | */ 122 | export async function getWorldInfoContent(params: { name?: string; keyword: string }): Promise { 123 | if (!params.name) { 124 | return null; 125 | } 126 | const worldInfo = await st_loadWorldInfo(params.name); 127 | if (!worldInfo) { 128 | return null; 129 | } 130 | 131 | const result: WIEntry[] = []; 132 | for (const entry of Object.values(worldInfo.entries)) { 133 | for (const key of entry.key) { 134 | if (key.toLowerCase().includes(params.keyword.toLowerCase())) { 135 | result.push({ 136 | id: entry.uid, 137 | keys: entry.key, 138 | content: entry.content, 139 | }); 140 | break; 141 | } 142 | } 143 | } 144 | 145 | return result; 146 | } 147 | 148 | export async function getFirstWorldInfoContent(params: { name?: string; keyword: string }): Promise { 149 | const result = await getWorldInfoContent(params); 150 | return result?.[0] ?? null; 151 | } 152 | -------------------------------------------------------------------------------- /scripts/storycard_to_lorebook.py: -------------------------------------------------------------------------------- 1 | ### Converts story cards(AI Dungeon) to lorebook(SillyTavern) format. 2 | ### Usage: python storycard_to_lorebook.py input.json output.json --remove-braces --description-in-comment --prevent-recursion="type:character" --exclude-recursion="type:character" 3 | 4 | import json 5 | import argparse 6 | from typing import Optional, List, Any, cast, Union 7 | 8 | from models import StoryCard, LoreBook, RecursionRule, LoreBookEntry 9 | 10 | 11 | def parse_recursion_rule(rule_str: Optional[str]) -> Optional[RecursionRule]: 12 | """Parse recursion rule string into a list of field-value tuples.""" 13 | if not rule_str: 14 | return None 15 | rules = [] 16 | for rule in rule_str.split(","): 17 | if ":" in rule: 18 | field, value = rule.split(":", 1) 19 | rules.append((field.strip(), value.strip())) 20 | return rules 21 | 22 | 23 | def matches_rules( 24 | card: Union[StoryCard, dict[str, Any]], rules: Optional[RecursionRule] 25 | ) -> bool: 26 | """Check if card matches any of the provided rules.""" 27 | if not rules: 28 | return False 29 | for field, value in rules: 30 | card_value = card.get(field) 31 | if card_value is not None and str(card_value) == value: 32 | return True 33 | return False 34 | 35 | 36 | def convert_story_cards_to_lorebook( 37 | story_cards: List[StoryCard], 38 | remove_braces: bool = False, 39 | description_in_comment: bool = False, 40 | exclude_recursion: Optional[str] = None, 41 | prevent_recursion: Optional[str] = None, 42 | ) -> LoreBook: 43 | """Convert story cards to lorebook format.""" 44 | lorebook: LoreBook = {"entries": {}} 45 | 46 | # Parse recursion rules 47 | exclude_rules = parse_recursion_rule(exclude_recursion) 48 | prevent_rules = parse_recursion_rule(prevent_recursion) 49 | 50 | for index, card in enumerate(story_cards): 51 | # Split keys string into list and trim each key 52 | keys = ( 53 | [key.strip() for key in card.get("keys", "").split(",")] 54 | if card.get("keys") 55 | else [] 56 | ) 57 | 58 | # Handle value text 59 | content = card.get("value", "") 60 | if remove_braces: 61 | if content.startswith("{") and content.endswith("}"): 62 | content = content[1:-1] 63 | 64 | # Handle comment 65 | comment = card.get("title", "") 66 | if description_in_comment and card.get("description"): 67 | comment = f"{card.get('title', '')} ({card.get('description', '').strip()})" 68 | 69 | # Create lorebook entry 70 | entry: LoreBookEntry = { 71 | "uid": index, 72 | "key": keys, 73 | "keysecondary": [], 74 | "comment": comment, 75 | "content": content, 76 | "constant": False, 77 | "vectorized": False, 78 | "selective": True, 79 | "selectiveLogic": 0, 80 | "addMemo": True, 81 | "order": 100, 82 | "position": 4, 83 | "disable": False, 84 | "excludeRecursion": matches_rules(card, exclude_rules), 85 | "preventRecursion": matches_rules(card, prevent_rules), 86 | "delayUntilRecursion": False, 87 | "probability": 100, 88 | "useProbability": True, 89 | "depth": 0, 90 | "group": "", 91 | "groupOverride": False, 92 | "groupWeight": 100, 93 | "scanDepth": None, 94 | "caseSensitive": None, 95 | "matchWholeWords": None, 96 | "useGroupScoring": None, 97 | "automationId": "", 98 | "role": 0, 99 | "sticky": 0, 100 | "cooldown": 0, 101 | "delay": 0, 102 | "displayIndex": 0, 103 | } 104 | 105 | lorebook["entries"][str(index)] = entry 106 | 107 | return lorebook 108 | 109 | 110 | def main() -> None: 111 | parser = argparse.ArgumentParser( 112 | description="Convert story cards to lorebook format" 113 | ) 114 | parser.add_argument("input", help="Input story cards JSON file") 115 | parser.add_argument("output", help="Output lorebook JSON file") 116 | parser.add_argument( 117 | "--remove-braces", action="store_true", help="Remove curly braces from content" 118 | ) 119 | parser.add_argument( 120 | "--description-in-comment", 121 | action="store_true", 122 | help="Include description in comment if available", 123 | ) 124 | parser.add_argument( 125 | "--exclude-recursion", 126 | help="Set excludeRecursion for entries matching field:value pairs (comma-separated)", 127 | ) 128 | parser.add_argument( 129 | "--prevent-recursion", 130 | help="Set preventRecursion for entries matching field:value pairs (comma-separated)", 131 | ) 132 | 133 | args = parser.parse_args() 134 | 135 | # Read story cards from file 136 | with open(args.input, "r", encoding="utf-8") as f: 137 | story_cards = cast(List[StoryCard], json.load(f)) 138 | 139 | # Convert to lorebook format 140 | lorebook = convert_story_cards_to_lorebook( 141 | story_cards, 142 | args.remove_braces, 143 | args.description_in_comment, 144 | args.exclude_recursion, 145 | args.prevent_recursion, 146 | ) 147 | 148 | # Save to file 149 | with open(args.output, "w", encoding="utf-8") as f: 150 | json.dump(lorebook, f, indent=4) 151 | 152 | 153 | if __name__ == "__main__": 154 | main() 155 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # SillyTavern Custom Scenario 2 | 3 | A [SillyTavern](https://docs.sillytavern.app/) extension that allows you to create and play interactive character cards. Want to customize the scenario before starting? Define character traits, starting location, or any other key element through a series of questions! 4 | 5 | ## Key Features 6 | 7 | * **Custom Scenarios:** Design scenarios with tailored question prompts. 8 | * **Dynamic Variables:** Use the answers to those questions as variables within the description, first message, personality, scenario notes, and character notes. 9 | * **Simple Scripting (JavaScript):** Add dynamic logic and complex variable manipulation. 10 | * **Import/Export:** Share and backup your creations as JSON or PNG files. 11 | * **Question Types:** Supports Text input, Dropdown select, and Checkbox options. 12 | 13 | ## Installation 14 | 15 | Install via the SillyTavern extension installer: 16 | 17 | ```txt 18 | https://github.com/bmen25124/SillyTavern-Custom-Scenario 19 | ``` 20 | 21 | ## How to Use 22 | 23 | ### 1. Create a Scenario 24 | 25 | 1. Click the puzzle icon on the character create/edit sidebar. 26 | 27 | ![Create Icon](images/create-icon.png) 28 | 29 | 2. Fill out the form in the "Create Scenario" dialog. 30 | 31 | ![Create Dialog](images/create-dialog.png) 32 | ![Question - Text](images/question-text.png) 33 | 34 | 3. Open the *Script* accordion and test your scripting logic with the "Preview" button. 35 | 36 | ![Simple Preview in First Message](images/first-message-simple-preview.png) 37 | 38 | 4. Export your completed scenario. 39 | 40 | ### 2. Play a Scenario 41 | 42 | 1. Click the play icon on the characters sidebar and select the JSON/PNG file containing your scenario. 43 | 44 | ![Play Icon](images/play-icon.png) 45 | 46 | 2. Fill in the inputs in the "Play Scenario" dialog. 47 | 48 | ![Play Dialog](images/play-dialog.png) 49 | 50 | 3. Your character card will be created with the scenario applied! 51 | 52 | ![Created Card](images/created-card.png) 53 | 54 | ## Example Cards 55 | 56 | Check out the [rentry page (half NSFW)](https://rentry.co/custom-scenario-creator-examples) for example scenarios. Import these into the "Create Scenario" dialog to see how the scripting works. 57 | 58 | ## Simple Scripting 59 | 60 | You can use basic JavaScript to manipulate variables based on user input. 61 | 62 | **Example:** 63 | 64 | If your description is: 65 | 66 | ``` 67 | {{user}} just received a package with a gift from an unknown sender. The package is labeled as containing {{gift}}. 68 | 69 | You also received a card with the following message: {{occasionMessage}} 70 | ``` 71 | 72 | And the user answers the questions: 73 | 74 | ```yml 75 | gift: "a book" 76 | message: "birthday" 77 | # As you can see, there is no `occasionMessage` defined yet. 78 | ``` 79 | 80 | You can use a script to set the `occasionMessage`: 81 | 82 | ```javascript 83 | variables.occasionMessage = `Happy {{message}}! Enjoy your new {{gift}}`; 84 | ``` 85 | 86 | or 87 | 88 | ```javascript 89 | variables.occasionMessage = `Happy ${variables.message}! Enjoy your new ${variables.gift}`; 90 | ``` 91 | 92 | or 93 | 94 | ```javascript 95 | variables.occasionMessage = "Happy " + variables.message + "! Enjoy your new " + variables.gift; 96 | ``` 97 | 98 | The output will be: 99 | 100 | ``` 101 | {{user}} just received a package with a gift from an unknown sender. The package is labeled as containing a book. 102 | 103 | You also received a card with the following message: Happy birthday! Enjoy your new book 104 | ``` 105 | 106 | ## Scripting Details 107 | 108 | * `variables` is an object that holds all the user-provided answers (variables). 109 | * All variables can be accessed and modified within your scripts. 110 | * **Example Usage (Question ID: `gift`):** 111 | 112 | * **Text Input:** `variables.gift` (The text entered by the user) 113 | * **Dropdown Select:** `variables.gift.value` (The selected value) and `variables.gift.label` (The displayed label). The `label` is used when creating the card. 114 | * **Checkbox:** `variables.gift` (A boolean: `true` if checked, `false` if not). 115 | 116 | * **Show Script:** This script determines whether a question is displayed in the "Play Scenario" dialog. Example: `return variables.gift === "birthday"` will only show the question if the `gift` variable is "birthday". 117 | * In the preview, empty variables are displayed as `{{variable}}`. In the created character card, these empty variables are not shown. 118 | * **Accessing Lorebook Entries:** 119 | 120 | * **Get a single Lorebook entry:** `await world.getFirst({name?: string, keyword: string})`. *name* is optional, and defaults to the character lorebook. 121 | 122 | ```javascript 123 | const info = await world.getFirst({keyword: "triggerWord"}); 124 | if (info) { 125 | variables.f_companion_content = info.content; 126 | } 127 | ``` 128 | 129 | * **Get all Lorebook entries:** `await world.getAll({name?: string, keyword: string})`. *name* is optional, and defaults to the character lorebook. 130 | 131 | ```javascript 132 | const infos = await world.getAll({keyword: "triggerWord"}); 133 | if (infos && infos.length > 0) { 134 | variables.f_companion_content = infos[0].content; 135 | } 136 | ``` 137 | 138 | ## FAQ 139 | 140 | ### Why did you create this? 141 | 142 | I was inspired by the scenario system in [AIDungeon](https://play.aidungeon.com/). See this [Reddit post](https://www.reddit.com/r/SillyTavernAI/comments/1i59jem/scenario_system_similar_to_ai_dungeon_nsfw_for/) for an example. 143 | 144 | ### Why is the version number *0.4.5*? 145 | 146 | The version number reflects UI changes, not core functionality updates. 147 | 148 | ## Known Issues 149 | 150 | * Character tags are not currently imported from the scenario files. I plan to add an extension setting to enable/disable the "Import Tags" dialog in a future update. 151 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { 3 | saveCharacterDebounced, 4 | getThumbnailUrl, 5 | // @ts-ignore 6 | } from '../../../../../script.js'; 7 | // @ts-ignore 8 | import { Popper, hljs } from '../../../../../lib.js'; 9 | 10 | // @ts-ignore 11 | import dialogPolyfill from '../../../../../lib/dialog-polyfill.esm.js'; 12 | // @ts-ignore 13 | // import { shouldSendOnEnter } from '../../../../RossAscends-mods.js'; 14 | // @ts-ignore 15 | import { fixToastrForDialogs, Popup as STPopup } from '../../../../popup.js'; 16 | // @ts-ignore 17 | import { removeFromArray, runAfterAnimation } from '../../../../utils.js'; 18 | 19 | import { 20 | world_names, 21 | loadWorldInfo, 22 | setWorldInfoButtonClass, 23 | // @ts-ignore 24 | } from '../../../../world-info.js'; 25 | 26 | import { FullExportData } from './types/types.js'; 27 | 28 | export const extensionName = 'SillyTavern-Custom-Scenario'; 29 | export const extensionVersion = '0.4.5'; 30 | 31 | /** 32 | * Sends an echo message using the SlashCommandParser's echo command. 33 | */ 34 | export async function st_echo(severity: string, message: string): Promise { 35 | // @ts-ignore 36 | await SillyTavern.getContext().SlashCommandParser.commands['echo'].callback({ severity: severity }, message); 37 | } 38 | 39 | /** 40 | * Executes the 'go' slash command to switch to a specified character. 41 | */ 42 | export async function st_go(name: string): Promise { 43 | // @ts-ignore 44 | await SillyTavern.getContext().SlashCommandParser.commands['go'].callback(undefined, name); 45 | } 46 | 47 | export function st_getRequestHeaders(): Partial<{ 48 | 'Content-Type': string; 49 | 'X-CSRF-Token': string; 50 | }> { 51 | return SillyTavern.getContext().getRequestHeaders(); 52 | } 53 | 54 | export function st_getcreateCharacterData(): { 55 | extensions: Record; 56 | } { 57 | return SillyTavern.getContext().createCharacterData; 58 | } 59 | 60 | export async function st_updateCharacters(): Promise { 61 | return await SillyTavern.getContext().getCharacters(); 62 | } 63 | 64 | export function st_humanizedDateTime(): string { 65 | return SillyTavern.getContext().humanizedDateTime(); 66 | } 67 | 68 | export function st_createPopper( 69 | reference: HTMLElement, 70 | popper: HTMLElement, 71 | options?: { 72 | placement: 73 | | 'top-start' 74 | | 'top-end' 75 | | 'bottom-start' 76 | | 'bottom-end' 77 | | 'right-start' 78 | | 'right-end' 79 | | 'left-start' 80 | | 'left-end'; 81 | }, 82 | ): { update: () => void } { 83 | return Popper.createPopper(reference, popper, options); 84 | } 85 | 86 | /** 87 | * Note: It doesn't contain the scenario data. 88 | */ 89 | export function st_getCharacters(): FullExportData[] { 90 | return SillyTavern.getContext().characters; 91 | } 92 | 93 | export function st_saveCharacterDebounced() { 94 | return saveCharacterDebounced(); 95 | } 96 | 97 | export function st_getWorldNames(): string[] { 98 | return world_names; 99 | } 100 | 101 | export async function st_loadWorldInfo(worldName: string): Promise<{ entries: any[]; name: string } | null> { 102 | return await loadWorldInfo(worldName); 103 | } 104 | 105 | // https://github.com/SillyTavern/SillyTavern/blob/999da4945aaf1da6f6d4ff1e9e314c11f0ccfeb1/src/endpoints/characters.js#L466 106 | export function st_server_convertWorldInfoToCharacterBook( 107 | name: string, 108 | entries: any[], 109 | ): { entries: any[]; name: string } { 110 | const result: { entries: any[]; name: string } = { entries: [], name }; 111 | 112 | for (const index in entries) { 113 | const entry = entries[index]; 114 | 115 | const originalEntry = { 116 | id: entry.uid, 117 | keys: entry.key, 118 | secondary_keys: entry.keysecondary, 119 | comment: entry.comment, 120 | content: entry.content, 121 | constant: entry.constant, 122 | selective: entry.selective, 123 | insertion_order: entry.order, 124 | enabled: !entry.disable, 125 | position: entry.position == 0 ? 'before_char' : 'after_char', 126 | use_regex: true, // ST keys are always regex 127 | extensions: { 128 | ...entry.extensions, 129 | position: entry.position, 130 | exclude_recursion: entry.excludeRecursion, 131 | display_index: entry.displayIndex, 132 | probability: entry.probability ?? null, 133 | useProbability: entry.useProbability ?? false, 134 | depth: entry.depth ?? 4, 135 | selectiveLogic: entry.selectiveLogic ?? 0, 136 | group: entry.group ?? '', 137 | group_override: entry.groupOverride ?? false, 138 | group_weight: entry.groupWeight ?? null, 139 | prevent_recursion: entry.preventRecursion ?? false, 140 | delay_until_recursion: entry.delayUntilRecursion ?? false, 141 | scan_depth: entry.scanDepth ?? null, 142 | match_whole_words: entry.matchWholeWords ?? null, 143 | use_group_scoring: entry.useGroupScoring ?? false, 144 | case_sensitive: entry.caseSensitive ?? null, 145 | automation_id: entry.automationId ?? '', 146 | role: entry.role ?? 0, 147 | vectorized: entry.vectorized ?? false, 148 | sticky: entry.sticky ?? null, 149 | cooldown: entry.cooldown ?? null, 150 | delay: entry.delay ?? null, 151 | }, 152 | }; 153 | 154 | result.entries.push(originalEntry); 155 | } 156 | 157 | return result; 158 | } 159 | 160 | export function st_convertCharacterBook(characterBook: { entries: any[]; name: string }): { 161 | entries: {}; 162 | originalData: any; 163 | } { 164 | return SillyTavern.getContext().convertCharacterBook(characterBook); 165 | } 166 | 167 | export function st_saveWorldInfo(name: string, data: any, immediately = false) { 168 | return SillyTavern.getContext().saveWorldInfo(name, data, immediately); 169 | } 170 | 171 | export async function st_updateWorldInfoList() { 172 | await SillyTavern.getContext().updateWorldInfoList(); 173 | } 174 | 175 | export function st_setWorldInfoButtonClass(chid: string | undefined, forceValue?: boolean | undefined) { 176 | setWorldInfoButtonClass(chid, forceValue); 177 | } 178 | 179 | export function st_getThumbnailUrl(type: string, file: string): string { 180 | return getThumbnailUrl(type, file); 181 | } 182 | 183 | /** 184 | * @returns True if user accepts it. 185 | */ 186 | export async function st_popupConfirm(header: string, text?: string): Promise { 187 | // @ts-ignore 188 | return await SillyTavern.getContext().Popup.show.confirm(header, text); 189 | } 190 | 191 | /** 192 | * @returns True if added or already exist. False if user rejected the popup 193 | */ 194 | export async function st_addWorldInfo( 195 | worldName: string, 196 | character_book: 197 | | { 198 | entries: any[]; 199 | name: string; 200 | } 201 | | undefined, 202 | skipPopup: boolean, 203 | ): Promise { 204 | const worldNames = st_getWorldNames(); 205 | if (!worldNames.includes(worldName) && character_book) { 206 | if (!skipPopup) { 207 | const confirmation = await st_popupConfirm(`Import lorebook named '${worldName}'`, 'Higly recommended'); 208 | if (!confirmation) { 209 | return false; 210 | } 211 | } 212 | const convertedBook = st_convertCharacterBook(character_book); 213 | st_saveWorldInfo(character_book.name, convertedBook, true); 214 | await st_updateWorldInfoList(); 215 | } 216 | 217 | return true; 218 | } 219 | 220 | export function st_fixToastrForDialogs() { 221 | fixToastrForDialogs(); 222 | } 223 | 224 | export function st_removeFromArray(array: any[], item: any) { 225 | removeFromArray(array, item); 226 | } 227 | 228 | export function st_runAfterAnimation(element: any, callback: any) { 229 | runAfterAnimation(element, callback); 230 | } 231 | 232 | export function st_uuidv4() { 233 | return SillyTavern.getContext().uuidv4(); 234 | } 235 | 236 | export { STPopup, dialogPolyfill, hljs }; 237 | -------------------------------------------------------------------------------- /dist/style.css: -------------------------------------------------------------------------------- 1 | .popup-content:has(#scenario-create-dialog) { 2 | overflow-y: auto; 3 | } 4 | 5 | .popup-content:has(#scenario-play-dialog) { 6 | overflow-y: auto; 7 | } 8 | 9 | #scenario-create-dialog .tab-navigation, #scenario-create-dialog .page-navigation { 10 | gap: 5px; 11 | margin-bottom: 10px; 12 | flex-wrap: wrap; 13 | } 14 | #scenario-create-dialog .tab-navigation .flex-container:first-child, #scenario-create-dialog .page-navigation .flex-container:first-child { 15 | background: rgba(0, 0, 0, 0.2); 16 | padding: 4px 6px; 17 | border-radius: 6px; 18 | display: flex; 19 | align-items: center; 20 | } 21 | #scenario-create-dialog .page-navigation { 22 | margin-top: 20px; 23 | border-top: 1px solid var(--accent-color); 24 | padding-top: 10px; 25 | } 26 | #scenario-create-dialog .page-navigation .button-group { 27 | display: flex; 28 | gap: 2px; 29 | background: rgba(0, 0, 0, 0.15); 30 | padding: 4px; 31 | border-radius: 6px; 32 | margin-right: 8px; 33 | } 34 | #scenario-create-dialog .page-navigation .button-group .menu_button { 35 | min-width: auto; 36 | padding: 6px 12px; 37 | font-size: 0.9em; 38 | height: 32px; 39 | line-height: 1; 40 | display: flex; 41 | align-items: center; 42 | gap: 6px; 43 | transition: all 0.2s ease; 44 | } 45 | #scenario-create-dialog .page-navigation .button-group .menu_button i { 46 | font-size: 0.9em; 47 | } 48 | #scenario-create-dialog .page-navigation .button-group .menu_button:hover { 49 | transform: translateY(-1px); 50 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 51 | } 52 | #scenario-create-dialog .page-navigation .button-group .menu_button.primary { 53 | background-color: var(--accent-color); 54 | border-color: var(--accent-color); 55 | color: var(--white-90); 56 | } 57 | #scenario-create-dialog .page-navigation .button-group .menu_button.primary:hover { 58 | background-color: var(--hover-color, #405060); 59 | border-color: var(--hover-color, #405060); 60 | } 61 | #scenario-create-dialog .page-navigation .button-group .menu_button.danger { 62 | background-color: var(--error-color); 63 | border-color: var(--error-color); 64 | } 65 | #scenario-create-dialog .page-navigation .button-group .menu_button.danger:hover { 66 | background-color: var(--error-hover-color, #a00); 67 | border-color: var(--error-hover-color, #a00); 68 | } 69 | #scenario-create-dialog .questions-container { 70 | background: rgba(0, 0, 0, 0.1); 71 | border-radius: 6px; 72 | padding: 10px; 73 | margin-bottom: 10px; 74 | } 75 | #scenario-create-dialog .questions-container .questions-tabs { 76 | display: flex; 77 | gap: 5px; 78 | flex-wrap: wrap; 79 | flex: 1; 80 | } 81 | #scenario-create-dialog .questions-container .button-group { 82 | display: flex; 83 | gap: 2px; 84 | background: rgba(0, 0, 0, 0.15); 85 | padding: 4px; 86 | border-radius: 6px; 87 | flex-direction: column; 88 | } 89 | #scenario-create-dialog .questions-container .button-group .menu_button { 90 | min-width: auto; 91 | padding: 6px 12px; 92 | font-size: 0.9em; 93 | height: 32px; 94 | line-height: 1; 95 | display: flex; 96 | align-items: center; 97 | gap: 6px; 98 | transition: all 0.2s ease; 99 | } 100 | #scenario-create-dialog .questions-container .button-group .menu_button i { 101 | font-size: 0.9em; 102 | } 103 | #scenario-create-dialog .questions-container .button-group .menu_button:hover { 104 | transform: translateY(-1px); 105 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 106 | } 107 | #scenario-create-dialog .page-tab-buttons { 108 | display: flex; 109 | gap: 5px; 110 | flex-wrap: wrap; 111 | position: relative; 112 | } 113 | #scenario-create-dialog .page-tab-buttons .page-button-container { 114 | display: flex; 115 | gap: 2px; 116 | position: relative; 117 | transition: transform 0.3s ease; 118 | will-change: transform; 119 | } 120 | #scenario-create-dialog .page-tab-buttons .page-button-container.moving-left { 121 | animation: slideLeft 0.3s ease forwards; 122 | } 123 | #scenario-create-dialog .page-tab-buttons .page-button-container.moving-right { 124 | animation: slideRight 0.3s ease forwards; 125 | } 126 | #scenario-create-dialog .page-tab-buttons .page-button-container .page-button { 127 | min-width: auto; 128 | padding: 6px 12px; 129 | font-size: 0.9em; 130 | height: 32px; 131 | line-height: 1; 132 | display: flex; 133 | align-items: center; 134 | gap: 6px; 135 | transition: all 0.2s ease; 136 | } 137 | #scenario-create-dialog .page-tab-buttons .page-button-container .page-button:hover { 138 | transform: translateY(-1px); 139 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 140 | } 141 | #scenario-create-dialog .page-tab-buttons .page-button-container .page-button.active { 142 | background-color: var(--accent-color); 143 | border-color: var(--accent-color); 144 | color: var(--white-90); 145 | font-weight: 500; 146 | } 147 | @keyframes slideLeft { 148 | 0% { 149 | transform: translateX(0); 150 | z-index: 1; 151 | } 152 | 100% { 153 | transform: translateX(-100%); 154 | z-index: 1; 155 | } 156 | } 157 | @keyframes slideRight { 158 | 0% { 159 | transform: translateX(0); 160 | z-index: 1; 161 | } 162 | 100% { 163 | transform: translateX(100%); 164 | z-index: 1; 165 | } 166 | } 167 | #scenario-create-dialog .tab-button { 168 | min-width: auto; 169 | padding: 6px 12px; 170 | font-size: 0.9em; 171 | height: 32px; 172 | line-height: 1; 173 | display: flex; 174 | align-items: center; 175 | gap: 6px; 176 | transition: all 0.2s ease; 177 | } 178 | #scenario-create-dialog .tab-button:hover { 179 | transform: translateY(-1px); 180 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 181 | } 182 | #scenario-create-dialog .tab-button.question { 183 | height: 52px; 184 | padding: 6px 16px; 185 | } 186 | #scenario-create-dialog .tab-button.active { 187 | background-color: var(--accent-color); 188 | border-color: var(--accent-color); 189 | color: var(--white-90); 190 | font-weight: 500; 191 | } 192 | #scenario-create-dialog .tab-button.active:hover { 193 | background-color: var(--hover-color, #405060); 194 | border-color: var(--hover-color, #405060); 195 | } 196 | #scenario-create-dialog .add-question-btn { 197 | margin-left: 8px; 198 | } 199 | #scenario-create-dialog .questions-tabs { 200 | display: flex; 201 | gap: 5px; 202 | flex-wrap: wrap; 203 | } 204 | #scenario-create-dialog .tab-button-container { 205 | display: flex; 206 | gap: 2px; 207 | } 208 | #scenario-create-dialog .remove-input-btn { 209 | height: fit-content; 210 | padding: 3px 8px; 211 | min-width: auto; 212 | line-height: 1; 213 | font-size: 16px; 214 | } 215 | #scenario-create-dialog .remove-input-btn.danger { 216 | background-color: var(--error-color); 217 | border-color: var(--error-color); 218 | color: var(--white-90); 219 | } 220 | #scenario-create-dialog .remove-input-btn.danger:hover { 221 | background-color: var(--error-hover-color, #a00); 222 | border-color: var(--error-hover-color, #a00); 223 | } 224 | #scenario-create-dialog .gap10 { 225 | gap: 10px; 226 | } 227 | #scenario-create-dialog .marginLeft10 { 228 | margin-left: 10px; 229 | } 230 | #scenario-create-dialog .marginBottom10 { 231 | margin-bottom: 10px; 232 | } 233 | #scenario-create-dialog .accordion { 234 | border: 1px solid var(--accent-color); 235 | border-radius: 5px; 236 | } 237 | #scenario-create-dialog .accordion-header { 238 | display: flex; 239 | justify-content: space-between; 240 | align-items: center; 241 | padding: 10px; 242 | gap: 10px; 243 | } 244 | #scenario-create-dialog .accordion-toggle { 245 | display: flex; 246 | align-items: center; 247 | gap: 5px; 248 | cursor: pointer; 249 | flex: 1; 250 | text-align: left; 251 | } 252 | #scenario-create-dialog .accordion-icon { 253 | display: inline-block; 254 | transition: transform 0.3s; 255 | } 256 | #scenario-create-dialog .accordion-content { 257 | padding: 10px; 258 | } 259 | #scenario-create-dialog .accordion.open .accordion-icon { 260 | transform: rotate(180deg); 261 | } 262 | #scenario-create-dialog .script-input-group { 263 | display: flex; 264 | gap: 10px; 265 | align-items: center; 266 | margin-bottom: 5px; 267 | } 268 | #scenario-create-dialog .script-input-group label { 269 | min-width: 100px; 270 | } 271 | #scenario-create-dialog .script-input-group input:not([type=checkbox]) { 272 | flex: 1; 273 | } 274 | #scenario-create-dialog .script-input-group input[type=checkbox] { 275 | width: auto; 276 | margin-left: 0; 277 | } 278 | #scenario-create-dialog .flex2 { 279 | flex: 2; 280 | } 281 | #scenario-create-dialog .code-highlight { 282 | margin: 0; 283 | background-color: var(--black30a); 284 | text-align: left; 285 | } 286 | #scenario-create-dialog .code-highlight code { 287 | background: 0; 288 | border: 0; 289 | } 290 | -------------------------------------------------------------------------------- /src/styles/main.scss: -------------------------------------------------------------------------------- 1 | .popup-content:has(#scenario-create-dialog) { 2 | overflow-y: auto; 3 | } 4 | 5 | .popup-content:has(#scenario-play-dialog) { 6 | overflow-y: auto; 7 | } 8 | 9 | #scenario-create-dialog { 10 | .tab-navigation, .page-navigation { 11 | gap: 5px; 12 | margin-bottom: 10px; 13 | flex-wrap: wrap; 14 | 15 | .flex-container:first-child { 16 | background: rgba(0, 0, 0, 0.2); 17 | padding: 4px 6px; 18 | border-radius: 6px; 19 | display: flex; 20 | align-items: center; 21 | } 22 | } 23 | 24 | .page-navigation { 25 | margin-top: 20px; 26 | border-top: 1px solid var(--accent-color); 27 | padding-top: 10px; 28 | 29 | .button-group { 30 | display: flex; 31 | gap: 2px; 32 | background: rgba(0, 0, 0, 0.15); 33 | padding: 4px; 34 | border-radius: 6px; 35 | margin-right: 8px; 36 | 37 | .menu_button { 38 | min-width: auto; 39 | padding: 6px 12px; 40 | font-size: 0.9em; 41 | height: 32px; 42 | line-height: 1; 43 | display: flex; 44 | align-items: center; 45 | gap: 6px; 46 | transition: all 0.2s ease; 47 | 48 | i { 49 | font-size: 0.9em; 50 | } 51 | 52 | &:hover { 53 | transform: translateY(-1px); 54 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 55 | } 56 | 57 | &.primary { 58 | background-color: var(--accent-color); 59 | border-color: var(--accent-color); 60 | color: var(--white-90); 61 | 62 | &:hover { 63 | background-color: var(--hover-color, #405060); 64 | border-color: var(--hover-color, #405060); 65 | } 66 | } 67 | 68 | &.danger { 69 | background-color: var(--error-color); 70 | border-color: var(--error-color); 71 | 72 | &:hover { 73 | background-color: var(--error-hover-color, #a00); 74 | border-color: var(--error-hover-color, #a00); 75 | } 76 | } 77 | } 78 | } 79 | } 80 | 81 | .questions-container { 82 | background: rgba(0, 0, 0, 0.1); 83 | border-radius: 6px; 84 | padding: 10px; 85 | margin-bottom: 10px; 86 | 87 | .questions-tabs { 88 | display: flex; 89 | gap: 5px; 90 | flex-wrap: wrap; 91 | flex: 1; 92 | } 93 | 94 | .button-group { 95 | display: flex; 96 | gap: 2px; 97 | background: rgba(0, 0, 0, 0.15); 98 | padding: 4px; 99 | border-radius: 6px; 100 | flex-direction: column; 101 | 102 | .menu_button { 103 | min-width: auto; 104 | padding: 6px 12px; 105 | font-size: 0.9em; 106 | height: 32px; 107 | line-height: 1; 108 | display: flex; 109 | align-items: center; 110 | gap: 6px; 111 | transition: all 0.2s ease; 112 | 113 | i { 114 | font-size: 0.9em; 115 | } 116 | 117 | &:hover { 118 | transform: translateY(-1px); 119 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 120 | } 121 | } 122 | } 123 | } 124 | 125 | .page-tab-buttons { 126 | display: flex; 127 | gap: 5px; 128 | flex-wrap: wrap; 129 | position: relative; 130 | 131 | .page-button-container { 132 | display: flex; 133 | gap: 2px; 134 | position: relative; 135 | transition: transform 0.3s ease; 136 | will-change: transform; 137 | 138 | &.moving-left { 139 | animation: slideLeft 0.3s ease forwards; 140 | } 141 | 142 | &.moving-right { 143 | animation: slideRight 0.3s ease forwards; 144 | } 145 | 146 | .page-button { 147 | min-width: auto; 148 | padding: 6px 12px; 149 | font-size: 0.9em; 150 | height: 32px; 151 | line-height: 1; 152 | display: flex; 153 | align-items: center; 154 | gap: 6px; 155 | transition: all 0.2s ease; 156 | 157 | &:hover { 158 | transform: translateY(-1px); 159 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 160 | } 161 | 162 | &.active { 163 | background-color: var(--accent-color); 164 | border-color: var(--accent-color); 165 | color: var(--white-90); 166 | font-weight: 500; 167 | } 168 | } 169 | } 170 | } 171 | 172 | @keyframes slideLeft { 173 | 0% { 174 | transform: translateX(0); 175 | z-index: 1; 176 | } 177 | 100% { 178 | transform: translateX(-100%); 179 | z-index: 1; 180 | } 181 | } 182 | 183 | @keyframes slideRight { 184 | 0% { 185 | transform: translateX(0); 186 | z-index: 1; 187 | } 188 | 100% { 189 | transform: translateX(100%); 190 | z-index: 1; 191 | } 192 | } 193 | 194 | .tab-button { 195 | min-width: auto; 196 | padding: 6px 12px; 197 | font-size: 0.9em; 198 | height: 32px; 199 | line-height: 1; 200 | display: flex; 201 | align-items: center; 202 | gap: 6px; 203 | transition: all 0.2s ease; 204 | 205 | &:hover { 206 | transform: translateY(-1px); 207 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 208 | } 209 | 210 | &.question { 211 | height: 52px; 212 | padding: 6px 16px; 213 | } 214 | 215 | &.active { 216 | background-color: var(--accent-color); 217 | border-color: var(--accent-color); 218 | color: var(--white-90); 219 | font-weight: 500; 220 | 221 | &:hover { 222 | background-color: var(--hover-color, #405060); 223 | border-color: var(--hover-color, #405060); 224 | } 225 | } 226 | } 227 | 228 | .add-question-btn { 229 | margin-left: 8px; 230 | } 231 | 232 | .questions-tabs { 233 | display: flex; 234 | gap: 5px; 235 | flex-wrap: wrap; 236 | } 237 | 238 | .tab-button-container { 239 | display: flex; 240 | gap: 2px; 241 | } 242 | 243 | .remove-input-btn { 244 | height: fit-content; 245 | padding: 3px 8px; 246 | min-width: auto; 247 | line-height: 1; 248 | font-size: 16px; 249 | 250 | &.danger { 251 | background-color: var(--error-color); 252 | border-color: var(--error-color); 253 | color: var(--white-90); 254 | 255 | &:hover { 256 | background-color: var(--error-hover-color, #a00); 257 | border-color: var(--error-hover-color, #a00); 258 | } 259 | } 260 | } 261 | 262 | .gap10 { 263 | gap: 10px; 264 | } 265 | 266 | .marginLeft10 { 267 | margin-left: 10px; 268 | } 269 | 270 | .marginBottom10 { 271 | margin-bottom: 10px; 272 | } 273 | 274 | .accordion { 275 | border: 1px solid var(--accent-color); 276 | border-radius: 5px; 277 | } 278 | 279 | .accordion-header { 280 | display: flex; 281 | justify-content: space-between; 282 | align-items: center; 283 | padding: 10px; 284 | gap: 10px; 285 | } 286 | 287 | .accordion-toggle { 288 | display: flex; 289 | align-items: center; 290 | gap: 5px; 291 | cursor: pointer; 292 | flex: 1; 293 | text-align: left; 294 | } 295 | 296 | .accordion-icon { 297 | display: inline-block; 298 | transition: transform 0.3s; 299 | } 300 | 301 | .accordion-content { 302 | padding: 10px; 303 | } 304 | 305 | .accordion.open .accordion-icon { 306 | transform: rotate(180deg); 307 | } 308 | 309 | .script-input-group { 310 | display: flex; 311 | gap: 10px; 312 | align-items: center; 313 | margin-bottom: 5px; 314 | } 315 | 316 | .script-input-group label { 317 | min-width: 100px; 318 | } 319 | 320 | .script-input-group input:not([type="checkbox"]) { 321 | flex: 1; 322 | } 323 | 324 | .script-input-group input[type="checkbox"] { 325 | width: auto; 326 | margin-left: 0; 327 | } 328 | 329 | .flex2 { 330 | flex: 2; 331 | } 332 | 333 | .code-highlight { 334 | margin: 0; 335 | background-color: var(--black30a); 336 | text-align: left; 337 | code { 338 | background: 0; 339 | border: 0; 340 | } 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /src/create/QuestionComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { QuestionType, ScriptInputValues } from '../types/types'; 3 | import { ScriptInput, ScriptInputs } from './ScriptInputs'; 4 | import { CodeEditor } from './CodeEditor'; 5 | 6 | interface Option { 7 | value: string; 8 | label: string; 9 | } 10 | 11 | interface QuestionComponentProps { 12 | id: string; 13 | type: QuestionType; 14 | onTypeChange: (value: QuestionType) => void; 15 | inputId: string; 16 | onInputIdChange: (value: string) => void; 17 | question: string; 18 | onQuestionChange: (value: string) => void; 19 | mainScript: string; 20 | onMainScriptChange: (value: string) => void; 21 | showScript: string; 22 | onShowScriptChange: (value: string) => void; 23 | showPreview: string; 24 | questionPreview: string; 25 | isRequired: boolean; 26 | onRequiredChange: (value: boolean) => void; 27 | options: Option[]; 28 | onOptionsChange: (options: Option[]) => void; 29 | defaultValue: string; 30 | onDefaultValueChange: (value: string) => void; 31 | isDefaultChecked: boolean; 32 | onDefaultCheckedChange: (value: boolean) => void; 33 | onRefreshPreview: () => void; 34 | isAccordionOpen: boolean; 35 | onAccordionToggle: () => void; 36 | scriptInputs: { 37 | inputs: ScriptInput[]; 38 | values?: ScriptInputValues; 39 | onChange?: (inputId: string, value: string | boolean) => void; 40 | }; 41 | isQuestionHighlightMode: boolean; 42 | onQuestionHighlightModeChange: (value: boolean) => void; 43 | isMainScriptHighlightMode: boolean; 44 | onMainScriptHighlightModeChange: (value: boolean) => void; 45 | isShowScriptHighlightMode: boolean; 46 | onShowScriptHighlightModeChange: (value: boolean) => void; 47 | } 48 | 49 | export const QuestionComponent: React.FC = ({ 50 | id, 51 | type, 52 | onTypeChange, 53 | inputId, 54 | onInputIdChange, 55 | question, 56 | onQuestionChange, 57 | mainScript, 58 | onMainScriptChange, 59 | showScript, 60 | onShowScriptChange, 61 | showPreview, 62 | questionPreview, 63 | isRequired, 64 | onRequiredChange, 65 | options, 66 | onOptionsChange, 67 | defaultValue, 68 | onDefaultValueChange, 69 | isDefaultChecked, 70 | onDefaultCheckedChange, 71 | onRefreshPreview, 72 | isAccordionOpen, 73 | onAccordionToggle, 74 | scriptInputs, 75 | isQuestionHighlightMode, 76 | onQuestionHighlightModeChange, 77 | isMainScriptHighlightMode, 78 | onMainScriptHighlightModeChange, 79 | isShowScriptHighlightMode, 80 | onShowScriptHighlightModeChange, 81 | }) => { 82 | const handleAddOption = () => { 83 | onOptionsChange([...options, { value: '', label: '' }]); 84 | }; 85 | 86 | const handleOptionChange = (index: number, field: 'value' | 'label', value: string) => { 87 | const newOptions = [...options]; 88 | newOptions[index] = { ...newOptions[index], [field]: value }; 89 | onOptionsChange(newOptions); 90 | }; 91 | 92 | return ( 93 |
94 |
95 |
96 | 97 | 106 |
107 |
108 | 109 | onInputIdChange(e.target.value)} 116 | /> 117 |
118 |
119 | 120 |
121 | 122 | 131 |
132 | 133 |
134 |
135 | 139 | 142 |
143 | {isAccordionOpen && ( 144 |
145 |
146 | 154 |
155 | 163 | 171 | 172 | 173 |
174 | )} 175 |
176 | 177 |
178 | 179 | 185 |
186 | 187 |
188 | 192 |
193 | 194 | {type === 'select' && ( 195 |
196 |
197 |
198 | 199 | 202 |
203 |
204 | {options.map((option, index) => ( 205 |
206 | handleOptionChange(index, 'label', e.target.value)} 213 | /> 214 | handleOptionChange(index, 'value', e.target.value)} 221 | /> 222 |
223 | ))} 224 |
225 |
226 |
227 | )} 228 | 229 |
230 |
231 | 232 |
233 | {type === 'text' && ( 234 |