From d9499b803021baeb7525cd19124347c533083800 Mon Sep 17 00:00:00 2001 From: miku448 <124639231+miku448@users.noreply.github.com> Date: Sun, 22 Dec 2024 09:02:29 -0300 Subject: [PATCH] Novel assistant (#178) * Add basic novel assistant * Refactor assistant * Add logic for for items, scenes, maps, objective, indicators * fix import type * Fix amount of calls and the create_start specification * improve assistant prompt * fix description prompt * add disclaimer, fix char description * fix background and song search --- apps/novel-builder/package.json | 2 + .../novel-assistant/DisclaimerModal.scss | 60 + .../novel-assistant/DisclaimerModal.tsx | 68 + .../novel-assistant/NovelAssistant.scss | 32 + .../novel-assistant/NovelAssistant.tsx | 307 +++ .../prompt/FunctionDefinitions.ts | 1764 +++++++++++++++++ .../novel-assistant/prompt/NovelSpec.ts | 1529 ++++++++++++++ apps/novel-builder/src/config.ts | 33 + .../character/CharacterDescriptionEdit.tsx | 2 +- .../CharacterDescriptionGeneration.tsx | 3 +- apps/novel-builder/src/panels/index.tsx | 2 + apps/services/package.json | 1 + apps/services/src/lib/verifyJWT.mts | 115 +- apps/services/src/server.mts | 3 + .../services/src/services/assistant/index.mts | 36 + .../src/services/assistant/systemPrompt.mts | 170 ++ pnpm-lock.yaml | 83 +- 17 files changed, 4136 insertions(+), 74 deletions(-) create mode 100644 apps/novel-builder/src/components/novel-assistant/DisclaimerModal.scss create mode 100644 apps/novel-builder/src/components/novel-assistant/DisclaimerModal.tsx create mode 100644 apps/novel-builder/src/components/novel-assistant/NovelAssistant.scss create mode 100644 apps/novel-builder/src/components/novel-assistant/NovelAssistant.tsx create mode 100644 apps/novel-builder/src/components/novel-assistant/prompt/FunctionDefinitions.ts create mode 100644 apps/novel-builder/src/components/novel-assistant/prompt/NovelSpec.ts create mode 100644 apps/services/src/services/assistant/index.mts create mode 100644 apps/services/src/services/assistant/systemPrompt.mts diff --git a/apps/novel-builder/package.json b/apps/novel-builder/package.json index 091f9d44..fcc73063 100644 --- a/apps/novel-builder/package.json +++ b/apps/novel-builder/package.json @@ -70,11 +70,13 @@ "lodash.clonedeep": "^4.5.0", "lodash.debounce": "^4.0.8", "multiformats": "^11.0.2", + "openai": "^4.77.0", "png-chunk-text": "^1.0.0", "png-chunks-extract": "^1.0.0", "prop-types": "^15.8.1", "query-string": "^8.1.0", "react": "^18.2.0", + "react-chatbotify": "2.0.0-beta.26", "react-color": "^2.19.3", "react-country-flag": "^3.1.0", "react-icons": "^4.12.0", diff --git a/apps/novel-builder/src/components/novel-assistant/DisclaimerModal.scss b/apps/novel-builder/src/components/novel-assistant/DisclaimerModal.scss new file mode 100644 index 00000000..4b504088 --- /dev/null +++ b/apps/novel-builder/src/components/novel-assistant/DisclaimerModal.scss @@ -0,0 +1,60 @@ +@import '../../styles/variables'; + +.disclaimer-modal { + &__content { + padding: 20px; + display: flex; + flex-direction: column; + gap: 20px; + } + + &__section { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 12px; + border-radius: 8px; + + &--premium { + background-color: rgba($color-gold, 0.1); + color: $color-gold; + } + + &--warning { + background-color: rgba($color-red, 0.1); + color: $text-1; + } + + &--info { + background-color: rgba($node-color, 0.1); + color: $text-1; + } + } + + &__icon { + font-size: 20px; + margin-top: 4px; + } + + &__text-container { + flex: 1; + } + + &__title { + font-size: 1rem; + font-weight: 600; + margin-bottom: 4px; + margin-top: 2px; + } + + &__text { + line-height: 1.5; + font-size: 0.95rem; + opacity: 0.9; + } + + &__button-container { + display: flex; + justify-content: center; + } +} diff --git a/apps/novel-builder/src/components/novel-assistant/DisclaimerModal.tsx b/apps/novel-builder/src/components/novel-assistant/DisclaimerModal.tsx new file mode 100644 index 00000000..e7753728 --- /dev/null +++ b/apps/novel-builder/src/components/novel-assistant/DisclaimerModal.tsx @@ -0,0 +1,68 @@ +import { Button, Modal } from '@mikugg/ui-kit'; +import { HiSparkles } from 'react-icons/hi'; +import { BiSolidError } from 'react-icons/bi'; +import { MdOutlineTimer, MdPhotoCamera } from 'react-icons/md'; +import './DisclaimerModal.scss'; + +interface DisclaimerModalProps { + opened: boolean; + onClose: () => void; +} + +export default function DisclaimerModal({ opened, onClose }: DisclaimerModalProps) { + return ( + +
+
+ +
+

Premium Feature

+

+ This feature is exclusively available for premium members at the moment. Please keep in mind that the + assistant might NOT be available even for premium members in the future due to high costs. +

+
+
+ +
+ +
+

Experimental Feature - Save Your Work

+

+ The assistant is highly experimental and may produce unexpected results. Always save your work before + using it, as it might occasionally misunderstand context or provide suboptimal suggestions. While we + strive for reliability, it's important to backup your content to prevent any potential data loss. +

+
+
+ +
+ +
+

Usage Limits

+

+ Due to the significant computational costs involved, there are usage limits in place. Please use the + assistant thoughtfully and efficiently. +

+
+
+ +
+ +
+

Text Only

+

+ The assistant can help with text content only. It cannot generate, modify, or manipulate images. +

+
+
+ +
+ +
+
+
+ ); +} diff --git a/apps/novel-builder/src/components/novel-assistant/NovelAssistant.scss b/apps/novel-builder/src/components/novel-assistant/NovelAssistant.scss new file mode 100644 index 00000000..3d275217 --- /dev/null +++ b/apps/novel-builder/src/components/novel-assistant/NovelAssistant.scss @@ -0,0 +1,32 @@ +@import '../../styles/variables'; + +.AssistantActivityLog { + font-style: italic; + padding: 0.4rem 1rem; + font-size: 0.8rem; + background-color: rgba($background-1, 1); + width: 100%; + animation: fadeIn 0.5s ease-in-out; + + &:first-child { + border-top-left-radius: 0.5rem; + border-top-right-radius: 0.5rem; + } + + &__verb { + color: $secondary-color; + } + + &__button { + color: $text-1; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/apps/novel-builder/src/components/novel-assistant/NovelAssistant.tsx b/apps/novel-builder/src/components/novel-assistant/NovelAssistant.tsx new file mode 100644 index 00000000..b30b18d9 --- /dev/null +++ b/apps/novel-builder/src/components/novel-assistant/NovelAssistant.tsx @@ -0,0 +1,307 @@ +import ChatBot, { Button, Params, RcbToggleChatWindowEvent } from 'react-chatbotify'; +import { ChatCompletion, ChatCompletionMessageParam } from 'openai/src/resources/index.js'; +import axios from 'axios'; +import { useState, useEffect } from 'react'; +import DisclaimerModal from './DisclaimerModal'; +import config from '../../config'; + +import './NovelAssistant.scss'; +import { FunctionAction, FunctionRegistry } from './prompt/FunctionDefinitions'; +import { NovelManager } from './prompt/NovelSpec'; +import { NovelV3 } from '@mikugg/bot-utils'; +import { useAppDispatch, useAppSelector } from '../../state/store'; +import { loadCompleteState } from '../../state/slices/novelFormSlice'; +import { SERVICES_ENDPOINT } from '../../libs/utils'; + +function getFunctionActionColor(action: FunctionAction): string { + switch (action) { + case 'created': + return '#4CAF50'; + case 'updated': + return '#2196F3'; + case 'removed': + return '#FF9800'; + case 'connected': + return '#FFC107'; + case 'deleted': + return '#F44336'; + } +} + +function AssistantActivityLog(props: { + verb: FunctionAction; + subject: string; + onNavigate?: () => void; + onClick: () => void; +}): React.ReactNode { + return ( +
+ Miku{' '} + + {props.verb} + {' '} + {props.subject} + {/* */} +
+ ); +} +const novelManager = new NovelManager(); +const functionRegistry = new FunctionRegistry(novelManager); +const functions = functionRegistry.getFunctionDefinitions(); + +// Load existing history if it exists +const conversationHistory: ChatCompletionMessageParam[] = []; + +// Load + +const callChatCompletion = async ( + messages: ChatCompletionMessageParam[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tools: any[], + parallel_tool_calls: boolean, + tool_choice: 'none' | 'auto', +): Promise => { + const response = await axios.post( + SERVICES_ENDPOINT + '/openai/chat/completions', + { + messages, + tools, + parallel_tool_calls, + tool_choice, + }, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + if (response.status !== 200) { + throw new Error('Failed to get completion from proxy'); + } + + return response.data; +}; + +const call_openai = async (params: Params, replaceState: (state: NovelV3.NovelState) => void) => { + try { + conversationHistory.push({ role: 'user', content: params.userInput }); + console.log(conversationHistory); + + // let amountOfCalls = 0; + const askResponse = (): Promise => + callChatCompletion( + conversationHistory, + functions.map((fn) => ({ type: 'function', function: fn })), + true, + 'auto', + ); + + let response; + for (response = await askResponse(); response?.choices[0].message?.tool_calls; response = await askResponse()) { + const message = response.choices[0].message; + conversationHistory.push(message); + if (message?.tool_calls) { + for (const toolCall of message.tool_calls) { + const fnName = toolCall.function.name; + const fnArgs = JSON.parse(toolCall.function.arguments || '{}'); + + const functionResponse = await functionRegistry.executeFunction(fnName, fnArgs); + // Add this line to save the novel state after each function execution + replaceState(novelManager.getNovelState()); + + conversationHistory.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: functionResponse, + }); + const displayData = functionRegistry.getFunctionDisplayData(fnName); + if (!functionResponse.toLowerCase().startsWith('error:') && displayData?.isSetter) { + const toastData: { id: string | null } = { id: null }; + toastData.id = await params.showToast( + toastData.id && params.dismissToast(toastData.id)} + verb={displayData.action} + subject={displayData.subject} + onNavigate={() => {}} + />, + 5000, + ); + } + } + } + if (message.content) { + await params.injectMessage(String(message?.content) || ''); + } + } + conversationHistory.push(response.choices[0].message); + console.log(conversationHistory); + if (response.choices[0].message.content) { + await params.injectMessage(String(response.choices[0].message?.content) || ''); + } + } catch (error) { + await params.injectMessage('Error: ' + error); + } +}; + +export default function NovelAssistant() { + const dispatch = useAppDispatch(); + const state = useAppSelector((state) => state.novel); + const [showDisclaimer, setShowDisclaimer] = useState(false); + const [hasAcceptedDisclaimer, setHasAcceptedDisclaimer] = useState(() => false); + const [isPremium, setIsPremium] = useState(false); + const [isCheckingPremium, setIsCheckingPremium] = useState(true); + + useEffect(() => { + const checkPremiumStatus = async () => { + try { + const premium = await config.isPremiumUser(); + setIsPremium(premium); + } catch (error) { + console.error('Failed to check premium status:', error); + setIsPremium(false); + } finally { + setIsCheckingPremium(false); + } + }; + + checkPremiumStatus(); + }, []); + + useEffect(() => { + novelManager.replaceState(state); + }, [state.title]); + + useEffect(() => { + const handleToggleChatWindow = (event: RcbToggleChatWindowEvent) => { + const shouldOpen = event.data.newState; + + if (shouldOpen && !hasAcceptedDisclaimer) { + setShowDisclaimer(true); + } + }; + + // eslint-disable-next-line + // @ts-ignore + window.addEventListener('rcb-toggle-chat-window', handleToggleChatWindow); + return () => { + // eslint-disable-next-line + // @ts-ignore + window.removeEventListener('rcb-toggle-chat-window', handleToggleChatWindow); + }; + }, [hasAcceptedDisclaimer]); + + const handleDisclaimerClose = () => { + setShowDisclaimer(false); + setHasAcceptedDisclaimer(true); + localStorage.setItem('assistant-disclaimer-accepted', 'true'); + // Open the chat after accepting disclaimer + const event = new CustomEvent('rcb-toggle-chat-window', { + detail: { newState: true }, + }); + window.dispatchEvent(event); + }; + + if (isCheckingPremium) { + return null; + } + return ( + <> + + { + await call_openai(params, (newState) => dispatch(loadCompleteState(newState))); + }, + path: () => { + return 'loop'; + }, + }, + end: { + message: '', + path: 'end', + }, + }} + styles={{ + bodyStyle: { + backgroundColor: '#1b2142', + }, + headerStyle: { + borderColor: 'transparent', + }, + footerStyle: { + backgroundColor: '#1b2142', + }, + chatInputAreaStyle: { + backgroundColor: '#25284b', + color: 'white', + }, + chatInputContainerStyle: { + backgroundColor: '#1b2142', + }, + toastPromptContainerStyle: { + backgroundColor: 'transparent', + color: 'white', + bottom: '73px', + opacity: 0.8, + textAlign: 'center', + }, + }} + settings={{ + event: { + rcbToggleChatWindow: true, + }, + footer: { + buttons: [], + }, + header: { + title: 'MikuGG Assistant', + avatar: 'https://assets.miku.gg/miku_profile_pic.png', + showAvatar: true, + buttons: [Button.CLOSE_CHAT_BUTTON], + }, + general: { + primaryColor: '#ff4e67', + secondaryColor: '#9747ff', + fontFamily: 'Poppins, Roboto, sans-serif', + embedded: false, + showFooter: false, + }, + audio: { + disabled: true, + }, + chatHistory: { + disabled: true, + }, + tooltip: { + text: 'I can assist!', + }, + botBubble: { + avatar: 'https://assets.miku.gg/miku_profile_pic.png', + showAvatar: true, + simStream: true, + animate: true, + }, + chatButton: { + icon: 'https://assets.miku.gg/miku_profile_pic.png', + }, + toast: { + dismissOnClick: true, + maxCount: 6, + }, + chatInput: { + disabled: !isPremium, + }, + }} + /> + + ); +} diff --git a/apps/novel-builder/src/components/novel-assistant/prompt/FunctionDefinitions.ts b/apps/novel-builder/src/components/novel-assistant/prompt/FunctionDefinitions.ts new file mode 100644 index 00000000..69ad3960 --- /dev/null +++ b/apps/novel-builder/src/components/novel-assistant/prompt/FunctionDefinitions.ts @@ -0,0 +1,1764 @@ +import { CharacterEmotion, NovelManager } from './NovelSpec'; + +export type FunctionHandler = (...args: any[]) => Promise; + +export type FunctionAction = 'created' | 'updated' | 'removed' | 'connected' | 'deleted'; + +export interface FunctionDefinition { + name: string; + description: string; + parameters: { + type: string; + properties: Record; + required?: string[]; + }; + handler: FunctionHandler; + displayData: + | { isSetter: false } + | { + isSetter: true; + action: FunctionAction; + subject: string; + }; +} + +export class FunctionRegistry { + private functions: Map; + private functionDefinitions: FunctionDefinition[]; + + constructor(novelManager: NovelManager) { + this.functions = new Map(); + this.functionDefinitions = this.createFunctionDefinitions(novelManager); + + // Register all functions + this.functionDefinitions.forEach((fn) => { + this.functions.set(fn.name, fn.handler); + }); + } + + private createFunctionDefinitions(novelManager: NovelManager): FunctionDefinition[] { + return [ + { + name: 'get_novel_validation_state', + description: 'Gives information about the current state of the novel and indicates warnings and missing parts', + parameters: { type: 'object', properties: {} }, + handler: () => novelManager.getNovelStats(), + displayData: { + isSetter: false, + }, + }, + { + name: 'get_title', + description: 'Retrieve the current title of the visual novel', + parameters: { type: 'object', properties: {} }, + handler: () => novelManager.getTitle(), + displayData: { + isSetter: false, + }, + }, + { + name: 'get_description', + description: 'Retrieve the current description of the visual novel.', + parameters: { type: 'object', properties: {} }, + handler: () => novelManager.getDescription(), + displayData: { + isSetter: false, + }, + }, + { + name: 'set_title', + description: 'Set a new title for the visual novel', + parameters: { + type: 'object', + properties: { + title: { + type: 'string', + description: 'The new title to set for the visual novel', + }, + }, + required: ['title'], + }, + handler: (args: { title: string }) => novelManager.setTitle(args.title), + displayData: { + isSetter: true, + action: 'updated', + subject: 'the novel title', + }, + }, + { + name: 'set_description', + description: 'Set a new description for the visual novel. The description should be ONLY one sentence.', + parameters: { + type: 'object', + properties: { + description: { + type: 'string', + description: 'The new description to set for the visual novel', + }, + }, + required: ['description'], + }, + handler: (args: { description: string }) => novelManager.setDescription(args.description), + displayData: { + isSetter: true, + action: 'updated', + subject: 'the novel description', + }, + }, + { + name: 'get_all_lorebooks_in_novel', + description: 'Retrieve all lorebooks in the visual novel', + parameters: { type: 'object', properties: {} }, + handler: () => novelManager.getLoreBooks(), + displayData: { + isSetter: false, + }, + }, + { + name: 'set_lorebook_details', + description: 'Create a new lorebook or update an existing one', + parameters: { + type: 'object', + properties: { + lorebookId: { + type: 'string', + description: 'ID of the lorebook to update (omit for creating new)', + }, + name: { + type: 'string', + description: 'Name of the lorebook (required for new lorebooks)', + }, + description: { + type: 'string', + description: 'Very small description of the lorebook. The actual content should be inside entries.', + }, + isGlobal: { + type: 'boolean', + description: 'Whether the lorebook is available to all novel scenes', + }, + }, + }, + handler: (args) => novelManager.setLoreBookDetails(args), + displayData: { + isSetter: true, + action: 'updated', + subject: 'the lorebook details', + }, + }, + { + name: 'add_lorebook_to_scene', + description: 'Attaches a lorebook to a specific scene', + parameters: { + type: 'object', + properties: { + lorebookId: { + type: 'string', + description: 'ID of the lorebook to add', + }, + sceneId: { + type: 'string', + description: 'ID of the scene to add the lorebook to', + }, + }, + required: ['lorebookId', 'sceneId'], + }, + handler: (args: { lorebookId: string; sceneId: string }) => + novelManager.addLorebookToScene(args.lorebookId, args.sceneId), + displayData: { + isSetter: true, + action: 'connected', + subject: 'a lorebook with a scene', + }, + }, + { + name: 'remove_lorebook_from_scene', + description: 'Detaches a lorebook from a specific scene', + parameters: { + type: 'object', + properties: { + lorebookId: { + type: 'string', + description: 'ID of the lorebook to detach', + }, + sceneId: { + type: 'string', + description: 'ID of the scene to detach the lorebook from', + }, + }, + required: ['lorebookId', 'sceneId'], + }, + handler: (args: { lorebookId: string; sceneId: string }) => + novelManager.removeLorebookFromScene(args.lorebookId, args.sceneId), + displayData: { + isSetter: true, + action: 'removed', + subject: 'a lorebook from a scene', + }, + }, + { + name: 'remove_entry_from_lorebook', + description: 'Removes an entry from a lorebook', + parameters: { + type: 'object', + properties: { + lorebookId: { + type: 'string', + description: 'ID of the lorebook containing the entry', + }, + entryId: { + type: 'number', + description: 'ID of the entry to remove', + }, + }, + required: ['lorebookId', 'entryId'], + }, + handler: (args: { lorebookId: string; entryId: number }) => + novelManager.removeEntryFromLorebook(args.lorebookId, args.entryId), + displayData: { + isSetter: true, + action: 'removed', + subject: 'an entry from a lorebook', + }, + }, + { + name: 'set_entry_to_lorebook', + description: + 'Creates a new entry or updates an existing one in a lorebook. Entries are used to give more context to the AI. Entries MUST be give information about the world, characters. The should NOT be used to give information about the player or events that will happen in the story.', + parameters: { + type: 'object', + properties: { + lorebookId: { + type: 'string', + description: 'ID of the lorebook', + }, + entryId: { + type: 'string', + description: 'ID of the entry to update (omit for creating new)', + }, + name: { + type: 'string', + description: 'Name of the entry (required for new entries)', + }, + keywords: { + type: 'array', + items: { type: 'string' }, + description: 'Keywords that make the entry be used to give more context (required for new entries)', + }, + content: { + type: 'string', + description: 'Content of the entry that, in Q-A format.', + }, + }, + required: ['lorebookId'], + }, + handler: (args) => novelManager.setEntryToLorebook(args), + displayData: { + isSetter: true, + action: 'updated', + subject: 'an entry in a lorebook', + }, + }, + { + name: 'add_character_basics', + description: 'Create a new character or update basic information of an existing character', + parameters: { + type: 'object', + properties: { + characterId: { + type: 'string', + description: 'ID of the character to update (omit for creating new character)', + }, + name: { + type: 'string', + description: 'Name of the character', + }, + short_description: { + type: 'string', + description: 'A brief one-line description of the character', + }, + tags: { + type: 'array', + items: { type: 'string' }, + description: "Tags that describe the character's traits and characteristics", + }, + }, + required: ['name', 'short_description', 'tags'], + }, + handler: (args) => novelManager.addCharacterBasics(args), + displayData: { + isSetter: true, + action: 'updated', + subject: 'a character', + }, + }, + { + name: 'set_character_prompt_and_examples', + description: 'Set the detailed description and conversation examples for a character', + parameters: { + type: 'object', + properties: { + characterId: { + type: 'string', + description: 'ID of the character to update', + }, + prompt: { + type: 'string', + description: + "Detailed description of the character's personality, background, physical appearance and traits", + }, + conversation_examples: { + type: 'string', + description: 'Examples of how the character typically speaks and interacts', + }, + }, + required: ['characterId', 'prompt', 'conversation_examples'], + }, + handler: (args) => novelManager.setCharacterPrompts(args), + displayData: { + isSetter: true, + action: 'updated', + subject: 'a character', + }, + }, + { + name: 'delete_character', + description: 'Remove a character from the novel', + parameters: { + type: 'object', + properties: { + characterId: { + type: 'string', + description: 'ID of the character to delete', + }, + }, + required: ['characterId'], + }, + handler: (args: { characterId: string }) => novelManager.deleteCharacter(args.characterId), + displayData: { + isSetter: true, + action: 'removed', + subject: 'a character', + }, + }, + { + name: 'get_all_characters_in_novel', + description: 'Retrieve all characters in the visual novel', + parameters: { type: 'object', properties: {} }, + handler: () => novelManager.getCharacters(), + displayData: { + isSetter: false, + }, + }, + { + name: 'attach_lorebook_to_character', + description: 'Associate a lorebook with a character to provide additional context', + parameters: { + type: 'object', + properties: { + characterId: { + type: 'string', + description: 'ID of the character', + }, + lorebookId: { + type: 'string', + description: 'ID of the lorebook to attach', + }, + }, + required: ['characterId', 'lorebookId'], + }, + handler: (args: { characterId: string; lorebookId: string }) => + novelManager.attachLorebookToCharacter(args.characterId, args.lorebookId), + displayData: { + isSetter: true, + action: 'connected', + subject: 'a lorebook with a character', + }, + }, + { + name: 'detach_lorebook_from_character', + description: 'Remove a lorebook association from a character', + parameters: { + type: 'object', + properties: { + characterId: { + type: 'string', + description: 'ID of the character', + }, + lorebookId: { + type: 'string', + description: 'ID of the lorebook to detach', + }, + }, + required: ['characterId', 'lorebookId'], + }, + handler: (args: { characterId: string; lorebookId: string }) => + novelManager.detachLorebookFromCharacter(args.characterId, args.lorebookId), + displayData: { + isSetter: true, + action: 'removed', + subject: 'a lorebook from a character', + }, + }, + { + name: 'add_background_from_database', + description: 'Search and add a background that best matches the given description', + parameters: { + type: 'object', + properties: { + prompt: { + type: 'string', + description: "Description of the background scene you're looking for", + }, + }, + required: ['prompt'], + }, + handler: (args: { prompt: string }) => novelManager.addBackgroundFromDatabase(args.prompt), + displayData: { + isSetter: true, + action: 'created', + subject: 'a background', + }, + }, + { + name: 'modify_background_description', + description: 'Update the description of an existing background', + parameters: { + type: 'object', + properties: { + backgroundId: { + type: 'string', + description: 'ID of the background to modify', + }, + description: { + type: 'string', + description: 'New description for the background', + }, + }, + required: ['backgroundId', 'description'], + }, + handler: (args: { backgroundId: string; description: string }) => + novelManager.modifyBackgroundDescription(args.backgroundId, args.description), + displayData: { + isSetter: true, + action: 'updated', + subject: 'a background description', + }, + }, + { + name: 'remove_background', + description: 'Remove a background from the novel', + parameters: { + type: 'object', + properties: { + backgroundId: { + type: 'string', + description: 'ID of the background to remove', + }, + }, + required: ['backgroundId'], + }, + handler: (args: { backgroundId: string }) => novelManager.removeBackground(args.backgroundId), + displayData: { + isSetter: true, + action: 'removed', + subject: 'a background', + }, + }, + { + name: 'get_all_backgrounds_in_novel', + description: 'Retrieve all backgrounds in the visual novel', + parameters: { type: 'object', properties: {} }, + handler: () => novelManager.getBackgrounds(), + displayData: { + isSetter: false, + }, + }, + { + name: 'add_music_from_database', + description: 'Search and add music that best matches the given description', + parameters: { + type: 'object', + properties: { + prompt: { + type: 'string', + description: "Description of the music or atmosphere you're looking for", + }, + }, + required: ['prompt'], + }, + handler: (args: { prompt: string }) => novelManager.addMusicFromDatabase(args.prompt), + displayData: { + isSetter: true, + action: 'created', + subject: 'a music track', + }, + }, + { + name: 'set_music_description', + description: 'Update the description of an existing music track', + parameters: { + type: 'object', + properties: { + musicId: { + type: 'string', + description: 'ID of the music track to modify', + }, + description: { + type: 'string', + description: 'New description for the music track', + }, + }, + required: ['musicId', 'description'], + }, + handler: (args: { musicId: string; description: string }) => + novelManager.modifyMusicDescription(args.musicId, args.description), + displayData: { + isSetter: true, + action: 'updated', + subject: 'a music track description', + }, + }, + { + name: 'remove_music_from_novel', + description: 'Remove a music track from the novel', + parameters: { + type: 'object', + properties: { + musicId: { + type: 'string', + description: 'ID of the music track to remove', + }, + }, + required: ['musicId'], + }, + handler: (args: { musicId: string }) => novelManager.removeMusic(args.musicId), + displayData: { + isSetter: true, + action: 'removed', + subject: 'a music track', + }, + }, + { + name: 'get_all_music_in_novel', + description: 'Retrieve all music tracks in the visual novel', + parameters: { type: 'object', properties: {} }, + handler: () => novelManager.getMusic(), + displayData: { + isSetter: false, + }, + }, + { + name: 'get_all_scenes_in_novel', + description: 'Retrieve all scenes in the visual novel', + parameters: { type: 'object', properties: {} }, + handler: () => novelManager.getScenes(), + displayData: { + isSetter: false, + }, + }, + { + name: 'create_scene', + description: 'Create a new scene in the visual novel. It requires a background and music already created.', + parameters: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Name of the scene', + }, + short_description: { + type: 'string', + description: 'Brief description of what happens in the scene', + }, + prompt: { + type: 'string', + description: "Instructions for the AI about the scene, must start with 'OOC:'", + }, + characters: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'ID of the character', + }, + outfitId: { + type: 'string', + description: 'ID of the outfit for this character', + }, + objective: { + type: 'string', + description: 'Optional objective for this character in the scene', + }, + }, + required: ['id', 'outfitId'], + }, + description: 'Array of characters in the scene (1-2 characters)', + }, + backgroundId: { + type: 'string', + description: 'ID of the background to use', + }, + musicId: { + type: 'string', + description: 'ID of the music track to play', + }, + cutscene: { + type: 'object', + properties: { + triggerOnlyOnce: { + type: 'boolean', + description: 'Whether this cutscene should only play once', + }, + parts: { + type: 'array', + items: { + type: 'object', + properties: { + text: { + type: 'array', + items: { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['dialogue', 'description'], + }, + content: { type: 'string' }, + }, + required: ['type', 'content'], + }, + }, + backgroundId: { type: 'string' }, + musicId: { type: 'string' }, + characters: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + outfitId: { type: 'string' }, + emotionId: { + type: 'string', + enum: [ + 'angry', + 'sad', + 'happy', + 'disgusted', + 'begging', + 'scared', + 'excited', + 'hopeful', + 'longing', + 'proud', + 'neutral', + 'rage', + 'scorn', + 'blushed', + 'pleasure', + 'lustful', + 'shocked', + 'confused', + 'disappointed', + 'embarrassed', + 'guilty', + 'shy', + 'frustrated', + 'annoyed', + 'exhausted', + 'tired', + 'curious', + 'intrigued', + 'amused', + ], + }, + }, + required: ['id', 'outfitId', 'emotionId'], + }, + }, + }, + required: ['text', 'backgroundId', 'characters'], + }, + }, + }, + required: ['triggerOnlyOnce', 'parts'], + description: 'Optional cutscene sequence', + }, + hint: { + type: 'string', + description: 'Optional hint to help players navigate the scene', + }, + condition: { + type: 'string', + description: 'Optional condition that must be met to trigger the scene', + }, + actionText: { + type: 'string', + description: 'Optional button text to trigger the scene', + }, + }, + required: ['name', 'short_description', 'prompt', 'characters', 'backgroundId', 'musicId'], + }, + handler: (args) => novelManager.setSceneDetails(args), + displayData: { + isSetter: true, + action: 'created', + subject: 'a scene', + }, + }, + { + name: 'remove_scene', + description: 'Remove a scene from the visual novel', + parameters: { + type: 'object', + properties: { + sceneId: { + type: 'string', + description: 'ID of the scene to remove', + }, + }, + required: ['sceneId'], + }, + handler: (args: { sceneId: string }) => novelManager.removeScene(args.sceneId), + displayData: { + isSetter: true, + action: 'removed', + subject: 'a scene', + }, + }, + { + name: 'connect_scenes', + description: 'Connect two scenes, making one a child of another', + parameters: { + type: 'object', + properties: { + sceneId: { + type: 'string', + description: 'ID of the parent scene', + }, + childSceneId: { + type: 'string', + description: 'ID of the scene to make a child', + }, + }, + required: ['sceneId', 'childSceneId'], + }, + handler: (args: { sceneId: string; childSceneId: string }) => + novelManager.connectScenes(args.sceneId, args.childSceneId), + displayData: { + isSetter: true, + action: 'connected', + subject: 'two scenes', + }, + }, + { + name: 'disconnect_scenes', + description: 'Remove the connection between two scenes', + parameters: { + type: 'object', + properties: { + sceneId: { + type: 'string', + description: 'ID of the parent scene', + }, + childSceneId: { + type: 'string', + description: 'ID of the child scene to disconnect', + }, + }, + required: ['sceneId', 'childSceneId'], + }, + handler: (args: { sceneId: string; childSceneId: string }) => + novelManager.disconnectScenes(args.sceneId, args.childSceneId), + displayData: { + isSetter: true, + action: 'removed', + subject: 'a scene connection', + }, + }, + { + name: 'create_start', + description: 'Add a new starting point for the visual novel', + parameters: { + type: 'object', + properties: { + sceneId: { + type: 'string', + description: 'ID of the scene where this start point begins', + }, + name: { + type: 'string', + description: 'Name of the start point', + }, + short_description: { + type: 'string', + description: 'Brief description of this starting point', + }, + firstMessagePerCharacters: { + type: 'array', + items: { + type: 'object', + properties: { + characterId: { + type: 'string', + description: 'ID of the character speaking', + }, + message: { + type: 'string', + description: 'The message content', + }, + emotionId: { + type: 'string', + enum: [ + 'angry', + 'sad', + 'happy', + 'disgusted', + 'begging', + 'scared', + 'excited', + 'hopeful', + 'longing', + 'proud', + 'neutral', + 'rage', + 'scorn', + 'blushed', + 'pleasure', + 'lustful', + 'shocked', + 'confused', + 'disappointed', + 'embarrassed', + 'guilty', + 'shy', + 'frustrated', + 'annoyed', + 'exhausted', + 'tired', + 'curious', + 'intrigued', + 'amused', + ], + description: 'The emotion the character shows while speaking', + }, + }, + required: ['characterId', 'message', 'emotionId'], + }, + description: + 'Initial messages from characters when starting from this point. A single message per character. The message can have several paragraphs in the string.', + }, + }, + required: ['sceneId', 'name', 'short_description', 'firstMessagePerCharacters'], + }, + handler: (args) => novelManager.addStart(args), + displayData: { + isSetter: true, + action: 'created', + subject: 'a start point', + }, + }, + { + name: 'remove_start', + description: 'Remove a starting point from the visual novel', + parameters: { + type: 'object', + properties: { + startId: { + type: 'string', + description: 'ID of the start point to remove', + }, + }, + required: ['startId'], + }, + handler: (args: { startId: string }) => novelManager.removeStart(args.startId), + displayData: { + isSetter: true, + action: 'removed', + subject: 'a start point', + }, + }, + { + name: 'move_start_as_first_option', + description: 'Move a start point to be the first option in the list', + parameters: { + type: 'object', + properties: { + startId: { + type: 'string', + description: 'ID of the start point to move to first position', + }, + }, + required: ['startId'], + }, + handler: (args: { startId: string }) => novelManager.moveStartAsFirstOption(args.startId), + displayData: { + isSetter: true, + action: 'updated', + subject: 'start point order', + }, + }, + { + name: 'set_scene_cutscene', + description: 'Update the cutscene of an existing scene', + parameters: { + type: 'object', + properties: { + sceneId: { + type: 'string', + description: 'ID of the scene to update', + }, + cutscene: { + type: 'object', + properties: { + triggerOnlyOnce: { + type: 'boolean', + description: 'Whether this cutscene should only play once', + }, + parts: { + type: 'array', + items: { + type: 'object', + properties: { + text: { + type: 'array', + items: { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['dialogue', 'description'], + }, + content: { type: 'string' }, + }, + required: ['type', 'content'], + }, + }, + backgroundId: { type: 'string' }, + musicId: { type: 'string' }, + characters: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + outfitId: { type: 'string' }, + emotionId: { + type: 'string', + enum: Object.values(CharacterEmotion), + }, + }, + required: ['id', 'outfitId', 'emotionId'], + }, + }, + }, + required: ['text', 'backgroundId', 'characters'], + }, + }, + }, + required: ['triggerOnlyOnce', 'parts'], + }, + }, + required: ['sceneId', 'cutscene'], + }, + handler: (args) => novelManager.updateSceneCutscene(args.sceneId, args.cutscene), + displayData: { + isSetter: true, + action: 'updated', + subject: 'a scene cutscene', + }, + }, + { + name: 'set_scene_hint', + description: 'Update the hint of an existing scene', + parameters: { + type: 'object', + properties: { + sceneId: { + type: 'string', + description: 'ID of the scene to update', + }, + hint: { + type: 'string', + description: 'New hint to help players navigate the scene', + }, + }, + required: ['sceneId', 'hint'], + }, + handler: (args) => novelManager.updateSceneHint(args.sceneId, args.hint), + displayData: { + isSetter: true, + action: 'updated', + subject: 'a scene hint', + }, + }, + { + name: 'set_scene_condition', + description: 'Update the condition of an existing scene', + parameters: { + type: 'object', + properties: { + sceneId: { + type: 'string', + description: 'ID of the scene to update', + }, + condition: { + type: 'string', + description: 'New condition that must be met to trigger the scene', + }, + }, + required: ['sceneId', 'condition'], + }, + handler: (args) => novelManager.updateSceneCondition(args.sceneId, args.condition), + displayData: { + isSetter: true, + action: 'updated', + subject: 'a scene condition', + }, + }, + { + name: 'set_scene_characters', + description: 'Update the characters in an existing scene', + parameters: { + type: 'object', + properties: { + sceneId: { + type: 'string', + description: 'ID of the scene to update', + }, + characters: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'ID of the character', + }, + outfitId: { + type: 'string', + description: 'ID of the outfit for this character', + }, + objective: { + type: 'string', + description: 'Optional objective for this character in the scene', + }, + }, + required: ['id', 'outfitId'], + }, + description: 'Array of characters in the scene (1-2 characters)', + }, + }, + required: ['sceneId', 'characters'], + }, + handler: (args) => novelManager.updateSceneCharacters(args.sceneId, args.characters), + displayData: { + isSetter: true, + action: 'updated', + subject: 'scene characters', + }, + }, + { + name: 'set_scene_background', + description: 'Update the background of an existing scene', + parameters: { + type: 'object', + properties: { + sceneId: { + type: 'string', + description: 'ID of the scene to update', + }, + backgroundId: { + type: 'string', + description: 'ID of the new background to use', + }, + }, + required: ['sceneId', 'backgroundId'], + }, + handler: (args) => novelManager.updateSceneBackground(args.sceneId, args.backgroundId), + displayData: { + isSetter: true, + action: 'updated', + subject: 'a scene background', + }, + }, + { + name: 'set_scene_music', + description: 'Update the music of an existing scene', + parameters: { + type: 'object', + properties: { + sceneId: { + type: 'string', + description: 'ID of the scene to update', + }, + musicId: { + type: 'string', + description: 'ID of the new music track to play', + }, + }, + required: ['sceneId', 'musicId'], + }, + handler: (args) => novelManager.updateSceneMusic(args.sceneId, args.musicId), + displayData: { + isSetter: true, + action: 'updated', + subject: 'a scene music', + }, + }, + { + name: 'update_character_name', + description: 'Update only the name of an existing character', + parameters: { + type: 'object', + properties: { + characterId: { + type: 'string', + description: 'ID of the character to update', + }, + name: { + type: 'string', + description: 'New name for the character', + }, + }, + required: ['characterId', 'name'], + }, + handler: (args) => novelManager.updateCharacterName(args.characterId, args.name), + displayData: { + isSetter: true, + action: 'updated', + subject: 'a character name', + }, + }, + { + name: 'update_character_short_description', + description: 'Update only the short description of an existing character', + parameters: { + type: 'object', + properties: { + characterId: { + type: 'string', + description: 'ID of the character to update', + }, + shortDescription: { + type: 'string', + description: 'New short description for the character', + }, + }, + required: ['characterId', 'shortDescription'], + }, + handler: (args) => novelManager.updateCharacterShortDescription(args.characterId, args.shortDescription), + displayData: { + isSetter: true, + action: 'updated', + subject: 'a character description', + }, + }, + { + name: 'update_character_tags', + description: 'Update only the tags of an existing character', + parameters: { + type: 'object', + properties: { + characterId: { + type: 'string', + description: 'ID of the character to update', + }, + tags: { + type: 'array', + items: { type: 'string' }, + description: 'New tags for the character', + }, + }, + required: ['characterId', 'tags'], + }, + handler: (args) => novelManager.updateCharacterTags(args.characterId, args.tags), + displayData: { + isSetter: true, + action: 'updated', + subject: 'character tags', + }, + }, + { + name: 'create_inventory_item', + description: 'Create a new item in the novel inventory.', + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'Name of the item' }, + description: { type: 'string', description: 'Description of the item' }, + hidden: { type: 'boolean', description: 'Whether the item is hidden from the player by default' }, + scenes: { + type: 'array', + items: { type: 'string' }, + description: 'List of scenes where the item can be used. Empty for every scene.', + }, + actions: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Name of the action' }, + prompt: { type: 'string', description: 'Prompt for the action' }, + usageActions: { + type: 'array', + items: { + type: 'object', + properties: { + actionType: { + type: 'string', + enum: [ + 'attach_parent_scene_to_child', + 'suggest_advance_to_scene', + 'display_inventory_item', + 'hide_inventory_item', + ], + description: 'Type of the action', + }, + suggestSceneId: { type: 'string', description: 'ID of the scene to suggest to the player' }, + itemId: { type: 'string', description: 'ID of the item to add' }, + parentSceneId: { type: 'string', description: 'ID of the parent scene' }, + childSceneId: { type: 'string', description: 'ID of the child scene' }, + }, + required: ['actionType'], + }, + description: 'List of actions that can be used to trigger this item. Optional.', + }, + }, + }, + }, + }, + required: ['name', 'description'], + }, + handler: (args) => novelManager.createItem(args), + displayData: { + isSetter: true, + action: 'created', + subject: 'an item', + }, + }, + { + name: 'update_inventory_item', + description: 'Update an existing item in the novel inventory.', + parameters: { + type: 'object', + properties: { + itemId: { type: 'string', description: 'ID of the item to update' }, + name: { type: 'string', description: 'New name for the item' }, + description: { type: 'string', description: 'Updated description' }, + hidden: { type: 'boolean', description: 'Whether the item is hidden from the player' }, + scenesIds: { + type: 'array', + items: { type: 'string' }, + description: 'List of scene ids where the item can be used. Empty for every scene.', + }, + actions: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Name of the action' }, + prompt: { type: 'string', description: 'Prompt for the action' }, + usageActions: { + type: 'array', + items: { + type: 'object', + properties: { + actionType: { + type: 'string', + enum: [ + 'attach_parent_scene_to_child', + 'suggest_advance_to_scene', + 'display_inventory_item', + 'hide_inventory_item', + ], + description: 'Type of the action', + }, + suggestSceneId: { type: 'string', description: 'ID of the scene to suggest to the player' }, + itemId: { type: 'string', description: 'ID of the item to add' }, + parentSceneId: { type: 'string', description: 'ID of the parent scene' }, + childSceneId: { type: 'string', description: 'ID of the child scene' }, + }, + required: ['actionType'], + }, + description: 'List of actions that can be used to trigger this item. Optional.', + }, + }, + }, + }, + }, + required: ['itemId'], + }, + handler: (args) => novelManager.updateItem(args), + displayData: { + isSetter: true, + action: 'updated', + subject: 'an item', + }, + }, + { + name: 'remove_inventory_item', + description: 'Remove an item from the novel inventory.', + parameters: { + type: 'object', + properties: { + itemId: { type: 'string', description: 'ID of the item to remove' }, + }, + required: ['itemId'], + }, + handler: (args) => novelManager.removeItem(args.itemId), + displayData: { + isSetter: true, + action: 'removed', + subject: 'an item', + }, + }, + { + name: 'create_objective', + description: 'Create a new objective for the novel.', + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'Name of the objective' }, + description: { type: 'string', description: 'Short description of the objective' }, + hint: { type: 'string', description: 'Hint for the player about this objective' }, + condition: { type: 'string', description: 'Condition prompt that triggers the objective' }, + }, + required: ['name'], + }, + handler: (args) => novelManager.createObjective(args), + displayData: { + isSetter: true, + action: 'created', + subject: 'an objective', + }, + }, + { + name: 'update_objective', + description: 'Update an existing objective in the novel.', + parameters: { + type: 'object', + properties: { + objectiveId: { type: 'string', description: 'ID of the objective to update' }, + name: { type: 'string', description: 'Name of the objective' }, + description: { type: 'string', description: 'Short description' }, + hint: { type: 'string', description: 'Hint for the player' }, + condition: { type: 'string', description: 'Condition prompt that triggers the objective' }, + }, + required: ['objectiveId'], + }, + handler: (args) => novelManager.updateObjective(args), + displayData: { + isSetter: true, + action: 'updated', + subject: 'an objective', + }, + }, + { + name: 'remove_objective', + description: 'Remove an objective from the novel.', + parameters: { + type: 'object', + properties: { + objectiveId: { type: 'string', description: 'ID of the objective to remove' }, + }, + required: ['objectiveId'], + }, + handler: (args) => novelManager.removeObjective(args.objectiveId), + displayData: { + isSetter: true, + action: 'removed', + subject: 'an objective', + }, + }, + { + name: 'create_map', + description: 'Create a new map for the novel.', + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'Name of the map' }, + description: { type: 'string', description: 'Short description of the map' }, + }, + required: ['name'], + }, + handler: (args) => novelManager.createMap(args), + displayData: { + isSetter: true, + action: 'created', + subject: 'a map', + }, + }, + { + name: 'update_map', + description: 'Update an existing map in the novel.', + parameters: { + type: 'object', + properties: { + mapId: { type: 'string', description: 'ID of the map to update' }, + name: { type: 'string', description: 'New name for the map' }, + description: { type: 'string', description: 'Updated description' }, + }, + required: ['mapId'], + }, + handler: (args) => novelManager.updateMap(args), + displayData: { + isSetter: true, + action: 'updated', + subject: 'a map', + }, + }, + { + name: 'remove_map', + description: 'Remove a map from the novel.', + parameters: { + type: 'object', + properties: { + mapId: { type: 'string', description: 'ID of the map to remove' }, + }, + required: ['mapId'], + }, + handler: (args) => novelManager.removeMap(args.mapId), + displayData: { + isSetter: true, + action: 'removed', + subject: 'a map', + }, + }, + { + name: 'create_map_place', + description: 'Create a new place in an existing map.', + parameters: { + type: 'object', + properties: { + mapId: { type: 'string', description: 'ID of the map to create the place in' }, + name: { type: 'string', description: 'Name of the place' }, + sceneId: { type: 'string', description: 'If associated with a scene, specify its ID' }, + description: { type: 'string', description: 'Short description of the place' }, + }, + required: ['mapId', 'name'], + }, + handler: (args) => novelManager.createMapPlace(args), + displayData: { + isSetter: true, + action: 'created', + subject: 'a map place', + }, + }, + { + name: 'update_map_place', + description: 'Update a place in an existing map.', + parameters: { + type: 'object', + properties: { + mapId: { type: 'string', description: 'ID of the map containing the place' }, + placeId: { type: 'string', description: 'ID of the place to update' }, + name: { type: 'string', description: 'New name for the place' }, + sceneId: { type: 'string', description: 'Associated scene ID if any' }, + description: { type: 'string', description: 'Updated place description' }, + }, + required: ['mapId', 'placeId'], + }, + handler: (args) => novelManager.updateMapPlace(args), + displayData: { + isSetter: true, + action: 'updated', + subject: 'a map place', + }, + }, + { + name: 'remove_map_place', + description: 'Remove a place from an existing map.', + parameters: { + type: 'object', + properties: { + mapId: { type: 'string', description: 'ID of the map containing the place' }, + placeId: { type: 'string', description: 'ID of the place to remove' }, + }, + required: ['mapId', 'placeId'], + }, + handler: (args) => novelManager.removeMapPlace(args.mapId, args.placeId), + displayData: { + isSetter: true, + action: 'removed', + subject: 'a place from a map', + }, + }, + { + name: 'create_scene_indicator', + description: 'Attach a new indicator to a scene', + parameters: { + type: 'object', + properties: { + sceneId: { type: 'string', description: 'ID of the scene to receive the indicator' }, + name: { type: 'string', description: 'Indicator name' }, + description: { type: 'string', description: 'Indicator purpose or context' }, + type: { + type: 'string', + description: 'Indicator type: percentage, amount or discrete', + enum: ['percentage', 'amount', 'discrete'], + }, + values: { + type: 'array', + description: 'Valid only for discrete indicators. List of possible states', + items: { type: 'string' }, + }, + initialValue: { + type: 'string', + description: 'Default or starting value of the indicator', + }, + inferred: { + type: 'boolean', + description: 'If true, the system will set the indicator value automatically', + }, + step: { + type: 'number', + description: 'Step used to increment or decrement the indicator if not inferred', + }, + min: { + type: 'number', + description: 'Minimum range for amounts or percentages', + }, + max: { + type: 'number', + description: 'Maximum range for amounts or percentages', + }, + hidden: { + type: 'boolean', + description: 'Whether the indicator is hidden from the player', + }, + editable: { + type: 'boolean', + description: 'Whether the player can change this indicator in-game', + }, + color: { + type: 'string', + description: 'Color for the indicator, e.g. #4CAF50', + }, + }, + required: ['sceneId', 'name', 'type', 'initialValue'], + }, + handler: (args) => novelManager.addIndicatorToScene(args), + displayData: { + isSetter: true, + action: 'created', + subject: 'a scene indicator', + }, + }, + { + name: 'update_scene_indicator', + description: 'Update an existing indicator in a scene', + parameters: { + type: 'object', + properties: { + sceneId: { type: 'string', description: 'ID of the scene containing the indicator' }, + indicatorId: { type: 'string', description: 'ID of the indicator to update' }, + name: { type: 'string', description: 'Updated indicator name' }, + description: { type: 'string', description: 'Updated indicator purpose or context' }, + type: { + type: 'string', + description: 'Indicator type: percentage, amount or discrete', + enum: ['percentage', 'amount', 'discrete'], + }, + values: { + type: 'array', + description: 'Valid only for discrete indicators. List of possible states', + items: { type: 'string' }, + }, + initialValue: { type: 'string', description: 'Updated default or current value' }, + inferred: { type: 'boolean', description: 'If true, the system sets the value automatically' }, + step: { type: 'number', description: 'Step for increment/decrement' }, + min: { type: 'number', description: 'Minimum range' }, + max: { type: 'number', description: 'Maximum range' }, + hidden: { type: 'boolean', description: 'Whether the indicator is hidden' }, + editable: { type: 'boolean', description: 'Whether the player can change this indicator' }, + color: { type: 'string', description: 'Color (hex) for the indicator' }, + }, + required: ['sceneId', 'indicatorId'], + }, + handler: (args) => novelManager.updateIndicatorInScene(args), + displayData: { + isSetter: true, + action: 'updated', + subject: 'a scene indicator', + }, + }, + { + name: 'remove_scene_indicator', + description: 'Remove an indicator from a scene', + parameters: { + type: 'object', + properties: { + sceneId: { type: 'string', description: 'ID of the scene containing the indicator' }, + indicatorId: { type: 'string', description: 'ID of the indicator to remove' }, + }, + required: ['sceneId', 'indicatorId'], + }, + handler: (args) => novelManager.removeIndicatorFromScene(args.sceneId, args.indicatorId), + displayData: { + isSetter: true, + action: 'removed', + subject: 'a scene indicator', + }, + }, + { + name: 'get_objectives_in_scene', + description: 'Retrieve all objectives in a specific scene', + parameters: { + type: 'object', + properties: { + sceneId: { type: 'string', description: 'ID of the scene to list objectives from' }, + }, + required: ['sceneId'], + }, + handler: (args) => novelManager.getObjectivesInScene(args.sceneId), + displayData: { + isSetter: false, + }, + }, + { + name: 'get_all_inventory_items_in_novel', + description: 'Retrieve all inventory items in the novel inventory', + parameters: { type: 'object', properties: {} }, + handler: () => novelManager.getItems(), + displayData: { + isSetter: false, + }, + }, + { + name: 'get_indicators_in_scene', + description: 'Retrieve the indicators associated with a particular scene', + parameters: { + type: 'object', + properties: { + sceneId: { type: 'string', description: 'ID of the scene' }, + }, + required: ['sceneId'], + }, + handler: (args) => novelManager.getIndicatorsInScene(args.sceneId), + displayData: { + isSetter: false, + }, + }, + { + name: 'create_objective', + description: 'Create a new objective and attach it to a given scene.', + parameters: { + type: 'object', + properties: { + sceneId: { type: 'string', description: 'ID of the scene to attach this objective' }, + name: { type: 'string', description: 'Name of the objective' }, + description: { type: 'string', description: 'Short description of the objective' }, + hint: { type: 'string', description: 'Hint for the player about this objective' }, + condition: { type: 'string', description: 'Condition prompt that triggers the objective' }, + }, + required: ['sceneId', 'name'], + }, + handler: (args) => novelManager.createObjective(args), + displayData: { + isSetter: true, + action: 'created', + subject: 'an objective', + }, + }, + { + name: 'update_objective', + description: 'Update an existing objective in a specific scene.', + parameters: { + type: 'object', + properties: { + sceneId: { type: 'string', description: 'ID of the scene containing the objective' }, + objectiveId: { type: 'string', description: 'ID of the objective to update' }, + name: { type: 'string', description: 'Name of the objective' }, + description: { type: 'string', description: 'Short description' }, + hint: { type: 'string', description: 'Hint for the player' }, + condition: { type: 'string', description: 'Condition prompt that triggers the objective' }, + }, + required: ['sceneId', 'objectiveId'], + }, + handler: (args) => novelManager.updateObjective(args), + displayData: { + isSetter: true, + action: 'updated', + subject: 'an objective', + }, + }, + { + name: 'remove_objective', + description: 'Remove an objective from a specific scene.', + parameters: { + type: 'object', + properties: { + sceneId: { type: 'string', description: 'ID of the scene that has the objective' }, + objectiveId: { type: 'string', description: 'ID of the objective to remove' }, + }, + required: ['sceneId', 'objectiveId'], + }, + handler: (args) => novelManager.removeObjective(args.objectiveId), + displayData: { + isSetter: true, + action: 'removed', + subject: 'an objective', + }, + }, + { + name: 'get_all_maps_in_novel', + description: 'Retrieve all maps in the novel', + parameters: { + type: 'object', + properties: {}, + }, + handler: () => novelManager.getMaps(), + displayData: { + isSetter: false, + }, + }, + { + name: 'set_map_scenes', + description: 'Define a list of scene IDs where the map is accessible or visible', + parameters: { + type: 'object', + properties: { + mapId: { type: 'string', description: 'ID of the map to attach to scenes' }, + sceneIds: { + type: 'array', + description: 'List of scene IDs where the map is visible', + items: { type: 'string' }, + }, + }, + required: ['mapId', 'sceneIds'], + }, + handler: (args) => novelManager.setMapScenes(args.mapId, args.sceneIds), + displayData: { + isSetter: true, + action: 'updated', + subject: 'the scenes that have a map', + }, + }, + ]; + } + + getFunctionDefinitions(): FunctionDefinition[] { + return this.functionDefinitions; + } + + getFunctionDisplayData(name: string): + | { isSetter: false } + | { + isSetter: true; + action: FunctionAction; + subject: string; + } + | null { + return this.functionDefinitions.find((fn) => fn.name === name)?.displayData || null; + } + + async executeFunction(name: string, args: any): Promise { + const handler = this.functions.get(name); + if (!handler) { + throw new Error(`Function ${name} not found`); + } + return handler(args); + } +} diff --git a/apps/novel-builder/src/components/novel-assistant/prompt/NovelSpec.ts b/apps/novel-builder/src/components/novel-assistant/prompt/NovelSpec.ts new file mode 100644 index 00000000..3965f3d0 --- /dev/null +++ b/apps/novel-builder/src/components/novel-assistant/prompt/NovelSpec.ts @@ -0,0 +1,1529 @@ +import { NovelV3, validateNovelState } from '@mikugg/bot-utils'; +import { BackgroundResult, listSearch, SearchType, SongResult } from '../../../libs/listSearch'; +import config from '../../../config'; + +export interface LoreBookEntry { + id: string; + name: string; + keywords: string[]; + content: string; +} + +export interface LoreBook { + id: string; + name: string; + isGlobal: boolean; + sceneIds: string[]; + entries: LoreBookEntry[]; +} + +export enum CharacterEmotion { + ANGRY = 'angry', + SAD = 'sad', + HAPPY = 'happy', + DISGUSTED = 'disgusted', + BEGGING = 'begging', + SCARED = 'scared', + EXCITED = 'excited', + HOPEFUL = 'hopeful', + LONGING = 'longing', + PROUD = 'proud', + NEUTRAL = 'neutral', + RAGE = 'rage', + SCORN = 'scorn', + BLUSHED = 'blushed', + PLEASURE = 'pleasure', + LUSTFUL = 'lustful', + SHOCKED = 'shocked', + CONFUSED = 'confused', + DISAPPOINTED = 'disappointed', + EMBARRASSED = 'embarrassed', + GUILTY = 'guilty', + SHY = 'shy', + FRUSTRATED = 'frustrated', + ANNOYED = 'annoyed', + EXHAUSTED = 'exhausted', + TIRED = 'tired', + CURIOUS = 'curious', + INTRIGUED = 'intrigued', + AMUSED = 'amused', +} + +export interface CutScenePart { + id: string; + text: { + type: 'dialogue' | 'description'; + content: string; + }[]; + background: string; + music?: string; + characters: { + id: string; + outfitId: string; + emotionId: CharacterEmotion; + }[]; +} + +export interface SceneObjective { + characterId: string; + objective: string; +} + +const ids: Map = new Map(); +function getId(prefix: string): string { + if (!ids.has(prefix)) { + ids.set(prefix, 0); + } + ids.set(prefix, (ids.get(prefix) || 0) + 1); + return `${prefix}_${ids.get(prefix)}`; +} + +export class NovelManager { + private novel: NovelV3.NovelState; + + constructor() { + this.novel = { + title: '', + description: '', + logoPic: '', + author: '', + tags: [], + characters: [], + backgrounds: [], + songs: [], + maps: [], + scenes: [], + starts: [], + lorebooks: [], + inventory: [], + cutscenes: [], + language: 'en', + }; + } + + replaceState(state: NovelV3.NovelState) { + this.novel = JSON.parse(JSON.stringify(state)); + } + + getNovelState(): NovelV3.NovelState { + return JSON.parse(JSON.stringify(this.novel)); + } + + async getNovelStats(): Promise { + const warns = validateNovelState(this.novel); + return JSON.stringify({ + warnings: warns, + scenes: this.novel.scenes?.length, + characters: this.novel.characters?.length, + backgrounds: this.novel.backgrounds?.length, + songs: this.novel.songs?.length, + maps: this.novel.maps?.length, + starts: this.novel.starts?.length, + lorebooks: this.novel.lorebooks?.length, + inventory: this.novel.inventory?.length, + cutscenes: this.novel.cutscenes?.length, + }); + } + + async getTitle(): Promise { + return this.novel.title || 'empty value'; + } + + async getDescription(): Promise { + return this.novel.description || 'empty value'; + } + + async setTitle(title: string): Promise { + this.novel.title = title; + return `Title set to: ${title}`; + } + + async setDescription(description: string): Promise { + this.novel.description = description; + return `Description updated successfully`; + } + + async getLoreBooks(): Promise { + if (!this.novel.lorebooks) return 'empty value'; + return this.novel.lorebooks.length > 0 ? JSON.stringify(this.novel.lorebooks) : 'empty value'; + } + + async setLoreBookDetails(params: { + lorebookId?: string; + name?: string; + description?: string; + isGlobal?: boolean; + }): Promise { + const { lorebookId, name, description, isGlobal } = params; + + if (lorebookId) { + // Update existing lorebook + const lorebook = this.novel.lorebooks?.find((lb) => lb.id === lorebookId); + if (!lorebook) return 'Lorebook not found'; + + if (name) lorebook.name = name; + if (description) lorebook.description = description; + if (isGlobal !== undefined) lorebook.isGlobal = isGlobal; + + return 'Lorebook updated successfully'; + } else { + // Create new lorebook + if (!name || isGlobal === undefined) { + return 'Error: missing required parameters for new lorebook'; + } + + const newLorebook: NovelV3.NovelLorebook = { + id: getId('lorebook'), + name, + description: description || '', + isGlobal, + entries: [], + extensions: [], + }; + + this.novel.lorebooks?.push(newLorebook); + return `New lorebook created with ID: ${newLorebook.id}`; + } + } + + async addLorebookToScene(lorebookId: string, sceneId: string): Promise { + const lorebook = this.novel.lorebooks?.find((lb) => lb.id === lorebookId); + if (!lorebook) return 'Lorebook not found'; + + const scene = this.novel.scenes?.find((s) => s.id === sceneId); + if (!scene) return 'Scene not found'; + if (!scene.lorebookIds) scene.lorebookIds = []; + + if (!scene.lorebookIds.includes(lorebookId)) { + scene.lorebookIds.push(lorebookId); + } + return 'Lorebook added to scene successfully'; + } + + async removeLorebookFromScene(lorebookId: string, sceneId: string): Promise { + const scene = this.novel.scenes?.find((s) => s.id === sceneId); + if (!scene) return 'Scene not found'; + + scene.lorebookIds = scene.lorebookIds?.filter((id) => id !== lorebookId); + return 'Lorebook removed from scene successfully'; + } + + async removeEntryFromLorebook(lorebookId: string, entryId: number): Promise { + const lorebook = this.novel.lorebooks?.find((lb) => lb.id === lorebookId); + if (!lorebook) return 'Lorebook not found'; + + lorebook.entries = lorebook.entries.filter((entry) => entry.id !== entryId); + return 'Entry removed from lorebook successfully'; + } + + async setEntryToLorebook(params: { + lorebookId: string; + entryId?: number; + name?: string; + keywords?: string[]; + content?: string; + }): Promise { + const { lorebookId, entryId, name, keywords, content } = params; + const lorebook = this.novel.lorebooks?.find((lb) => lb.id === lorebookId); + if (!lorebook) return 'Lorebook not found'; + + if (entryId) { + // Update existing entry + const entry = lorebook.entries.find((e) => e.id === entryId); + if (!entry) return 'Entry not found'; + + if (name) entry.name = name; + if (keywords) entry.keys = keywords; + if (content) entry.content = content; + + return 'Entry updated successfully'; + } else { + // Create new entry + if (!name || !keywords) { + return 'Error: missing required parameters for new entry'; + } + + const id = Number(getId('entry')); + lorebook.entries.push({ + id, + enabled: true, + insertion_order: 0, + case_sensitive: false, + name: name, + priority: 0, + position: 'before_char', + keys: keywords, + content: content || '', + extensions: {}, + }); + return `New entry created with ID: ${id}`; + } + } + + async addCharacterBasics(params: { + characterId?: string; + name: string; + short_description: string; + tags: string[]; + }): Promise { + const { characterId, name, short_description, tags } = params; + + if (characterId) { + // Update existing character + const character = this.novel.characters.find((c) => c.id === characterId); + if (!character) return `Error: Character with id ${characterId} not found`; + + character.name = name; + character.short_description = short_description; + character.card.data.tags = tags; + character.card.data.name = name; + character.card.data.extensions.mikugg_v2.short_description = short_description; + + return 'Character updated successfully'; + } else { + const id = getId('character'); + const outfitId = getId('outfit'); + // Create new character + this.novel.characters.push({ + id, + name, + short_description, + profile_pic: 'empty_char.png', + tags, + nsfw: NovelV3.NovelNSFW.NONE, + card: { + spec: 'chara_card_v2', + spec_version: '2.0', + data: { + name: 'char1', + alternate_greetings: [], + character_version: '1', + creator: '', + creator_notes: '', + description: '', + extensions: { + mikugg_v2: { + license: 'CC BY-NC-SA 4.0', + language: 'en', + short_description, + profile_pic: 'empty_char.png', + nsfw: NovelV3.NovelNSFW.NONE, + outfits: [ + { + id: outfitId, + name: 'default', + description: 'The default outfit', + attributes: [], + template: 'single-emotion', + nsfw: NovelV3.NovelNSFW.NONE, + emotions: [ + { + id: 'neutral', + sources: { + png: 'empty_char_emotion.png', + }, + }, + ], + }, + ], + }, + }, + first_mes: '', + mes_example: '', + personality: '', + post_history_instructions: '', + scenario: '', + system_prompt: '', + tags: [], + }, + }, + }); + return `New character created with ID: ${id} with outfit ID: ${outfitId}`; + } + } + + async setCharacterPrompts(params: { + characterId: string; + prompt: string; + conversation_examples: string; + }): Promise { + const character = this.novel.characters.find((c) => c.id === params.characterId); + if (!character) return `Error: Character with id ${params.characterId} not found`; + + character.card.data.description = params.prompt; + character.card.data.mes_example = params.conversation_examples; + + return 'Character prompts updated successfully'; + } + + async deleteCharacter(characterId: string): Promise { + const initialLength = this.novel.characters.length; + this.novel.characters = this.novel.characters.filter((c) => c.id !== characterId); + + return initialLength !== this.novel.characters.length + ? 'Character deleted successfully' + : `Error: Character with id ${characterId} not found`; + } + + async getCharacters(): Promise { + if (!this.novel.characters.length) return 'empty value'; + + const characterList = this.novel.characters.map((char) => ({ + id: char.id, + name: char.name, + short_description: char.short_description, + prompt: char.card.data.description, + conversation_examples: char.card.data.mes_example, + outfits: char.card.data.extensions.mikugg_v2.outfits.map((outfit) => ({ + id: outfit.id, + name: outfit.name, + description: outfit.description, + })), + lorebookIds: char.lorebookIds || [], + })); + + return JSON.stringify(characterList); + } + + async attachLorebookToCharacter(characterId: string, lorebookId: string): Promise { + const character = this.novel.characters.find((c) => c.id === characterId); + if (!character) return `Error: Character with id ${characterId} not found`; + + const lorebook = this.novel.lorebooks?.find((l) => l.id === lorebookId); + if (!lorebook) return `Error: Lorebook with id ${lorebookId} not found`; + + if (!character.lorebookIds) { + character.lorebookIds = []; + } + + if (!character.lorebookIds.includes(lorebookId)) { + character.lorebookIds.push(lorebookId); + } + + return 'Lorebook attached to character successfully'; + } + + async detachLorebookFromCharacter(characterId: string, lorebookId: string): Promise { + const character = this.novel.characters.find((c) => c.id === characterId); + if (!character) return `Error: Character with id ${characterId} not found`; + + if (!character.lorebookIds) { + return `Error: Character with id ${characterId} has no lorebooks attached`; + } + + character.lorebookIds = character.lorebookIds.filter((id) => id !== lorebookId); + + return 'Lorebook detached from character successfully'; + } + + async addBackgroundFromDatabase(prompt: string): Promise { + try { + let background: BackgroundResult = { + id: 'default_background', + description: 'default_background', + asset: 'default_background.png', + sdPrompt: null, + sdModel: null, + sdParams: null, + author: { + id: 'miku', + username: 'miku', + profilePic: null, + }, + createdAt: new Date(), + tags: [], + }; + let searchingError = false; + try { + // Use vector search to find the most similar background + const results = await listSearch(config.platformAPIEndpoint, SearchType.BACKGROUND_VECTORS, { + search: prompt, + take: 1, + skip: 0, + }); + + if (!results.length) { + searchingError = true; + console.error('No matching background found for the given description'); + } else { + background = results[0]; + } + } catch (error) { + console.error('Error searching for background:', error); + searchingError = true; + } + + const id = getId('background'); + + this.novel.backgrounds.push({ + id, + name: `background_${id}`, + description: background.description, + source: { + jpg: background.asset, + }, + attributes: background.tags?.map((tag) => ['tag', tag]) || [], + }); + + return `Background added with ID: ${id}. Description: ${background.description}.${ + searchingError + ? ' There was an error searching for the background in the database. Using default background' + : '' + }`; + } catch (error) { + console.error('Error searching for background:', error); + return 'Error searching for background in database'; + } + } + + async modifyBackgroundDescription(backgroundId: string, description: string): Promise { + const background = this.novel.backgrounds?.find((bg) => bg.id === backgroundId); + if (!background) return `Error: Background with id ${backgroundId} not found`; + + background.description = description; + return 'Background description updated successfully'; + } + + async removeBackground(backgroundId: string): Promise { + const initialLength = this.novel.backgrounds.length; + this.novel.backgrounds = this.novel.backgrounds.filter((bg) => bg.id !== backgroundId); + + return initialLength !== this.novel.backgrounds.length ? 'Background removed successfully' : 'Background not found'; + } + + async getBackgrounds(): Promise { + if (!this.novel.backgrounds.length) return "There's no backgrounds"; + + const backgroundList = this.novel.backgrounds.map((bg) => ({ + id: bg.id, + description: bg.description, + })); + + return JSON.stringify(backgroundList); + } + + async addMusicFromDatabase(prompt: string): Promise { + try { + let song: SongResult = { + id: 'devonshire', + title: 'default_song', + description: 'default_song', + asset: 'devonshire.mp3', + tags: [], + authorId: 'miku', + createdAt: new Date(), + updatedAt: new Date(), + }; + let searchingError = false; + try { + // Use vector search to find the most similar song + const results = await listSearch(config.platformAPIEndpoint, SearchType.SONG_VECTOR, { + search: prompt, + take: 1, + skip: 0, + }); + + if (!results.length) { + searchingError = true; + } else { + song = results[0]; + } + } catch (error) { + searchingError = true; + console.error('Error searching for song:', error); + } + + const id = getId('music'); + + this.novel.songs.push({ + id, + name: song.title || `song_${id}`, + description: song.description, + source: song.asset, + tags: song.tags || [], + }); + + return `Music added with ID: ${id}. Description: ${song.description}.${ + searchingError ? ' There was an error searching for the song in the database. Using default song' : '' + }`; + } catch (error) { + console.error('Error searching for song:', error); + return 'Error searching for song in database'; + } + } + + async modifyMusicDescription(musicId: string, description: string): Promise { + const music = this.novel.songs?.find((s) => s.id === musicId); + if (!music) return `Error: Music with id ${musicId} with id ${musicId} not found`; + + music.description = description; + return 'Music description updated successfully'; + } + + async removeMusic(musicId: string): Promise { + const initialLength = this.novel.songs.length; + this.novel.songs = this.novel.songs.filter((song) => song.id !== musicId); + + return initialLength !== this.novel.songs.length + ? 'Music removed successfully' + : `Error: Music with id ${musicId} not found`; + } + + async getMusic(): Promise { + if (!this.novel.songs.length) return 'empty value'; + + const musicList = this.novel.songs.map((song) => ({ + id: song.id, + description: song.description, + })); + + return JSON.stringify(musicList); + } + + async setSceneDetails(params: { + sceneId?: string; // If not provided, creates a new scene + name?: string; + short_description?: string; + prompt?: string; + characters?: { + id: string; + outfitId: string; + emotionId: CharacterEmotion; + objective?: string; + }[]; + backgroundId?: string; + musicId?: string; + cutscene?: { + triggerOnlyOnce: boolean; + parts: { + text: { + type: 'dialogue' | 'description'; + content: string; + }[]; + backgroundId: string; + music?: string; + characters: { + id: string; + outfitId: string; + emotionId: CharacterEmotion; + }[]; + }[]; + }; + hint?: string; + condition?: string; + actionText?: string; + }): Promise { + const { sceneId, ...sceneData } = params; + + if (!sceneId) { + if (!sceneData.name) return 'Error: missing scene name'; + if (!sceneData.short_description) return 'Error: missing scene short description'; + if (!sceneData.prompt) return 'Error: missing scene prompt'; + if (!sceneData.characters) return 'Error: missing scene characters'; + if (!sceneData.backgroundId) return 'Error: missing scene background'; + if (!sceneData.musicId) return 'Error: missing scene music'; + + // Validate characters length + if (sceneData.characters.length === 0 || sceneData.characters.length > 2) { + return 'Error: Scene must have 1 or 2 characters'; + } + + // Validate characters exist and their outfits + for (const char of sceneData.characters) { + const character = this.novel.characters.find((c) => c.id === char.id); + if (!character) return `Error: Character with ID ${char.id} not found`; + + const outfit = character.card.data.extensions.mikugg_v2.outfits.find((o) => o.id === char.outfitId); + if (!outfit) { + return `Error: Outfit with ID ${char.outfitId} not found for character ${char.id}`; + } + } + + // Validate background exists + const bgExists = this.novel.backgrounds.find((bg) => bg.id === sceneData.backgroundId); + if (!bgExists) return `Error: Background with ID ${sceneData.backgroundId} not found`; + + // Validate music exists + const musicExists = this.novel.songs.find((s) => s.id === sceneData.musicId); + if (!musicExists) return `Error: Music with ID ${sceneData.musicId} not found`; + } else { + // Updating existing scene - only validate what's being updated + const existingScene = this.novel.scenes.find((s) => s.id === sceneId); + if (!existingScene) return `Error: Scene with ID ${sceneId} not found`; + + if (sceneData.characters) { + // Validate characters length + if (sceneData.characters.length === 0 || sceneData.characters.length > 2) { + return 'Error: Scene must have 1 or 2 characters'; + } + + // Validate characters and outfits + for (const char of sceneData.characters) { + const character = this.novel.characters.find((c) => c.id === char.id); + if (!character) return `Error: Character with ID ${char.id} not found`; + + const outfit = character.card.data.extensions.mikugg_v2.outfits.find((o) => o.id === char.outfitId); + if (!outfit) { + return `Error: Outfit with ID ${char.outfitId} not found for character ${char.id}`; + } + } + } + + if (sceneData.backgroundId) { + const bgExists = this.novel.backgrounds.find((bg) => bg.id === sceneData.backgroundId); + if (!bgExists) return `Error: Background with ID ${sceneData.backgroundId} not found`; + } + + if (sceneData.musicId) { + const musicExists = this.novel.songs.find((s) => s.id === sceneData.musicId); + if (!musicExists) return `Error: Music with ID ${sceneData.musicId} not found`; + } + } + + // Handle cutscene if provided + let cutSceneId: string | null = null; + if (sceneData.cutscene) { + const existingScene = sceneId ? this.novel.scenes.find((s) => s.id === sceneId) : null; + cutSceneId = existingScene?.cutScene?.id || getId('cutscene'); + + const cutsceneData = { + id: cutSceneId, + name: 'cutscene', + parts: sceneData.cutscene.parts.map((part) => ({ + id: getId('cutscene'), + text: part.text, + background: part.backgroundId, + music: part.music, + characters: part.characters, + })), + }; + + if (existingScene?.cutScene) { + const existingCutscene = this.novel.cutscenes?.find((cs) => cs.id === cutSceneId); + if (existingCutscene) { + Object.assign(existingCutscene, cutsceneData); + } + } else { + if (!this.novel.cutscenes) { + this.novel.cutscenes = []; + } + this.novel.cutscenes.push(cutsceneData); + } + } + + if (sceneId) { + // Update existing scene - only update provided fields + const sceneIndex = this.novel.scenes.findIndex((s) => s.id === sceneId); + if (sceneIndex === -1) return 'Error: Scene not found'; + + const existingScene = this.novel.scenes[sceneIndex]; + + // Update only the fields that were provided + if (sceneData.name) existingScene.name = sceneData.name; + if (sceneData.short_description) existingScene.description = sceneData.short_description; + if (sceneData.prompt) existingScene.prompt = sceneData.prompt; + if (sceneData.characters) { + existingScene.characters = sceneData.characters.map((char) => ({ + characterId: char.id, + outfit: char.outfitId, + objective: char.objective, + })); + } + if (sceneData.backgroundId) existingScene.backgroundId = sceneData.backgroundId; + if (sceneData.musicId) existingScene.musicId = sceneData.musicId; + if (sceneData.cutscene && cutSceneId) { + existingScene.cutScene = { + id: cutSceneId, + triggerOnlyOnce: sceneData.cutscene.triggerOnlyOnce, + triggered: false, + }; + } + if (sceneData.hint !== undefined) existingScene.hint = sceneData.hint; + if (sceneData.condition !== undefined) existingScene.condition = sceneData.condition || null; + if (sceneData.actionText !== undefined) existingScene.actionText = sceneData.actionText; + + return 'Scene updated successfully'; + } else { + // Create new scene + const newScene: NovelV3.NovelScene = { + id: getId('scene'), + name: sceneData.name!, + description: sceneData.short_description!, + prompt: sceneData.prompt!, + characters: sceneData.characters!.map((char) => ({ + characterId: char.id, + outfit: char.outfitId, + objective: char.objective, + })), + backgroundId: sceneData.backgroundId!, + musicId: sceneData.musicId!, + cutScene: cutSceneId + ? { + id: cutSceneId, + triggerOnlyOnce: sceneData.cutscene?.triggerOnlyOnce || false, + triggered: false, + } + : undefined, + hint: sceneData.hint, + condition: sceneData.condition || null, + actionText: sceneData.actionText || '', + lorebookIds: [], + children: [], + nsfw: NovelV3.NovelNSFW.NONE, + }; + + this.novel.scenes.push(newScene); + return `Scene created with ID: ${newScene.id}`; + } + } + + async removeScene(sceneId: string): Promise { + const initialLength = this.novel.scenes.length; + this.novel.scenes = this.novel.scenes.filter((s) => s.id !== sceneId); + + // Also remove this scene from any parent scenes' childrenIds + this.novel.scenes.forEach((scene) => { + if (scene.children?.includes(sceneId)) { + scene.children = scene.children.filter((id) => id !== sceneId); + } + }); + + return initialLength !== this.novel.scenes.length ? 'Scene removed successfully' : 'Scene not found'; + } + + async connectScenes(sceneId: string, childSceneId: string): Promise { + const parentScene = this.novel.scenes.find((s) => s.id === sceneId); + if (!parentScene) return 'Error: Parent scene not found'; + + const childScene = this.novel.scenes.find((s) => s.id === childSceneId); + if (!childScene) return 'Error: Child scene not found'; + + if (!parentScene.children) { + parentScene.children = []; + } + + if (!parentScene.children.includes(childSceneId)) { + parentScene.children.push(childSceneId); + } + + return 'Scenes connected successfully'; + } + + async disconnectScenes(sceneId: string, childSceneId: string): Promise { + const parentScene = this.novel.scenes.find((s) => s.id === sceneId); + if (!parentScene) return 'Parent scene not found'; + + if (!parentScene.children?.includes(childSceneId)) { + return 'Error: Scenes are not connected'; + } + + parentScene.children = parentScene.children.filter((id) => id !== childSceneId); + return 'Scenes disconnected successfully'; + } + + async addStart(params: { + sceneId: string; + name: string; + short_description: string; + firstMessagePerCharacters: { + characterId: string; + message: string; + emotionId: CharacterEmotion; + }[]; + }): Promise { + const { sceneId, name, short_description, firstMessagePerCharacters } = params; + + // Validate scene exists + const scene = this.novel.scenes.find((s) => s.id === sceneId); + if (!scene) return 'Error: Scene not found'; + + // Validate characters exist and emotions + for (const msg of firstMessagePerCharacters) { + const character = this.novel.characters.find((c) => c.id === msg.characterId); + if (!character) { + return `Error: Character with ID ${msg.characterId} not found`; + } + } + // check if characters.length in the scene === firstMessages.length + if (scene.characters.length !== firstMessagePerCharacters.length) { + return 'Error: Number of characters in the scene does not match the number of characters in the first messages'; + } + // check if all characters in the scene are in the first messages + for (const char of scene.characters) { + if (!firstMessagePerCharacters.some((msg) => msg.characterId === char.characterId)) { + return `Error: Character with ID ${char.characterId} not found in the first messages`; + } + } + + const newStart: NovelV3.NovelStart = { + id: getId('start'), + title: name, + description: short_description, + sceneId, + characters: firstMessagePerCharacters.map((msg) => ({ + characterId: msg.characterId, + text: msg.message, + emotion: msg.emotionId, + pose: '', + })), + }; + + if (!this.novel.starts) { + this.novel.starts = []; + } + + this.novel.starts.push(newStart); + return `Start point created with ID: ${newStart.id}`; + } + + async removeStart(startId: string): Promise { + const initialLength = this.novel.starts?.length || 0; + if (!this.novel.starts) return 'Error: Start not found'; + + this.novel.starts = this.novel.starts.filter((s) => s.id !== startId); + + return initialLength !== this.novel.starts.length ? 'Start removed successfully' : 'Error: Start not found'; + } + + async moveStartAsFirstOption(startId: string): Promise { + if (!this.novel.starts || this.novel.starts.length === 0) { + return 'No starts available'; + } + + const startIndex = this.novel.starts.findIndex((s) => s.id === startId); + if (startIndex === -1) return 'Error: Start not found'; + if (startIndex === 0) return 'Error: Start is already the first option'; + + // Remove the start from its current position and insert it at the beginning + const [start] = this.novel.starts.splice(startIndex, 1); + this.novel.starts.unshift(start); + + return 'Start moved to first position successfully'; + } + + async updateSceneCutscene( + sceneId: string, + cutscene: { + triggerOnlyOnce: boolean; + parts: { + text: { + type: 'dialogue' | 'description'; + content: string; + }[]; + backgroundId: string; + musicId: string; + characters: { + id: string; + outfitId: string; + emotionId: CharacterEmotion; + }[]; + }[]; + }, + ): Promise { + const scene = this.novel.scenes.find((s) => s.id === sceneId); + if (!scene) return 'Error: Scene not found'; + + const cutSceneId = scene.cutScene?.id || getId('cutscene'); + + const cutsceneData = { + id: cutSceneId, + name: 'cutscene', + parts: cutscene.parts.map((part) => ({ + id: getId('cutscene'), + text: part.text, + background: part.backgroundId, + music: part.musicId, + characters: part.characters, + })), + }; + + if (scene.cutScene) { + const existingCutscene = this.novel.cutscenes?.find((cs) => cs.id === cutSceneId); + if (existingCutscene) { + Object.assign(existingCutscene, cutsceneData); + } + } else { + if (!this.novel.cutscenes) { + this.novel.cutscenes = []; + } + this.novel.cutscenes.push(cutsceneData); + } + + scene.cutScene = { + id: cutSceneId, + triggerOnlyOnce: cutscene.triggerOnlyOnce, + triggered: false, + }; + + return 'Scene cutscene updated successfully'; + } + + async updateSceneHint(sceneId: string, hint: string): Promise { + const scene = this.novel.scenes.find((s) => s.id === sceneId); + if (!scene) return 'Error: Scene not found'; + + scene.hint = hint; + return 'Scene hint updated successfully'; + } + + async updateSceneCondition(sceneId: string, condition: string): Promise { + const scene = this.novel.scenes.find((s) => s.id === sceneId); + if (!scene) return 'Error: Scene not found'; + + scene.condition = condition; + return 'Scene condition updated successfully'; + } + + async updateSceneCharacters(sceneId: string, characters: any[]): Promise { + const scene = this.novel.scenes.find((s) => s.id === sceneId); + if (!scene) return 'Scene not found'; + + // Validate characters length + if (characters.length === 0 || characters.length > 2) { + return 'Scene must have 1 or 2 characters'; + } + + // Validate characters and outfits + for (const char of characters) { + const character = this.novel.characters.find((c) => c.id === char.id); + if (!character) return `Error: Character with ID ${char.id} not found`; + + const outfit = character.card.data.extensions.mikugg_v2.outfits.find((o) => o.id === char.outfitId); + if (!outfit) { + return `Error: Outfit with ID ${char.outfitId} not found for character ${char.id}`; + } + } + + scene.characters = characters.map((char) => ({ + characterId: char.id, + outfit: char.outfitId, + objective: char.objective, + })); + + return 'Scene characters updated successfully'; + } + + async updateSceneBackground(sceneId: string, backgroundId: string): Promise { + const scene = this.novel.scenes.find((s) => s.id === sceneId); + if (!scene) return 'Error: Scene not found'; + + const background = this.novel.backgrounds.find((bg) => bg.id === backgroundId); + if (!background) return 'Error: Background not found'; + + scene.backgroundId = backgroundId; + return 'Scene background updated successfully'; + } + + async updateSceneMusic(sceneId: string, musicId: string): Promise { + const scene = this.novel.scenes.find((s) => s.id === sceneId); + if (!scene) return 'Error: Scene not found'; + + const music = this.novel.songs.find((s) => s.id === musicId); + if (!music) return 'Error: Music not found'; + + scene.musicId = musicId; + return 'Scene music updated successfully'; + } + + async updateCharacterName(characterId: string, name: string): Promise { + const character = this.novel.characters.find((c) => c.id === characterId); + if (!character) return `Error: Character with id ${characterId} not found`; + + character.name = name; + character.card.data.name = name; + return 'Character name updated successfully'; + } + + async updateCharacterShortDescription(characterId: string, shortDescription: string): Promise { + const character = this.novel.characters.find((c) => c.id === characterId); + if (!character) return `Error: Character with id ${characterId} not found`; + + character.short_description = shortDescription; + character.card.data.extensions.mikugg_v2.short_description = shortDescription; + return 'Character short description updated successfully'; + } + + async updateCharacterTags(characterId: string, tags: string[]): Promise { + const character = this.novel.characters.find((c) => c.id === characterId); + if (!character) return `Error: Character with id ${characterId} not found`; + + character.tags = tags; + character.card.data.tags = tags; + return 'Character tags updated successfully'; + } + + async getScenes(): Promise { + return JSON.stringify({ + amount_of_scenes: this.novel.scenes.length, + scenes: this.novel.scenes, + }); + } + + async createItem(args: { + name: string; + description: string; + hidden?: boolean; + scenes?: string[]; + actions?: { + name: string; + prompt: string; + usageActions?: { + actionType: + | 'attach_parent_scene_to_child' + | 'suggest_advance_to_scene' + | 'display_inventory_item' + | 'hide_inventory_item'; + suggestSceneId?: string; + itemId?: string; + parentSceneId?: string; + childSceneId?: string; + }[]; + }[]; + }): Promise { + if (!this.novel.inventory) { + this.novel.inventory = []; + } + const itemId = getId('item'); + + // Convert item actions (if provided) + const itemActions: NovelV3.InventoryAction[] = + args.actions?.map((action) => ({ + id: getId('itemaction'), + name: action.name, + prompt: action.prompt, + usageActions: + action.usageActions?.map((usageAction) => { + switch (usageAction.actionType) { + case 'attach_parent_scene_to_child': + return { + type: NovelV3.NovelActionType.ADD_CHILD_SCENES, + params: { sceneId: usageAction.parentSceneId || '', children: [usageAction.childSceneId || ''] }, + }; + case 'suggest_advance_to_scene': + return { + type: NovelV3.NovelActionType.SUGGEST_ADVANCE_SCENE, + params: { sceneId: usageAction.suggestSceneId || '' }, + }; + case 'display_inventory_item': + return { + type: NovelV3.NovelActionType.SHOW_ITEM, + params: { itemId: usageAction.itemId || '' }, + }; + case 'hide_inventory_item': + default: + return { + type: NovelV3.NovelActionType.HIDE_ITEM, + params: { itemId: usageAction.itemId || '' }, + }; + } + }) || [], + })) || []; + + // Create and store the item + this.novel.inventory.push({ + id: itemId, + name: args.name, + description: args.description, + icon: '', + hidden: args.hidden ?? false, + isPremium: false, + isNovelOnly: true, + locked: args.scenes?.length ? { type: 'IN_SCENE', config: { sceneIds: args.scenes } } : undefined, + actions: itemActions, + }); + + return `Item created with ID: ${itemId}`; + } + + async updateItem(args: { + itemId: string; + name?: string; + description?: string; + hidden?: boolean; + scenes?: string[]; + actions?: { + name: string; + prompt: string; + usageActions?: { + actionType: + | 'attach_parent_scene_to_child' + | 'suggest_advance_to_scene' + | 'display_inventory_item' + | 'hide_inventory_item'; + suggestSceneId?: string; + itemId?: string; + parentSceneId?: string; + childSceneId?: string; + }[]; + }[]; + }): Promise { + if (!this.novel.inventory) return 'Error: No items in the inventory'; + + const item = this.novel.inventory.find((itm) => itm.id === args.itemId); + if (!item) return 'Error: Item not found'; + + // Update fields only if present in args + if (args.name !== undefined) item.name = args.name; + if (args.description !== undefined) item.description = args.description; + if (args.hidden !== undefined) item.hidden = args.hidden; + // If actions were provided, overwrite the existing actions + if (args.actions !== undefined) { + item.actions = + args.actions?.map((action) => ({ + id: getId('itemaction'), + name: action.name, + prompt: action.prompt, + usageActions: + action.usageActions?.map((usageAction) => { + switch (usageAction.actionType) { + case 'attach_parent_scene_to_child': + return { + type: NovelV3.NovelActionType.ADD_CHILD_SCENES, + params: { sceneId: usageAction.parentSceneId || '', children: [usageAction.childSceneId || ''] }, + }; + case 'suggest_advance_to_scene': + return { + type: NovelV3.NovelActionType.SUGGEST_ADVANCE_SCENE, + params: { sceneId: usageAction.suggestSceneId || '' }, + }; + case 'display_inventory_item': + return { + type: NovelV3.NovelActionType.SHOW_ITEM, + params: { itemId: usageAction.itemId || '' }, + }; + case 'hide_inventory_item': + default: + return { + type: NovelV3.NovelActionType.HIDE_ITEM, + params: { itemId: usageAction.itemId || '' }, + }; + } + }) || [], + })) || []; + } + + if (args.scenes) { + item.locked = args.scenes?.length ? { type: 'IN_SCENE', config: { sceneIds: args.scenes } } : undefined; + } + + return 'Item updated successfully'; + } + + async removeItem(itemId: string): Promise { + if (!this.novel.inventory) return 'Error: No items in the inventory'; + const initialLength = this.novel.inventory.length; + this.novel.inventory = this.novel.inventory.filter((itm) => itm.id !== itemId); + return initialLength !== this.novel.inventory.length ? 'Item removed successfully' : 'Error: Item not found'; + } + + // + // OBJECTIVES + // + async createObjective(args: { + sceneId: string; + name: string; + description?: string; + hint?: string; + condition?: string; + }): Promise { + const scene = this.novel.scenes.find((s) => s.id === args.sceneId); + if (!scene) return 'Error: Scene not found'; + const objectives = this.novel.objectives || []; + + const newId = getId('objective'); + objectives.push({ + id: newId, + name: args.name, + description: args.description || '', + hint: args.hint || '', + condition: args.condition || '', + actions: [], + singleUse: true, + stateCondition: { + type: 'IN_SCENE', + config: { + sceneIds: [args.sceneId], + }, + }, + }); + return `Objective created with ID: ${newId} in scene ${args.sceneId}`; + } + + async updateObjective(args: { + sceneId: string; + objectiveId: string; + name?: string; + description?: string; + hint?: string; + condition?: string; + }): Promise { + const objective = this.novel.objectives?.find((obj: any) => obj.id === args.objectiveId); + if (!objective) return 'Error: Objective not found'; + if (args.name !== undefined) objective.name = args.name; + if (args.description !== undefined) objective.description = args.description; + if (args.sceneId !== undefined) + objective.stateCondition = { + type: 'IN_SCENE', + config: { + sceneIds: [args.sceneId], + }, + }; + if (args.hint !== undefined) objective.hint = args.hint; + if (args.condition !== undefined) objective.condition = args.condition; + + return 'Objective updated successfully'; + } + + async removeObjective(objectiveId: string): Promise { + const objective = this.novel.objectives?.find((obj) => obj.id === objectiveId); + if (!objective) return 'Error: Objective not found'; + this.novel.objectives = this.novel.objectives?.filter((obj) => obj.id !== objectiveId); + return 'Objective removed successfully'; + } + + // + // MAPS + // + async createMap(args: { name: string; description?: string }): Promise { + if (!this.novel.maps) { + this.novel.maps = []; + } + const newId = getId('map'); + this.novel.maps.push({ + id: newId, + name: args.name, + description: args.description || '', + source: { png: '' }, + places: [], + }); + return `Map created with ID: ${newId}`; + } + + async updateMap(args: { mapId: string; name?: string; description?: string }): Promise { + const map = this.novel.maps?.find((m) => m.id === args.mapId); + if (!map) return 'Error: Map not found'; + + if (args.name !== undefined) map.name = args.name; + if (args.description !== undefined) map.description = args.description; + + return 'Map updated successfully'; + } + + async removeMap(mapId: string): Promise { + const initialLength = this.novel.maps?.length || 0; + this.novel.maps = this.novel.maps?.filter((m) => m.id !== mapId); + return initialLength !== (this.novel.maps?.length || 0) ? 'Map removed successfully' : 'Error: Map not found'; + } + + async createMapPlace(args: { mapId: string; name: string; sceneId?: string; description?: string }): Promise { + const map = this.novel.maps?.find((m) => m.id === args.mapId); + if (!map) return 'Error: Map not found'; + const newPlaceId = getId('place'); + + map.places.push({ + id: newPlaceId, + sceneId: args.sceneId || '', + name: args.name, + description: args.description || '', + previewSource: '', + maskSource: '', + }); + return `New place created with ID: ${newPlaceId} in map ${args.mapId}`; + } + + async updateMapPlace(args: { + mapId: string; + placeId: string; + name?: string; + sceneId?: string; + description?: string; + }): Promise { + const map = this.novel.maps?.find((m) => m.id === args.mapId); + if (!map) return 'Error: Map not found'; + const place = map.places.find((p) => p.id === args.placeId); + if (!place) return 'Error: Place not found'; + + if (args.name !== undefined) place.name = args.name; + if (args.sceneId !== undefined) place.sceneId = args.sceneId; + if (args.description !== undefined) place.description = args.description; + + return 'Map place updated successfully'; + } + + async removeMapPlace(mapId: string, placeId: string): Promise { + const map = this.novel.maps?.find((m) => m.id === mapId); + if (!map) return 'Error: Map not found'; + const initialLength = map.places.length; + map.places = map.places.filter((p) => p.id !== placeId); + return initialLength !== map.places.length ? 'Place removed successfully' : 'Error: Place not found'; + } + + async addIndicatorToScene(args: { + sceneId: string; + name: string; + description?: string; + type: 'percentage' | 'amount' | 'discrete'; + values?: string[]; + initialValue: string; + inferred?: boolean; + step?: number; + min?: number; + max?: number; + hidden?: boolean; + editable?: boolean; + color?: string; + }): Promise { + const scene = this.novel.scenes.find((s) => s.id === args.sceneId); + if (!scene) return 'Error: Scene not found'; + + if (!scene.indicators) { + scene.indicators = []; + } + const indicatorId = getId('indicator'); + + scene.indicators.push({ + id: indicatorId, + name: args.name, + description: args.description ?? '', + type: args.type, + values: args.values || [], + initialValue: args.initialValue, + inferred: args.inferred ?? false, + step: args.step, + min: args.min, + max: args.max, + hidden: args.hidden ?? false, + editable: args.editable ?? false, + color: args.color || '#4CAF50', + }); + return `Indicator created with ID: ${indicatorId}`; + } + + async updateIndicatorInScene(args: { + sceneId: string; + indicatorId: string; + name?: string; + description?: string; + type?: 'percentage' | 'amount' | 'discrete'; + values?: string[]; + initialValue?: string; + inferred?: boolean; + step?: number; + min?: number; + max?: number; + hidden?: boolean; + editable?: boolean; + color?: string; + }): Promise { + const scene = this.novel.scenes.find((s) => s.id === args.sceneId); + if (!scene) return 'Error: Scene not found'; + if (!scene.indicators) return 'Error: No indicators in this scene'; + + const indicator = scene.indicators.find((i) => i.id === args.indicatorId); + if (!indicator) return 'Error: Indicator not found'; + + if (args.name !== undefined) indicator.name = args.name; + if (args.description !== undefined) indicator.description = args.description; + if (args.type !== undefined) indicator.type = args.type; + if (args.values !== undefined) indicator.values = args.values; + if (args.initialValue !== undefined) indicator.initialValue = args.initialValue; + if (args.inferred !== undefined) indicator.inferred = args.inferred; + if (args.step !== undefined) indicator.step = args.step; + if (args.min !== undefined) indicator.min = args.min; + if (args.max !== undefined) indicator.max = args.max; + if (args.hidden !== undefined) indicator.hidden = args.hidden; + if (args.editable !== undefined) indicator.editable = args.editable; + if (args.color !== undefined) indicator.color = args.color; + + return 'Indicator updated successfully'; + } + + async removeIndicatorFromScene(sceneId: string, indicatorId: string): Promise { + const scene = this.novel.scenes.find((s) => s.id === sceneId); + if (!scene) return 'Error: Scene not found'; + if (!scene.indicators) return 'Error: No indicators in this scene'; + + const initialLength = scene.indicators.length; + scene.indicators = scene.indicators.filter((ind) => ind.id !== indicatorId); + return initialLength !== scene.indicators.length ? 'Indicator removed successfully' : 'Error: Indicator not found'; + } + + async getItems(): Promise { + if (!this.novel.inventory || !this.novel.inventory.length) { + return 'No items found'; + } + return JSON.stringify( + this.novel.inventory.map((item) => { + return { + id: item.id, + name: item.name, + description: item.description, + hidden: item.hidden, + actions: item.actions.map((action) => ({ + id: action.id, + name: action.name, + prompt: action.prompt, + usageActions: action.usageActions, + })), + scenes: item.actions.length ? item.actions[0].usageCondition?.config.sceneIds : [], + }; + }), + ); + } + + async getObjectivesInScene(sceneId: string): Promise { + const objectives = this.novel.objectives?.filter((obj) => obj.stateCondition.config.sceneIds.includes(sceneId)); + if (!objectives || !objectives.length) return 'Error: Empty objectives'; + return JSON.stringify( + objectives.map((objective) => { + return { + id: objective.id, + name: objective.name, + description: objective.description, + hint: objective.hint, + condition: objective.condition, + actions: objective.actions, + sceneId: objective.stateCondition?.config?.sceneIds[0], + }; + }), + ); + } + + async getIndicatorsInScene(sceneId: string): Promise { + const scene = this.novel.scenes.find((s) => s.id === sceneId); + if (!scene) return 'Error: Scene not found'; + if (!scene.indicators || !scene.indicators.length) return 'Error: Empty indicators'; + return JSON.stringify(scene.indicators); + } + + async getMaps(): Promise { + if (!this.novel.maps || !this.novel.maps.length) { + return 'Error: No maps found'; + } + return JSON.stringify( + this.novel.maps.map((map) => ({ + id: map.id, + name: map.name, + description: map.description, + places: map.places.map((place) => ({ id: place.id, name: place.name, description: place.description })), + sceneIdsWhereVisible: this.novel.scenes + .filter((scene) => scene.parentMapIds?.includes(map.id)) + .map((scene) => scene.id), + })), + ); + } + + async setMapScenes(mapId: string, sceneIds: string[]): Promise { + const map = this.novel.maps?.find((m) => m.id === mapId); + if (!map) return 'Error: Map not found'; + this.novel.scenes + .filter((scene) => sceneIds.includes(scene.id)) + .forEach((scene) => { + if (!scene.parentMapIds?.includes(mapId)) { + scene.parentMapIds = scene.parentMapIds || []; + scene.parentMapIds.push(mapId); + } + }); + return 'Map scenes updated successfully'; + } +} diff --git a/apps/novel-builder/src/config.ts b/apps/novel-builder/src/config.ts index 411ef72f..9f4a2fd8 100644 --- a/apps/novel-builder/src/config.ts +++ b/apps/novel-builder/src/config.ts @@ -8,6 +8,7 @@ import { uploadAsset, } from '@mikugg/bot-utils'; import { BackgroundResult, CharacterResult, listSearch, SearchType, SongResult } from './libs/listSearch'; +import axios from 'axios'; export const MAX_FILE_SIZE = 5 * 1024 * 1024; const VITE_ASSETS_UPLOAD_URL = import.meta.env.VITE_ASSETS_UPLOAD_URL || 'http://localhost:8585/asset-upload'; @@ -16,6 +17,8 @@ interface BuilderConfig { assetsEndpoint: string; assetsEndpointOptimized: string; uploadAssetEndpoint: string; + platformAPIEndpoint: string; + isPremiumUser: () => Promise; genAssetLink: (asset: string, displayPrefix: AssetDisplayPrefix) => string; uploadAsset: ( file: File | string, @@ -59,6 +62,16 @@ const configs: Map<'development' | 'staging' | 'production', BuilderConfig> = ne assetsEndpoint: 'http://localhost:8585/s3/assets', assetsEndpointOptimized: 'http://localhost:8585/s3/assets', uploadAssetEndpoint: 'http://localhost:8585/asset-upload', + platformAPIEndpoint: 'http://localhost:8080', + isPremiumUser: async (): Promise => { + try { + const result = await axios.get<{ id: string; tier: 'REGULAR' | 'PREMIUM' }>(`http://localhost:8080/user`); + return !!(result.data.id && result.data.tier === 'PREMIUM'); + } catch (error) { + console.error('Failed to check premium status:', error); + return false; + } + }, genAssetLink: (asset: string) => { if (asset.startsWith('data')) { return asset; @@ -158,6 +171,16 @@ const configs: Map<'development' | 'staging' | 'production', BuilderConfig> = ne assetsEndpoint: 'https://assets.miku.gg', assetsEndpointOptimized: 'https://mikugg-assets.nyc3.digitaloceanspaces.com', uploadAssetEndpoint: 'https://apidev.miku.gg/asset/upload', + platformAPIEndpoint: 'https://apidev.miku.gg', + isPremiumUser: async (): Promise => { + try { + const result = await axios.get<{ id: string; tier: 'REGULAR' | 'PREMIUM' }>(`https://apidev.miku.gg/user`); + return !!(result.data.id && result.data.tier === 'PREMIUM'); + } catch (error) { + console.error('Failed to check premium status:', error); + return false; + } + }, genAssetLink: (asset: string, displayPrefix?: AssetDisplayPrefix) => { if (asset.startsWith('data')) { return asset; @@ -264,6 +287,16 @@ const configs: Map<'development' | 'staging' | 'production', BuilderConfig> = ne assetsEndpoint: 'https://assets.miku.gg', assetsEndpointOptimized: 'https://mikugg-assets.nyc3.digitaloceanspaces.com', uploadAssetEndpoint: 'https://api.miku.gg/asset/upload', + platformAPIEndpoint: 'https://api.miku.gg', + isPremiumUser: async (): Promise => { + try { + const result = await axios.get<{ id: string; tier: 'REGULAR' | 'PREMIUM' }>(`https://api.miku.gg/user`); + return !!(result.data.id && result.data.tier === 'PREMIUM'); + } catch (error) { + console.error('Failed to check premium status:', error); + return false; + } + }, genAssetLink: (asset: string, displayPrefix?: AssetDisplayPrefix) => { if (asset.startsWith('data')) { return asset; diff --git a/apps/novel-builder/src/modals/character/CharacterDescriptionEdit.tsx b/apps/novel-builder/src/modals/character/CharacterDescriptionEdit.tsx index 7f61066c..b2f2562a 100644 --- a/apps/novel-builder/src/modals/character/CharacterDescriptionEdit.tsx +++ b/apps/novel-builder/src/modals/character/CharacterDescriptionEdit.tsx @@ -275,7 +275,7 @@ export default function CharacterDescriptionEdit({ characterId }: { characterId?
- +