1032 lines
		
	
	
		
			38 KiB
		
	
	
	
		
			Svelte
		
	
	
	
			
		
		
	
	
			1032 lines
		
	
	
		
			38 KiB
		
	
	
	
		
			Svelte
		
	
	
	
| <script lang="ts">
 | |
|   // This beast needs to be broken down into multiple components before it gets any worse.
 | |
|   import {
 | |
|     saveChatStore,
 | |
|     apiKeyStorage,
 | |
|     chatsStorage,
 | |
|     globalStorage,
 | |
|     addMessage,
 | |
|     insertMessages,
 | |
|     clearMessages,
 | |
|     copyChat,
 | |
|     getChatSettingValueNullDefault,
 | |
|     saveCustomProfile,
 | |
|     deleteCustomProfile,
 | |
|     setGlobalSettingValueByKey,
 | |
|     updateChatSettings,
 | |
|     resetChatSettings,
 | |
|     setChatSettingValue,
 | |
|     addChatFromJSON,
 | |
|     updateRunningTotal
 | |
|   } from './Storage.svelte'
 | |
|   import { getChatSettingObjectByKey, getChatSettingList, getRequestSettingList, getChatDefaults, defaultModel } from './Settings.svelte'
 | |
|   import {
 | |
|     type Request,
 | |
|     type Response,
 | |
|     type Message,
 | |
|     type ChatSetting,
 | |
|     type ResponseModels,
 | |
|     type SettingSelect,
 | |
|     type Chat,
 | |
|     type SelectOption,
 | |
|     supportedModels,
 | |
| 
 | |
|     type Usage
 | |
| 
 | |
|   } from './Types.svelte'
 | |
|   import Prompts from './Prompts.svelte'
 | |
|   import Messages from './Messages.svelte'
 | |
|   import { applyProfile, getProfile, getProfileSelect, prepareSummaryPrompt, getDefaultProfileKey } from './Profiles.svelte'
 | |
| 
 | |
|   import { afterUpdate, onMount } from 'svelte'
 | |
|   import { replace } from 'svelte-spa-router'
 | |
|   import Fa from 'svelte-fa/src/fa.svelte'
 | |
|   import {
 | |
|     faArrowUpFromBracket,
 | |
|     faPaperPlane,
 | |
|     faGear,
 | |
|     faPenToSquare,
 | |
|     faTrash,
 | |
|     faMicrophone,
 | |
|     faLightbulb,
 | |
|     faClone,
 | |
|     faEllipsisVertical,
 | |
|     faFloppyDisk,
 | |
|     faThumbtack,
 | |
|     faDownload,
 | |
|     faUpload,
 | |
|     faEraser,
 | |
|     faRotateRight
 | |
|   } from '@fortawesome/free-solid-svg-icons/index'
 | |
|   // import { encode } from 'gpt-tokenizer'
 | |
|   import { v4 as uuidv4 } from 'uuid'
 | |
|   import { exportChatAsJSON, exportProfileAsJSON } from './Export.svelte'
 | |
|   import { clickOutside } from 'svelte-use-click-outside'
 | |
|   import { countPromptTokens, getMaxModelPrompt, getPrice } from './Stats.svelte'
 | |
| 
 | |
|   // This makes it possible to override the OpenAI API base URL in the .env file
 | |
|   const apiBase = import.meta.env.VITE_API_BASE || 'https://api.openai.com'
 | |
| 
 | |
|   export let params = { chatId: '' }
 | |
|   const chatId: number = parseInt(params.chatId)
 | |
| 
 | |
|   let updating: boolean = false
 | |
|   let updatingMessage: string = ''
 | |
|   let input: HTMLTextAreaElement
 | |
|   // let settings: HTMLDivElement
 | |
|   let chatNameSettings: HTMLFormElement
 | |
|   let recognition: any = null
 | |
|   let recording = false
 | |
|   let chatFileInput
 | |
|   let profileFileInput
 | |
|   let showSettingsModal = 0
 | |
|   let showProfileMenu = false
 | |
|   let showChatMenu = false
 | |
| 
 | |
|   const settingsList = getChatSettingList()
 | |
|   const modelSetting = getChatSettingObjectByKey('model') as ChatSetting & SettingSelect
 | |
|   const chatDefaults = getChatDefaults()
 | |
| 
 | |
|   $: chat = $chatsStorage.find((chat) => chat.id === chatId) as Chat
 | |
|   $: chatSettings = chat.settings
 | |
|   $: globalStore = $globalStorage
 | |
| 
 | |
|   // Make sure chat object is ready to go
 | |
|   updateChatSettings(chatId)
 | |
| 
 | |
|   onMount(async () => {
 | |
|     // Focus the input on mount
 | |
|     focusInput()
 | |
| 
 | |
|     // Try to detect speech recognition support
 | |
|     if ('SpeechRecognition' in window) {
 | |
|       // @ts-ignore
 | |
|       recognition = new window.SpeechRecognition()
 | |
|     } else if ('webkitSpeechRecognition' in window) {
 | |
|       // @ts-ignore
 | |
|       recognition = new window.webkitSpeechRecognition() // eslint-disable-line new-cap
 | |
|     }
 | |
| 
 | |
|     if (recognition) {
 | |
|       recognition.interimResults = false
 | |
|       recognition.onstart = () => {
 | |
|         recording = true
 | |
|       }
 | |
|       recognition.onresult = (event) => {
 | |
|         // Stop speech recognition, submit the form and remove the pulse
 | |
|         const last = event.results.length - 1
 | |
|         const text = event.results[last][0].transcript
 | |
|         input.value = text
 | |
|         recognition.stop()
 | |
|         recording = false
 | |
|         submitForm(true)
 | |
|       }
 | |
|     } else {
 | |
|       console.log('Speech recognition not supported')
 | |
|     }
 | |
|     if (chat.startSession) {
 | |
|       const profile = getProfile('') // get default profile
 | |
|       applyProfile(chatId, profile.profile as any)
 | |
|       if (chat.startSession) {
 | |
|         chat.startSession = false
 | |
|         saveChatStore()
 | |
|         // Auto start the session
 | |
|         setTimeout(() => { submitForm(false, true) }, 0)
 | |
|       }
 | |
|     }
 | |
|   })
 | |
| 
 | |
|   // Scroll to the bottom of the chat on update
 | |
|   afterUpdate(() => {
 | |
|     sizeTextElements()
 | |
|     // Scroll to the bottom of the page after any updates to the messages array
 | |
|     // focusInput()
 | |
|   })
 | |
| 
 | |
|   // Scroll to the bottom of the chat on update
 | |
|   const focusInput = () => {
 | |
|     input.focus()
 | |
|     setTimeout(() => document.querySelector('.chat-focus-point')?.scrollIntoView({ behavior: 'smooth', block: 'end' }), 0)
 | |
|   }
 | |
| 
 | |
|   // Send API request
 | |
|   const sendRequest = async (messages: Message[], summaryTarget:number|undefined = undefined, withSummary:boolean = false): Promise<Response> => {
 | |
|     // Show updating bar
 | |
|     updating = true
 | |
| 
 | |
|     const model = chat.settings.model || defaultModel
 | |
|     const maxTokens = getMaxModelPrompt(model) // max tokens for model
 | |
| 
 | |
|     let response: Response
 | |
| 
 | |
|     // Submit only the role and content of the messages, provide the previous messages as well for context
 | |
|     const filtered = messages.filter((message) => message.role !== 'error' && message.content && !message.summarized)
 | |
|   
 | |
|     // Get an estimate of the total prompt size we're sending
 | |
|     const promptTokenCount:number = countPromptTokens(filtered, model)
 | |
|   
 | |
|     let summarySize = chatSettings.summarySize
 | |
| 
 | |
|     // console.log('Estimated',promptTokenCount,'prompt token for this request')
 | |
| 
 | |
|     if (chatSettings.useSummarization &&
 | |
|           !withSummary && !summaryTarget &&
 | |
|           promptTokenCount > chatSettings.summaryThreshold) {
 | |
|       // Too many tokens -- well need to sumarize some past ones else we'll run out of space
 | |
|       // Get a block of past prompts we'll summarize
 | |
|       let pinTop = chatSettings.pinTop
 | |
|       const tp = chatSettings.trainingPrompts
 | |
|       pinTop = Math.max(pinTop, tp ? 1 : 0)
 | |
|       let pinBottom = chatSettings.pinBottom
 | |
|       const systemPad = (filtered[0] || {} as Message).role === 'system' ? 1 : 0
 | |
|       const mlen = filtered.length - systemPad // always keep system prompt
 | |
|       let diff = mlen - (pinTop + pinBottom)
 | |
|       while (diff <= 3 && (pinTop > 0 || pinBottom > 1)) {
 | |
|         // Not enough prompts exposed to summarize
 | |
|         // try to open up pinTop and pinBottom to see if we can get more to summarize
 | |
|         if (pinTop === 1 && pinBottom > 1) {
 | |
|           // If we have a pin top, try to keep some of it as long as we can
 | |
|           pinBottom = Math.max(Math.floor(pinBottom / 2), 0)
 | |
|         } else {
 | |
|           pinBottom = Math.max(Math.floor(pinBottom / 2), 0)
 | |
|           pinTop = Math.max(Math.floor(pinTop / 2), 0)
 | |
|         }
 | |
|         diff = mlen - (pinTop + pinBottom)
 | |
|       }
 | |
|       if (diff > 0) {
 | |
|         // We've found at least one prompt we can try to summarize
 | |
|         // Reduce to prompts we'll send in for summary
 | |
|         // (we may need to update this to not include the pin-top, but the context it provides seems to help in the accuracy of the summary)
 | |
|         const summarize = filtered.slice(0, filtered.length - pinBottom)
 | |
|         // Estimate token count of what we'll be summarizing
 | |
|         let sourceTokenCount = countPromptTokens(summarize, model)
 | |
|         // build summary prompt message
 | |
|         let summaryPrompt = prepareSummaryPrompt(chatId, sourceTokenCount)
 | |
|         const summaryMessage = {
 | |
|           role: 'user',
 | |
|           content: summaryPrompt
 | |
|         } as Message
 | |
|         // get an estimate of how many tokens this request + max completions could be
 | |
|         let summaryPromptSize = countPromptTokens(summarize.concat(summaryMessage), model)
 | |
|         // reduce summary size to make sure we're not requesting a summary larger than our prompts
 | |
|         summarySize = Math.floor(Math.min(summarySize, sourceTokenCount / 4))
 | |
|         // Make sure our prompt + completion request isn't too large
 | |
|         while (summarize.length - (pinTop + systemPad) >= 3 && summaryPromptSize + summarySize > maxTokens && summarySize >= 4) {
 | |
|           summarize.pop()
 | |
|           sourceTokenCount = countPromptTokens(summarize, model)
 | |
|           summaryPromptSize = countPromptTokens(summarize.concat(summaryMessage), model)
 | |
|           summarySize = Math.floor(Math.min(summarySize, sourceTokenCount / 4))
 | |
|         }
 | |
|         // See if we have to adjust our max summarySize
 | |
|         if (summaryPromptSize + summarySize > maxTokens) {
 | |
|           summarySize = maxTokens - summaryPromptSize
 | |
|         }
 | |
|         // Always try to end the prompts being summarized with a user prompt.  Seems to work better.
 | |
|         while (summarize.length - (pinTop + systemPad) >= 4 && summarize[summarize.length - 1].role !== 'user') {
 | |
|           summarize.pop()
 | |
|         }
 | |
|         // update with actual
 | |
|         sourceTokenCount = countPromptTokens(summarize, model)
 | |
|         summaryPrompt = prepareSummaryPrompt(chatId, sourceTokenCount)
 | |
|         summarySize = Math.floor(Math.min(summarySize, sourceTokenCount / 4))
 | |
|         summaryMessage.content = summaryPrompt
 | |
|         if (sourceTokenCount > 20 && summaryPrompt && summarySize > 4) {
 | |
|           // get prompt we'll be inserting after
 | |
|           const endPrompt = summarize[summarize.length - 1]
 | |
|           // Add a prompt to ask to summarize them
 | |
|           const summarizeReq = summarize.slice()
 | |
|           summarizeReq.push(summaryMessage)
 | |
|           summaryPromptSize = countPromptTokens(summarizeReq, model)
 | |
| 
 | |
|           // Wait for the summary completion
 | |
|           updatingMessage = 'Building Summary...'
 | |
|           const summary = await sendRequest(summarizeReq, summarySize)
 | |
|           if (summary.error) {
 | |
|             // Failed to some API issue. let the original caller handle it.
 | |
|             return summary
 | |
|           } else {
 | |
|             // Get response
 | |
|             const summaryPromptContent: string = summary.choices.reduce((a, c) => {
 | |
|               if (a.length > c.message.content.length) return a
 | |
|               a = c.message.content
 | |
|               return a
 | |
|             }, '')
 | |
| 
 | |
|             // Get use stats for response
 | |
|             const summaryUse = summary.choices.reduce((a, c) => {
 | |
|               const u = c.message.usage as Usage
 | |
|               a.completion_tokens += u.completion_tokens
 | |
|               a.prompt_tokens += u.prompt_tokens
 | |
|               a.total_tokens += u.total_tokens
 | |
|               return a
 | |
|             }, {prompt_tokens: 0,completion_tokens: 0,total_tokens: 0} as Usage)
 | |
| 
 | |
|             // Looks like we got our summarized messages.
 | |
|             // get ids of messages we summarized
 | |
|             const summarizedIds = summarize.slice(pinTop + systemPad).map(m => m.uuid)
 | |
|             // Mark the new summaries as such
 | |
|             const summaryPrompt:Message = {
 | |
|               role: 'assistant',
 | |
|               content: summaryPromptContent,
 | |
|               uuid: uuidv4(),
 | |
|               summary: summarizedIds,
 | |
|               usage: summaryUse,
 | |
|               model: model,
 | |
|             }
 | |
|             const summaryIds = [summaryPrompt.uuid]
 | |
|             // Insert messages
 | |
|             insertMessages(chatId, endPrompt, [summaryPrompt])
 | |
|             // Disable the messages we summarized so they still show in history
 | |
|             summarize.forEach((m, i) => {
 | |
|               if (i - systemPad >= pinTop) {
 | |
|                 m.summarized = summaryIds
 | |
|               }
 | |
|             })
 | |
|             saveChatStore()
 | |
|             // Re-run request with summarized prompts
 | |
|             // return { error: { message: "End for now" } } as Response
 | |
|             updatingMessage = 'Continuing...'
 | |
|             return await sendRequest(chat.messages, undefined, true)
 | |
|           }
 | |
|         } else if (!summaryPrompt) {
 | |
|           addMessage(chatId, { role: 'error', content: 'Unable to summarize. No summary prompt defined.', uuid: uuidv4() })
 | |
|         } else if (sourceTokenCount <= 20) {
 | |
|           addMessage(chatId, { role: 'error', content: 'Unable to summarize. Not enough words in past content to summarize.', uuid: uuidv4() })
 | |
|         }
 | |
|       } else {
 | |
|         addMessage(chatId, { role: 'error', content: 'Unable to summarize. Not enough messages in past content to summarize.', uuid: uuidv4() })
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     try {
 | |
|       const request: Request = {
 | |
|         messages: filtered.map(m => { return { role: m.role, content: m.content } }) as Message[],
 | |
| 
 | |
|         // Provide the settings by mapping the settingsMap to key/value pairs
 | |
|         ...getRequestSettingList().reduce((acc, setting) => {
 | |
|           let value = getChatSettingValueNullDefault(chatId, setting)
 | |
|           if (typeof setting.apiTransform === 'function') {
 | |
|             value = setting.apiTransform(chatId, setting, value)
 | |
|           }
 | |
|           if (summaryTarget) {
 | |
|             // requesting summary. do overrides
 | |
|             if (setting.key === 'max_tokens') value = summaryTarget // only as large as we need for summary
 | |
|             if (setting.key === 'n') value = 1 // never more than one completion
 | |
|           }
 | |
|           if (value !== null) acc[setting.key] = value
 | |
|           return acc
 | |
|         }, {})
 | |
|       }
 | |
| 
 | |
|       // Not working yet: a way to get the response as a stream
 | |
|       /*
 | |
|       request.stream = true
 | |
|       await fetchEventSource(apiBase + '/v1/chat/completions', {
 | |
|         method: 'POST',
 | |
|         headers: {
 | |
|           Authorization:
 | |
|           `Bearer ${$apiKeyStorage}`,
 | |
|           'Content-Type': 'application/json'
 | |
|         },
 | |
|         body: JSON.stringify(request),
 | |
|         onmessage (ev) {
 | |
|           const data = JSON.parse(ev.data)
 | |
|           console.log(data)
 | |
|         },
 | |
|         onerror (err) {
 | |
|           throw err
 | |
|         }
 | |
|       })
 | |
|       */
 | |
| 
 | |
|       response = await (
 | |
|         await fetch(apiBase + '/v1/chat/completions', {
 | |
|           method: 'POST',
 | |
|           headers: {
 | |
|             Authorization: `Bearer ${$apiKeyStorage}`,
 | |
|             'Content-Type': 'application/json'
 | |
|           },
 | |
|           body: JSON.stringify(request)
 | |
|         })
 | |
|       ).json()
 | |
|     } catch (e) {
 | |
|       response = { error: { message: e.message } } as Response
 | |
|     }
 | |
| 
 | |
|     // Hide updating bar
 | |
|     updating = false
 | |
|     updatingMessage = ''
 | |
| 
 | |
|     if (!response.error) {
 | |
|       // Add response counts to usage totals
 | |
|       updateRunningTotal(chatId, response.usage, response.model)
 | |
|       // const completionTokenCount:number = response.choices.reduce((a, c) => {
 | |
|       //   // unlike the prompts, token count of the completion is just the completion.
 | |
|       //   a += encode(c.message.content).length
 | |
|       //   return a
 | |
|       // }, 0)
 | |
|       // console.log('estimated response token count', completionTokenCount)
 | |
|     }
 | |
| 
 | |
|     return response
 | |
|   }
 | |
| 
 | |
|   const addNewMessage = () => {
 | |
|     let inputMessage: Message
 | |
|     const lastMessage = chat.messages[chat.messages.length - 1]
 | |
|     const uuid = uuidv4()
 | |
|     if (chat.messages.length === 0) {
 | |
|       inputMessage = { role: 'system', content: input.value, uuid }
 | |
|     } else if (lastMessage && lastMessage.role === 'user') {
 | |
|       inputMessage = { role: 'assistant', content: input.value, uuid }
 | |
|     } else {
 | |
|       inputMessage = { role: 'user', content: input.value, uuid }
 | |
|     }
 | |
|     addMessage(chatId, inputMessage)
 | |
| 
 | |
|     // Clear the input value
 | |
|     input.value = ''
 | |
|     // input.blur()
 | |
|     focusInput()
 | |
|   }
 | |
| 
 | |
|   const submitForm = async (recorded: boolean = false, skipInput: boolean = false): Promise<void> => {
 | |
|     // Compose the system prompt message if there are no messages yet - disabled for now
 | |
|     if (updating) return
 | |
|   
 | |
|     if (!skipInput) {
 | |
|       chat.sessionStarted = true
 | |
|       saveChatStore()
 | |
|       if (input.value !== '') {
 | |
|         // Compose the input message
 | |
|         const inputMessage: Message = { role: 'user', content: input.value, uuid: uuidv4() }
 | |
|         addMessage(chatId, inputMessage)
 | |
|       }
 | |
| 
 | |
|       // Clear the input value
 | |
|       input.value = ''
 | |
|       input.blur()
 | |
|   
 | |
|       // Resize back to single line height
 | |
|       input.style.height = 'auto'
 | |
|     }
 | |
|     focusInput()
 | |
| 
 | |
|     const response = await sendRequest(chat.messages)
 | |
| 
 | |
|     if (response.error) {
 | |
|       addMessage(chatId, {
 | |
|         role: 'error',
 | |
|         content: `Error: ${response.error.message}`,
 | |
|         uuid: uuidv4()
 | |
|       })
 | |
|     } else {
 | |
|       response.choices.forEach((choice) => {
 | |
|         // Store usage and model in the message
 | |
|         choice.message.usage = response.usage
 | |
|         choice.message.model = response.model
 | |
|   
 | |
|         // Remove whitespace around the message that the OpenAI API sometimes returns
 | |
|         choice.message.content = choice.message.content.trim()
 | |
|         addMessage(chatId, choice.message)
 | |
|         // Use TTS to read the response, if query was recorded
 | |
|         if (recorded && 'SpeechSynthesisUtterance' in window) {
 | |
|           const utterance = new SpeechSynthesisUtterance(choice.message.content)
 | |
|           window.speechSynthesis.speak(utterance)
 | |
|         }
 | |
|       })
 | |
|     }
 | |
|     focusInput()
 | |
|   }
 | |
| 
 | |
|   const suggestName = async (): Promise<void> => {
 | |
|     const suggestMessage: Message = {
 | |
|       role: 'user',
 | |
|       content: "Can you give me a 5 word summary of this conversation's topic?",
 | |
|       uuid: uuidv4()
 | |
|     }
 | |
| 
 | |
|     const suggestMessages = chat.messages.slice(0, 10) // limit to first 10 messages
 | |
|     suggestMessages.push(suggestMessage)
 | |
| 
 | |
|     const response = await sendRequest(suggestMessages, 20)
 | |
| 
 | |
|     if (response.error) {
 | |
|       addMessage(chatId, {
 | |
|         role: 'error',
 | |
|         content: `Unable to get suggested name: ${response.error.message}`,
 | |
|         uuid: uuidv4()
 | |
|       })
 | |
|     } else {
 | |
|       response.choices.forEach((choice) => {
 | |
|         chat.name = choice.message.content
 | |
|       })
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   const deleteChat = () => {
 | |
|     if (window.confirm('Are you sure you want to delete this chat?')) {
 | |
|       replace('/').then(() => {
 | |
|         chatsStorage.update((chats) => chats.filter((chat) => chat.id !== chatId))
 | |
|       })
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   const showChatNameSettings = () => {
 | |
|     chatNameSettings.classList.add('is-active');
 | |
|     (chatNameSettings.querySelector('#settings-chat-name') as HTMLInputElement).focus();
 | |
|     (chatNameSettings.querySelector('#settings-chat-name') as HTMLInputElement).select()
 | |
|   }
 | |
| 
 | |
|   const saveChatNameSettings = () => {
 | |
|     const newChatName = (chatNameSettings.querySelector('#settings-chat-name') as HTMLInputElement).value
 | |
|     // save if changed
 | |
|     if (newChatName && newChatName !== chat.name) {
 | |
|       chat.name = newChatName
 | |
|       chatsStorage.set($chatsStorage)
 | |
|     }
 | |
|     closeChatNameSettings()
 | |
|   }
 | |
| 
 | |
|   const closeChatNameSettings = () => {
 | |
|     chatNameSettings.classList.remove('is-active')
 | |
|   }
 | |
| 
 | |
|   const updateProfileSelectOptions = () => {
 | |
|     const profileSelect = getChatSettingObjectByKey('profile') as ChatSetting & SettingSelect
 | |
|     profileSelect.options = getProfileSelect()
 | |
|     chatDefaults.profile = getDefaultProfileKey()
 | |
|     // const defaultProfile = globalStore.defaultProfile || profileSelect.options[0].value
 | |
|   }
 | |
| 
 | |
|   const refreshSettings = async () => {
 | |
|     showSettingsModal && showSettings()
 | |
|   }
 | |
| 
 | |
|   const showSettings = async () => {
 | |
|     // Show settings modal
 | |
|     showSettingsModal++
 | |
| 
 | |
|     // Get profile options
 | |
|     updateProfileSelectOptions()
 | |
| 
 | |
|     // Refresh settings modal
 | |
|     showSettingsModal++
 | |
|   
 | |
|     // Load available models from OpenAI
 | |
|     const allModels = (await (
 | |
|       await fetch(apiBase + '/v1/models', {
 | |
|         method: 'GET',
 | |
|         headers: {
 | |
|           Authorization: `Bearer ${$apiKeyStorage}`,
 | |
|           'Content-Type': 'application/json'
 | |
|         }
 | |
|       })
 | |
|     ).json()) as ResponseModels
 | |
|     const filteredModels = supportedModels.filter((model) => allModels.data.find((m) => m.id === model))
 | |
| 
 | |
|     const modelOptions:SelectOption[] = filteredModels.reduce((a, m) => {
 | |
|       const o:SelectOption = {
 | |
|         value: m,
 | |
|         text: m
 | |
|       }
 | |
|       a.push(o)
 | |
|       return a
 | |
|     }, [] as SelectOption[])
 | |
| 
 | |
|     // Update the models in the settings
 | |
|     if (modelSetting) {
 | |
|       modelSetting.options = modelOptions
 | |
|     }
 | |
|     // Refresh settings modal
 | |
|     showSettingsModal++
 | |
| 
 | |
|     setTimeout(() => sizeTextElements, 100)
 | |
|   }
 | |
| 
 | |
|   const sizeTextElements = () => {
 | |
|     const els = document.querySelectorAll('textarea.auto-size')
 | |
|     for (let i:number = 0, l = els.length; i < l; i++) autoGrowInput(els[i] as HTMLTextAreaElement)
 | |
|   }
 | |
| 
 | |
|   const closeSettings = () => {
 | |
|     showSettingsModal = 0
 | |
|     showProfileMenu = false
 | |
|     if (chat.startSession) {
 | |
|       chat.startSession = false
 | |
|       saveChatStore()
 | |
|       submitForm(false, true)
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   const clearSettings = () => {
 | |
|     resetChatSettings(chatId)
 | |
|     showSettingsModal++ // Make sure the dialog updates
 | |
|   }
 | |
| 
 | |
|   const recordToggle = () => {
 | |
|     // Check if already recording - if so, stop - else start
 | |
|     if (recording) {
 | |
|       recognition?.stop()
 | |
|       recording = false
 | |
|     } else {
 | |
|       recognition?.start()
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   const debounce = {}
 | |
| 
 | |
|   const queueSettingValueChange = (event: Event, setting: ChatSetting) => {
 | |
|     clearTimeout(debounce[setting.key])
 | |
|     if (event.target === null) return
 | |
|     const val = chatSettings[setting.key]
 | |
|     const el = (event.target as HTMLInputElement)
 | |
|     const doSet = () => {
 | |
|       try {
 | |
|         (typeof setting.beforeChange === 'function') && setting.beforeChange(chatId, setting, el.checked || el.value) &&
 | |
|           refreshSettings()
 | |
|       } catch (e) {
 | |
|         window.alert('Unable to change:\n' + e.message)
 | |
|       }
 | |
|       switch (setting.type) {
 | |
|         case 'boolean':
 | |
|           setChatSettingValue(chatId, setting, el.checked)
 | |
|           refreshSettings()
 | |
|           break
 | |
|         default:
 | |
|           setChatSettingValue(chatId, setting, el.value)
 | |
|       }
 | |
|       try {
 | |
|         (typeof setting.afterChange === 'function') && setting.afterChange(chatId, setting, chatSettings[setting.key]) &&
 | |
|           refreshSettings()
 | |
|       } catch (e) {
 | |
|         setChatSettingValue(chatId, setting, val)
 | |
|         window.alert('Unable to change:\n' + e.message)
 | |
|       }
 | |
|     }
 | |
|     if (setting.key === 'profile' && chat.sessionStarted &&
 | |
|       (getProfile(el.value).characterName !== chatSettings.characterName)) {
 | |
|       const val = chatSettings[setting.key]
 | |
|       if (window.confirm('Personality change will not correctly apply to existing chat session.\n Continue?')) {
 | |
|         doSet()
 | |
|       } else {
 | |
|         // roll-back
 | |
|         setChatSettingValue(chatId, setting, val)
 | |
|         // refresh setting modal, if open
 | |
|         showSettingsModal && showSettingsModal++
 | |
|       }
 | |
|     }
 | |
|     debounce[setting.key] = setTimeout(doSet, 250)
 | |
|   }
 | |
|   const autoGrowInputOnEvent = (event: Event) => {
 | |
|     // Resize the textarea to fit the content - auto is important to reset the height after deleting content
 | |
|     if (event.target === null) return
 | |
|     autoGrowInput(event.target as HTMLTextAreaElement)
 | |
|   }
 | |
|   
 | |
|   const autoGrowInput = (el: HTMLTextAreaElement) => {
 | |
|     el.style.height = '38px' // don't use "auto" here.  Firefox will over-size.
 | |
|     el.style.height = el.scrollHeight + 'px'
 | |
|   }
 | |
| 
 | |
|   const saveProfile = () => {
 | |
|     showProfileMenu = false
 | |
|     try {
 | |
|       saveCustomProfile(chat.settings)
 | |
|       refreshSettings()
 | |
|     } catch (e) {
 | |
|       window.alert('Error saving profile: \n' + e.message)
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   const newNameForProfile = (name:string):string => {
 | |
|     const profiles = getProfileSelect()
 | |
|     const nameMap = profiles.reduce((a, p) => { a[p.text] = p; return a }, {})
 | |
|     if (!nameMap[name]) return name
 | |
|     let i:number = 1
 | |
|     let cname = name + `-${i}`
 | |
|     while (nameMap[cname]) {
 | |
|       i++
 | |
|       cname = name + `-${i}`
 | |
|     }
 | |
|     return cname
 | |
|   }
 | |
| 
 | |
|   const cloneProfile = () => {
 | |
|     showProfileMenu = false
 | |
|     const clone = JSON.parse(JSON.stringify(chat.settings))
 | |
|     const name = chat.settings.profileName
 | |
|     clone.profileName = newNameForProfile(name || '')
 | |
|     clone.profile = null
 | |
|     try {
 | |
|       saveCustomProfile(clone)
 | |
|       chat.settings.profile = clone.profile
 | |
|       chat.settings.profileName = clone.profileName
 | |
|       refreshSettings()
 | |
|     } catch (e) {
 | |
|       window.alert('Error cloning profile: \n' + e.message)
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   const deleteProfile = () => {
 | |
|     showProfileMenu = false
 | |
|     try {
 | |
|       deleteCustomProfile(chatId, chat.settings.profile as any)
 | |
|       chat.settings.profile = globalStore.defaultProfile || ''
 | |
|       saveChatStore()
 | |
|       setGlobalSettingValueByKey('lastProfile', chat.settings.profile)
 | |
|       applyProfile(chatId, chat.settings.profile as any)
 | |
|       refreshSettings()
 | |
|     } catch (e) {
 | |
|       window.alert('Error deleting profile: \n' + e.message)
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   const pinDefaultProfile = () => {
 | |
|     showProfileMenu = false
 | |
|     setGlobalSettingValueByKey('defaultProfile', chat.settings.profile)
 | |
|     refreshSettings()
 | |
|   }
 | |
| 
 | |
|   const importProfileFromFile = (e) => {
 | |
|     const image = e.target.files[0]
 | |
|     const reader = new FileReader()
 | |
|     reader.readAsText(image)
 | |
|     reader.onload = e => {
 | |
|       const json = (e.target || {}).result as string
 | |
|       try {
 | |
|         const profile = JSON.parse(json)
 | |
|         profile.profileName = newNameForProfile(profile.profileName || '')
 | |
|         profile.profile = null
 | |
|         saveCustomProfile(profile)
 | |
|         refreshSettings()
 | |
|       } catch (e) {
 | |
|         window.alert('Unable to import profile: \n' + e.message)
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   const importChatFromFile = (e) => {
 | |
|     const image = e.target.files[0]
 | |
|     const reader = new FileReader()
 | |
|     reader.readAsText(image)
 | |
|     reader.onload = e => {
 | |
|       const json = (e.target || {}).result as string
 | |
|       addChatFromJSON(json)
 | |
|     }
 | |
|   }
 | |
| </script>
 | |
| 
 | |
| <nav class="level chat-header">
 | |
|   <div class="level-left">
 | |
|     <div class="level-item">
 | |
|       <p class="subtitle is-5">
 | |
|         <span>{chat.name || `Chat ${chat.id}`}</span>
 | |
|         <a href={'#'} class="greyscale ml-2 is-hidden has-text-weight-bold editbutton" title="Rename chat" on:click|preventDefault={showChatNameSettings}><Fa icon={faPenToSquare} /></a>
 | |
|         <a href={'#'} class="greyscale ml-2 is-hidden has-text-weight-bold editbutton" title="Suggest a chat name" on:click|preventDefault={suggestName}><Fa icon={faLightbulb} /></a>
 | |
|         <!-- <a href={'#'} class="greyscale ml-2 is-hidden has-text-weight-bold editbutton" title="Copy this chat" on:click|preventDefault={() => { copyChat(chatId) }}><Fa icon={faClone} /></a> -->
 | |
|         <!-- <a href={'#'} class="greyscale ml-2 is-hidden has-text-weight-bold editbutton" title="Delete this chat" on:click|preventDefault={deleteChat}><Fa icon={faTrash} /></a> -->
 | |
|       </p>
 | |
|     </div>
 | |
|   </div>
 | |
| 
 | |
|   <div class="level-right">
 | |
|     <div class="level-item">
 | |
|       
 | |
|       <div class="dropdown is-right" class:is-active={showChatMenu} use:clickOutside={() => { showChatMenu = false }}>
 | |
|         <div class="dropdown-trigger">
 | |
|           <button class="button" aria-haspopup="true" 
 | |
|             aria-controls="dropdown-menu3" 
 | |
|             on:click|preventDefault|stopPropagation={() => { showChatMenu = !showChatMenu }}
 | |
|             >
 | |
|             <span><Fa icon={faEllipsisVertical}/></span>
 | |
|           </button>
 | |
|         </div>
 | |
|         <div class="dropdown-menu" id="dropdown-menu3" role="menu">
 | |
|           <div class="dropdown-content">
 | |
|             <a href={'#'} class="dropdown-item" on:click|preventDefault={() => { showChatMenu = false; showSettings() }}>
 | |
|               <span><Fa icon={faGear}/></span> Settings
 | |
|             </a>
 | |
|             <hr class="dropdown-divider">
 | |
|             <a href={'#'} class="dropdown-item" on:click|preventDefault={() => { showChatMenu = false; copyChat(chatId) }}>
 | |
|               <span><Fa icon={faClone}/></span> Clone Chat
 | |
|             </a>
 | |
|             <hr class="dropdown-divider">
 | |
|             <a href={'#'} 
 | |
|               class="dropdown-item"
 | |
|               on:click|preventDefault={() => { showChatMenu = false; exportChatAsJSON(chatId) }}
 | |
|             >
 | |
|               <span><Fa icon={faDownload}/></span> Save Chat
 | |
|             </a>
 | |
|             <a href={'#'} class="dropdown-item" on:click|preventDefault={() => { showChatMenu = false; chatFileInput.click() }}>
 | |
|               <span><Fa icon={faUpload}/></span> Load Chat
 | |
|             </a>
 | |
|             <hr class="dropdown-divider">
 | |
|             <a href={'#'} class="dropdown-item" on:click|preventDefault={() => { applyProfile(chatId, '', true); closeSettings() }}>
 | |
|               <span><Fa icon={faRotateRight}/></span> Restart Chat
 | |
|             </a>
 | |
|             <a href={'#'} class="dropdown-item" on:click|preventDefault={() => { showChatMenu = false; clearMessages(chatId) }}>
 | |
|               <span><Fa icon={faEraser}/></span> Clear Chat Messages
 | |
|             </a>
 | |
|             <hr class="dropdown-divider">
 | |
|             <a href={'#'} class="dropdown-item" on:click|preventDefault={() => { showChatMenu = false; deleteChat() }}>
 | |
|               <span><Fa icon={faTrash}/></span> Delete Chat
 | |
|             </a>
 | |
|           </div>
 | |
|         </div>
 | |
|       </div>
 | |
|       <!-- <button class="button is-warning" on:click={() => { clearMessages(chatId); window.location.reload() }}><span class="greyscale mr-2"><Fa icon={faTrash} /></span> Clear messages</button> -->
 | |
|     </div>
 | |
|   </div>
 | |
| </nav>
 | |
| 
 | |
| <Messages messages={chat.messages} chatId={chatId} />
 | |
| 
 | |
| {#if updating}
 | |
|   <article class="message is-success assistant-message">
 | |
|     <div class="message-body content">
 | |
|       <span class="is-loading" ></span>
 | |
|       <span>{updatingMessage}</span>
 | |
|     </div>
 | |
|   </article>
 | |
| {/if}
 | |
| 
 | |
| {#if chat.messages.length === 0 || (chat.messages.length === 1 && chat.messages[0].role === 'system')}
 | |
|   <Prompts bind:input />
 | |
| {/if}
 | |
| 
 | |
| <form class="field has-addons has-addons-right is-align-items-flex-end" on:submit|preventDefault={() => submitForm()}>
 | |
|   <p class="control is-expanded">
 | |
|     <textarea
 | |
|       class="input is-info is-focused chat-input auto-size"
 | |
|       placeholder="Type your message here..."
 | |
|       rows="1"
 | |
|       on:keydown={e => {
 | |
|         // Only send if Enter is pressed, not Shift+Enter
 | |
|         if (e.key === 'Enter' && !e.shiftKey) {
 | |
|           submitForm()
 | |
|           e.preventDefault()
 | |
|         }
 | |
|       }}
 | |
|       on:input={e => autoGrowInputOnEvent(e)}
 | |
|       bind:this={input}
 | |
|     />
 | |
|   </p>
 | |
|   <p class="control" class:is-hidden={!recognition}>
 | |
|     <button class="button" class:is-pulse={recording} on:click|preventDefault={recordToggle}
 | |
|       ><span class="greyscale"><Fa icon={faMicrophone} /></span></button
 | |
|     >
 | |
|   </p>
 | |
|   <p class="control">
 | |
|     <button title="Chat/Profile Settings" class="button" on:click|preventDefault={showSettings}><Fa icon={faGear} /></button>
 | |
|   </p>
 | |
|   <p class="control">
 | |
|     <button title="Add message, don't send yet" class="button is-ghost" on:click|preventDefault={addNewMessage}><Fa icon={faArrowUpFromBracket} /></button>
 | |
|   </p>
 | |
|   <p class="control">
 | |
|     <button title="Send" class="button is-info" type="submit"><Fa icon={faPaperPlane} /></button>
 | |
|   </p>
 | |
| </form>
 | |
| <!-- a target to scroll to -->
 | |
| <div class="chat-focus-point running-total-container">
 | |
|   {#each Object.entries(chat.usage || {}) as [model, usage]}
 | |
|   <p class="is-size-7 running-totals">
 | |
|     <em>{model}</em> total <span class="has-text-weight-bold">{usage.total_tokens}</span>
 | |
|     tokens ~= <span class="has-text-weight-bold">${getPrice(usage, model).toFixed(6)}</span>
 | |
|   </p>
 | |
|   {/each}
 | |
| </div>
 | |
| 
 | |
| <svelte:window
 | |
|   on:keydown={(event) => {
 | |
|     if (event.key === 'Escape') {
 | |
|       closeSettings()
 | |
|       closeChatNameSettings()
 | |
|       showChatMenu = false
 | |
|     }
 | |
|   }}
 | |
| />
 | |
| 
 | |
| <!-- svelte-ignore a11y-click-events-have-key-events -->
 | |
| <div class="modal" class:is-active={showSettingsModal}>
 | |
|   <div class="modal-background" on:click={closeSettings} />
 | |
|   <div class="modal-card" on:click={() => { showProfileMenu = false }}>
 | |
|     <header class="modal-card-head">
 | |
|       <p class="modal-card-title">Chat Settings</p>
 | |
| 
 | |
|       <div class="dropdown is-right" class:is-active={showProfileMenu}>
 | |
|         <div class="dropdown-trigger">
 | |
|           <button class="button" aria-haspopup="true" aria-controls="dropdown-menu3" on:click|preventDefault|stopPropagation={() => { showProfileMenu = !showProfileMenu }}>
 | |
|             <span><Fa icon={faEllipsisVertical}/></span>
 | |
|           </button>
 | |
|         </div>
 | |
|         <div class="dropdown-menu" id="dropdown-menu3" role="menu">
 | |
|           <div class="dropdown-content">
 | |
|             <a href={'#'} class="dropdown-item disabled" on:click|preventDefault={saveProfile}>
 | |
|               <span><Fa icon={faFloppyDisk}/></span> Save Profile
 | |
|             </a>
 | |
|             <a href={'#'} class="dropdown-item" on:click|preventDefault={cloneProfile}>
 | |
|               <span><Fa icon={faClone}/></span> Clone Profile
 | |
|             </a>
 | |
|             <hr class="dropdown-divider">
 | |
|             <a href={'#'} 
 | |
|               class="dropdown-item"
 | |
|               on:click|preventDefault={() => { showProfileMenu = false; exportProfileAsJSON(chatId) }}
 | |
|             >
 | |
|               <span><Fa icon={faDownload}/></span> Export Profile
 | |
|             </a>
 | |
|             <a href={'#'} class="dropdown-item" on:click|preventDefault={() => { showProfileMenu = false; profileFileInput.click() }}>
 | |
|               <span><Fa icon={faUpload}/></span> Import Profile
 | |
|             </a>
 | |
|             <hr class="dropdown-divider">
 | |
|             <a href={'#'} class="dropdown-item" on:click|preventDefault={pinDefaultProfile}>
 | |
|               <span><Fa icon={faThumbtack}/></span> Set as Default Profile
 | |
|             </a>
 | |
|             <hr class="dropdown-divider">
 | |
|             <a href={'#'} class="dropdown-item" on:click|preventDefault={deleteProfile}>
 | |
|               <span><Fa icon={faTrash}/></span> Delete Profile
 | |
|             </a>
 | |
|           </div>
 | |
|         </div>
 | |
|       </div>
 | |
|     </header>
 | |
|     <section class="modal-card-body">
 | |
|       <!-- Below are the settings that OpenAI allows to be changed for the API calls. See the <a href="https://platform.openai.com/docs/api-reference/chat/create">OpenAI API docs</a> for more details.</p> -->
 | |
|       {#key showSettingsModal}
 | |
|       {#each settingsList as setting}
 | |
|         {#if (typeof setting.hide !== 'function') || !setting.hide(chatId)}
 | |
|         {#if setting.header}
 | |
|         <p class="notification {setting.headerClass}">
 | |
|           {@html setting.header}
 | |
|         </p>
 | |
|         {/if}
 | |
|         <div class="field is-horizontal">
 | |
|           {#if setting.type === 'boolean'}
 | |
|           <div class="field is-normal">
 | |
|             <label class="label" for="settings-{setting.key}" title="{setting.title}">
 | |
|               <input 
 | |
|               type="checkbox"
 | |
|               title="{setting.title}"
 | |
|               class="checkbox" 
 | |
|               id="settings-{setting.key}"
 | |
|               checked={!!chatSettings[setting.key]} 
 | |
|               on:click={e => queueSettingValueChange(e, setting)}
 | |
|             >
 | |
|               {setting.name}
 | |
|             </label>
 | |
|           </div>
 | |
|           {:else if setting.type === 'textarea'}
 | |
|           <div class="field is-normal" style="width:100%">
 | |
|             <label class="label" for="settings-{setting.key}" title="{setting.title}">{setting.name}</label>
 | |
|             <textarea
 | |
|               class="input is-info is-focused chat-input auto-size"
 | |
|               placeholder={setting.placeholder || ''}
 | |
|               rows="1"
 | |
|               on:input={e => autoGrowInputOnEvent(e)}
 | |
|               on:change={e => { queueSettingValueChange(e, setting); autoGrowInputOnEvent(e) }}
 | |
|             >{chatSettings[setting.key]}</textarea>
 | |
|           </div>
 | |
|           {:else}
 | |
|           <div class="field-label is-normal">
 | |
|             <label class="label" for="settings-{setting.key}" title="{setting.title}">{setting.name}</label>
 | |
|           </div>
 | |
|           {/if}
 | |
|           <div class="field-body">
 | |
|             <div class="field">
 | |
|               {#if setting.type === 'number'}
 | |
|                 <input
 | |
|                   class="input"
 | |
|                   inputmode="decimal"
 | |
|                   type={setting.type}
 | |
|                   title="{setting.title}"
 | |
|                   id="settings-{setting.key}"
 | |
|                   value={chatSettings[setting.key]}
 | |
|                   min={setting.min}
 | |
|                   max={setting.max}
 | |
|                   step={setting.step}
 | |
|                   placeholder={String(setting.placeholder)}
 | |
|                   on:change={e => queueSettingValueChange(e, setting)}
 | |
|                 />
 | |
|               {:else if setting.type === 'select'}
 | |
|                 <div class="select">
 | |
|                   <select id="settings-{setting.key}" title="{setting.title}" on:change={e => queueSettingValueChange(e, setting) } >
 | |
|                     {#each setting.options as option}
 | |
|                       <option class:is-default={option.value === chatDefaults[setting.key]} value={option.value} selected={option.value === chatSettings[setting.key]}>{option.text}</option>
 | |
|                     {/each}
 | |
|                   </select>
 | |
|                 </div>
 | |
|               {:else if setting.type === 'text'}
 | |
|                 <div class="field">
 | |
|                     <input 
 | |
|                     type="text"
 | |
|                     title="{setting.title}"
 | |
|                     class="input" 
 | |
|                     value={chatSettings[setting.key]} 
 | |
|                     on:change={e => { queueSettingValueChange(e, setting) }}
 | |
|                   >
 | |
|                 </div>
 | |
|               {/if}
 | |
|             </div>
 | |
|           </div>
 | |
|         </div>
 | |
|       {/if}
 | |
|       {/each}
 | |
|       {/key}
 | |
|     </section>
 | |
| 
 | |
|     <footer class="modal-card-foot">
 | |
|       <button class="button is-info" on:click={closeSettings}>Close settings</button>
 | |
|       <button class="button" on:click={clearSettings}>Clear settings</button>
 | |
|     </footer>
 | |
|   </div>
 | |
| </div>
 | |
| 
 | |
| 
 | |
| <input style="display:none" type="file" accept=".json" on:change={(e) => importChatFromFile(e)} bind:this={chatFileInput} >
 | |
| <input style="display:none" type="file" accept=".json" on:change={(e) => importProfileFromFile(e)} bind:this={profileFileInput} >
 | |
| 
 | |
| <!-- rename modal -->
 | |
| <form class="modal" bind:this={chatNameSettings} on:submit={saveChatNameSettings}>
 | |
|   <!-- svelte-ignore a11y-click-events-have-key-events -->
 | |
|   <div class="modal-background" on:click={closeChatNameSettings} />
 | |
|   <div class="modal-card">
 | |
|     <header class="modal-card-head">
 | |
|       <p class="modal-card-title">Enter a new name for this chat</p>
 | |
|     </header>
 | |
|     <section class="modal-card-body">
 | |
|       <div class="field is-horizontal">
 | |
|         <div class="field-label is-normal">
 | |
|           <label class="label" for="settings-chat-name">New name:</label>
 | |
|         </div>
 | |
|         <div class="field-body">
 | |
|           <div class="field">
 | |
|             <input
 | |
|               class="input"
 | |
|               type="text"
 | |
|               id="settings-chat-name"
 | |
|               value={chat.name}
 | |
|             />
 | |
|           </div>
 | |
|         </div>
 | |
|       </div>
 | |
|     </section>
 | |
|     <footer class="modal-card-foot">
 | |
|       <input type="submit" class="button is-info" value="Save" />
 | |
|       <button class="button" on:click={closeChatNameSettings}>Cancel</button>
 | |
|     </footer>
 | |
|   </div>
 | |
| </form>
 | |
| <!-- end -->
 | |
| 
 | |
| <style>
 | |
|   .running-total-container {
 | |
|     min-height:2em;
 | |
|     padding-bottom:.6em;
 | |
|     /* padding-left: 1.9em; */
 | |
|     margin-bottom:-2.6em
 | |
|   }
 | |
|   .running-totals {
 | |
|     opacity: 0.5;
 | |
|   }
 | |
| </style>
 |