chatgpt-web/src/lib/Chat.svelte

654 lines
24 KiB
Svelte

<script lang="ts">
// This beast needs to be broken down into multiple components before it gets any worse.
import {
saveChatStore,
apiKeyStorage,
chatsStorage,
addMessage,
insertMessages,
getChatSettingValueNullDefault,
updateChatSettings,
checkStateChange,
showSetChatSettings,
submitExitingPromptsNow,
deleteMessage
} from './Storage.svelte'
import { getRequestSettingList, defaultModel } from './Settings.svelte'
import {
type Request,
type Message,
type Chat,
type ChatCompletionOpts,
type Usage
} from './Types.svelte'
import Prompts from './Prompts.svelte'
import Messages from './Messages.svelte'
import { prepareSummaryPrompt, restartProfile } from './Profiles.svelte'
import { afterUpdate, onMount, onDestroy } from 'svelte'
import Fa from 'svelte-fa/src/fa.svelte'
import {
faArrowUpFromBracket,
faPaperPlane,
faGear,
faPenToSquare,
faMicrophone,
faLightbulb,
faCommentSlash
} from '@fortawesome/free-solid-svg-icons/index'
// import { encode } from 'gpt-tokenizer'
import { v4 as uuidv4 } from 'uuid'
import { countPromptTokens, getModelMaxTokens, getPrice } from './Stats.svelte'
import { autoGrowInputOnEvent, scrollToMessage, sizeTextElements } from './Util.svelte'
import ChatSettingsModal from './ChatSettingsModal.svelte'
import Footer from './Footer.svelte'
import { openModal } from 'svelte-modals'
import PromptInput from './PromptInput.svelte'
import { ChatCompletionResponse } from './ChatCompletionResponse.svelte'
import { fetchEventSource } from '@microsoft/fetch-event-source'
// 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)
const controller = new AbortController()
let updating: boolean|number = false
let updatingMessage: string = ''
let input: HTMLTextAreaElement
let recognition: any = null
let recording = false
$: chat = $chatsStorage.find((chat) => chat.id === chatId) as Chat
$: chatSettings = chat.settings
let showSettingsModal
let scDelay
const onStateChange = (...args:any) => {
clearTimeout(scDelay)
setTimeout(() => {
if (chat.startSession) {
restartProfile(chatId)
if (chat.startSession) {
chat.startSession = false
saveChatStore()
// Auto start the session
submitForm(false, true)
}
}
if ($showSetChatSettings) {
$showSetChatSettings = false
showSettingsModal()
}
if ($submitExitingPromptsNow) {
$submitExitingPromptsNow = false
submitForm(false, true)
}
})
}
$: onStateChange($checkStateChange, $showSetChatSettings, $submitExitingPromptsNow)
// Make sure chat object is ready to go
updateChatSettings(chatId)
onDestroy(async () => {
// clean up
// abort any pending requests.
controller.abort()
})
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) {
restartProfile(chatId)
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()
scrollToBottom()
}
const scrollToBottom = (instant:boolean = false) => {
setTimeout(() => document.querySelector('body')?.scrollIntoView({ behavior: (instant ? 'instant' : 'smooth') as any, block: 'end' }), 0)
}
// Send API request
const sendRequest = async (messages: Message[], opts:ChatCompletionOpts): Promise<ChatCompletionResponse> => {
// Show updating bar
opts.chat = chat
const chatResponse = new ChatCompletionResponse(opts)
updating = true
const model = chat.settings.model || defaultModel
const maxTokens = getModelMaxTokens(model) // max tokens for model
const messageFilter = (m:Message) => !m.suppress && m.role !== 'error' && m.content && !m.summarized
// Submit only the role and content of the messages, provide the previous messages as well for context
let filtered = messages.filter(messageFilter)
// 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 && !opts.didSummary &&
!opts.summaryRequest && !opts.maxTokens &&
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)
const useRollMode = !prepareSummaryPrompt(chatId, 0)
if (!useRollMode) {
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 (!useRollMode && 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)
const summaryResponse:Message = {
role: 'assistant',
content: '',
uuid: uuidv4(),
streaming: opts.streaming,
summary: []
}
summaryResponse.usage = {
prompt_tokens: 0
} as Usage
summaryResponse.model = model
// Insert summary prompt
insertMessages(chatId, endPrompt, [summaryResponse])
if (opts.streaming) setTimeout(() => scrollToMessage(summaryResponse.uuid, 150, true, true), 0)
// Wait for the summary completion
updatingMessage = 'Summarizing...'
const summary = await sendRequest(summarizeReq, {
summaryRequest: true,
streaming: opts.streaming,
maxTokens: summarySize,
fillMessage: summaryResponse,
autoAddMessages: true,
onMessageChange: (m) => {
if (opts.streaming) scrollToMessage(summaryResponse.uuid, 150, true, true)
}
} as ChatCompletionOpts)
if (!summary.hasFinished()) await summary.promiseToFinish()
if (summary.hasError()) {
// Failed to some API issue. let the original caller handle it.
deleteMessage(chatId, summaryResponse.uuid)
return summary
} else {
// 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
summaryResponse.summary = summarizedIds
const summaryIds = [summaryResponse.uuid]
// 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...'
opts.didSummary = true
return await sendRequest(chat.messages, opts)
}
} 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 if (!useRollMode && diff < 1) {
addMessage(chatId, { role: 'error', content: 'Unable to summarize. Not enough messages in past content to summarize.', uuid: uuidv4() })
} else {
// roll-off mode
const top = filtered.slice(0, pinTop + systemPad)
const rollaway = filtered.slice(pinTop + systemPad)
let promptTokenCount = countPromptTokens(top.concat(rollaway), model)
// suppress messages we're rolling off
while (rollaway.length > (((promptTokenCount + (chatSettings.max_tokens || 1)) > maxTokens) ? pinBottom || 1 : 1) &&
promptTokenCount >= chatSettings.summaryThreshold) {
const rollOff = rollaway.shift()
if (rollOff) rollOff.suppress = true
promptTokenCount = countPromptTokens(top.concat(rollaway), model)
}
saveChatStore()
// get a new list now excluding them
filtered = messages.filter(messageFilter)
}
}
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 (opts.summaryRequest && opts.maxTokens) {
// requesting summary. do overrides
if (setting.key === 'max_tokens') value = opts.maxTokens // only as large as we need for summary
if (setting.key === 'n') value = 1 // never more than one completion for summary
}
if (opts.streaming) {
/*
Streaming goes insane with more than one completion.
Doesn't seem like there's any way to separate the jumbled mess of deltas for the
different completions.
*/
if (setting.key === 'n') value = 1
}
if (value !== null) acc[setting.key] = value
return acc
}, {})
}
request.stream = opts.streaming
chatResponse.setPromptTokenCount(promptTokenCount)
const signal = controller.signal
const fetchOptions = {
method: 'POST',
headers: {
Authorization: `Bearer ${$apiKeyStorage}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(request),
signal
}
if (opts.streaming) {
const abortListener = (e:Event) => {
chatResponse.updateFromError('User aborted request.')
}
// fetchEventSource doesn't seem to throw on abort, so...
signal.addEventListener('abort', abortListener)
chatResponse.onFinish(() => {
updating = false
updatingMessage = ''
signal.removeEventListener('abort', abortListener)
scrollToBottom()
})
fetchEventSource(apiBase + '/v1/chat/completions', {
...fetchOptions,
onmessage (ev) {
// Remove updating indicator
updating = 1 // hide indicator, but still signal we're updating
updatingMessage = ''
// console.log('ev.data', ev.data)
if (!chatResponse.hasFinished()) {
if (ev.data === '[DONE]') {
// ?? anything to do when "[DONE]"?
} else {
const data = JSON.parse(ev.data)
// console.log('data', data)
window.requestAnimationFrame(() => { chatResponse.updateFromAsyncResponse(data) })
}
}
},
onclose () {
chatResponse.updateFromClose()
},
onerror (err) {
console.error(err)
throw err
}
}).catch(err => {
chatResponse.updateFromError(err.message)
scrollToBottom()
})
} else {
const response = await fetch(apiBase + '/v1/chat/completions', fetchOptions)
const json = await response.json()
// Remove updating indicator
updating = false
updatingMessage = ''
chatResponse.updateFromSyncResponse(json)
scrollToBottom()
}
} catch (e) {
updating = false
updatingMessage = ''
chatResponse.updateFromError(e.message)
scrollToBottom()
}
return chatResponse
}
const addNewMessage = () => {
if (updating) return
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 tts = (text:string, recorded:boolean) => {
// Use TTS to read the response, if query was recorded
if (recorded && 'SpeechSynthesisUtterance' in window) {
const utterance = new SpeechSynthesisUtterance(text)
window.speechSynthesis.speak(utterance)
}
}
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, {
chat,
autoAddMessages: true, // Auto-add and update messages in array
streaming: chatSettings.stream,
onMessageChange: (messages) => {
scrollToBottom(true)
}
})
await response.promiseToFinish()
const message = response.getMessages()[0]
if (message) {
tts(message.content, recorded)
}
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, {
chat,
autoAddMessages: false,
streaming: false
})
await response.promiseToFinish()
if (response.hasError()) {
addMessage(chatId, {
role: 'error',
content: `Unable to get suggested name: ${response.getError()}`,
uuid: uuidv4()
})
} else {
response.getMessages().forEach(m => {
chat.name = m.content
})
}
}
function promptRename () {
openModal(PromptInput, {
title: 'Enter Name for Chat',
label: 'Name',
value: chat.name,
class: 'is-info',
onSubmit: (value) => {
chat.name = (value || '').trim() || chat.name
saveChatStore()
$checkStateChange++
}
})
}
const recordToggle = () => {
if (updating) return
// Check if already recording - if so, stop - else start
if (recording) {
recognition?.stop()
recording = false
} else {
recognition?.start()
}
}
</script>
<ChatSettingsModal chatId={chatId} bind:show={showSettingsModal} />
<div class="chat-content">
<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={promptRename}><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">
<!-- <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 === true}
<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}
</div>
<Footer class="prompt-input-container" strongMask={true}>
<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) {
e.stopPropagation()
submitForm()
e.preventDefault()
}
}}
on:input={e => autoGrowInputOnEvent(e)}
bind:this={input}
/>
</p>
<p class="control mic" class:is-hidden={!recognition}>
<button class="button" class:is-disabled={updating} class:is-pulse={recording} on:click|preventDefault={recordToggle}
><span class="icon"><Fa icon={faMicrophone} /></span></button
>
</p>
<p class="control settings">
<button title="Chat/Profile Settings" class="button" on:click|preventDefault={showSettingsModal}><span class="icon"><Fa icon={faGear} /></span></button>
</p>
<p class="control queue">
<button title="Queue message, don't send yet" class:is-disabled={updating} class="button is-ghost" on:click|preventDefault={addNewMessage}><span class="icon"><Fa icon={faArrowUpFromBracket} /></span></button>
</p>
{#if updating}
<p class="control send">
<button title="Cancel Response" class="button is-danger" type="button" on:click={() => { controller.abort() }}><span class="icon"><Fa icon={faCommentSlash} /></span></button>
</p>
{:else}
<p class="control send">
<button title="Send" class="button is-info" type="submit"><span class="icon"><Fa icon={faPaperPlane} /></span></button>
</p>
{/if}
</form>
<!-- a target to scroll to -->
<div class="content has-text-centered 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>
</Footer>