chatgpt-web/src/lib/Chat.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>