619 lines
19 KiB
Svelte
619 lines
19 KiB
Svelte
<script lang="ts">
|
|
// import { fetchEventSource } from '@microsoft/fetch-event-source'
|
|
|
|
import { apiKeyStorage, chatsStorage, addMessage, clearMessages } from './Storage.svelte'
|
|
import {
|
|
type Request,
|
|
type Response,
|
|
type Message,
|
|
type Settings,
|
|
type Model,
|
|
type ResponseModels,
|
|
type SettingsSelect,
|
|
type Chat,
|
|
type Usage,
|
|
supportedModels
|
|
} from './Types.svelte'
|
|
import Code from './Code.svelte'
|
|
|
|
import { afterUpdate, onMount } from 'svelte'
|
|
import { replace } from 'svelte-spa-router'
|
|
import SvelteMarkdown from 'svelte-markdown'
|
|
|
|
// 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 input: HTMLTextAreaElement
|
|
let settings: HTMLDivElement
|
|
let chatNameSettings: HTMLFormElement
|
|
let recognition: any = null
|
|
let recording = false
|
|
|
|
const modelSetting: Settings & SettingsSelect = {
|
|
key: 'model',
|
|
name: 'Model',
|
|
default: 'gpt-3.5-turbo',
|
|
title: 'The model to use - GPT-3.5 is cheaper, but GPT-4 is more powerful.',
|
|
options: supportedModels,
|
|
type: 'select'
|
|
}
|
|
|
|
let settingsMap: Settings[] = [
|
|
modelSetting,
|
|
{
|
|
key: 'temperature',
|
|
name: 'Sampling Temperature',
|
|
default: 1,
|
|
title: 'What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.\n' +
|
|
'\n' +
|
|
'We generally recommend altering this or top_p but not both.',
|
|
min: 0,
|
|
max: 2,
|
|
step: 0.1,
|
|
type: 'number'
|
|
},
|
|
{
|
|
key: 'top_p',
|
|
name: 'Nucleus Sampling',
|
|
default: 1,
|
|
title: 'An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.\n' +
|
|
'\n' +
|
|
'We generally recommend altering this or temperature but not both',
|
|
min: 0,
|
|
max: 1,
|
|
step: 0.1,
|
|
type: 'number'
|
|
},
|
|
{
|
|
key: 'n',
|
|
name: 'Number of Messages',
|
|
default: 1,
|
|
title: 'How many chat completion choices to generate for each input message.',
|
|
min: 1,
|
|
max: 10,
|
|
step: 1,
|
|
type: 'number'
|
|
},
|
|
{
|
|
key: 'max_tokens',
|
|
name: 'Max Tokens',
|
|
title: 'The maximum number of tokens to generate in the completion.\n' +
|
|
'\n' +
|
|
'The token count of your prompt plus max_tokens cannot exceed the model\'s context length. Most models have a context length of 2048 tokens (except for the newest models, which support 4096).\n',
|
|
default: 0,
|
|
min: 0,
|
|
max: 32768,
|
|
step: 1024,
|
|
type: 'number'
|
|
},
|
|
{
|
|
key: 'presence_penalty',
|
|
name: 'Presence Penalty',
|
|
default: 0,
|
|
title: 'Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model\'s likelihood to talk about new topics.',
|
|
min: -2,
|
|
max: 2,
|
|
step: 0.2,
|
|
type: 'number'
|
|
},
|
|
{
|
|
key: 'frequency_penalty',
|
|
name: 'Frequency Penalty',
|
|
default: 0,
|
|
title: 'Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model\'s likelihood to repeat the same line verbatim.',
|
|
min: -2,
|
|
max: 2,
|
|
step: 0.2,
|
|
type: 'number'
|
|
}
|
|
]
|
|
|
|
// Reference: https://openai.com/pricing#language-models
|
|
const tokenPrice : Record<string, [number, number]> = {
|
|
'gpt-4-32k': [0.00006, 0.00012], // $0.06 per 1000 tokens prompt, $0.12 per 1000 tokens completion
|
|
'gpt-4': [0.00003, 0.00006], // $0.03 per 1000 tokens prompt, $0.06 per 1000 tokens completion
|
|
'gpt-3.5': [0.000002, 0.000002] // $0.002 per 1000 tokens (both prompt and completion)
|
|
}
|
|
|
|
$: chat = $chatsStorage.find((chat) => chat.id === chatId) as Chat
|
|
|
|
onMount(async () => {
|
|
// Pre-select the last used model
|
|
if (chat.messages.length > 0) {
|
|
modelSetting.default = chat.messages[chat.messages.length - 1].model || modelSetting.default
|
|
settingsMap = settingsMap
|
|
}
|
|
|
|
// Focus the input on mount
|
|
input.focus()
|
|
|
|
// 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')
|
|
}
|
|
})
|
|
|
|
// Scroll to the bottom of the chat on update
|
|
afterUpdate(() => {
|
|
// Scroll to the bottom of the page after any updates to the messages array
|
|
document.querySelector('#content')?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
|
input.focus()
|
|
})
|
|
|
|
// Marked options
|
|
const markedownOptions = {
|
|
gfm: true, // Use GitHub Flavored Markdown
|
|
breaks: true, // Enable line breaks in markdown
|
|
mangle: false // Do not mangle email addresses
|
|
}
|
|
|
|
// Send API request
|
|
const sendRequest = async (messages: Message[]): Promise<Response> => {
|
|
// Show updating bar
|
|
updating = true
|
|
|
|
let response: Response
|
|
try {
|
|
const request: Request = {
|
|
// Submit only the role and content of the messages, provide the previous messages as well for context
|
|
messages: messages
|
|
.map((message): Message => {
|
|
const { role, content } = message
|
|
return { role, content }
|
|
})
|
|
// Skip error messages
|
|
.filter((message) => message.role !== 'error'),
|
|
|
|
// Provide the settings by mapping the settingsMap to key/value pairs
|
|
...settingsMap.reduce((acc, setting) => {
|
|
const value = (settings.querySelector(`#settings-${setting.key}`) as HTMLInputElement).value
|
|
if (value) {
|
|
acc[setting.key] = setting.type === 'number' ? parseFloat(value) : 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
|
|
|
|
return response
|
|
}
|
|
|
|
const getPrice = (tokens: Usage, model: Model) : number => {
|
|
for (const [key, [promptPrice, completionPrice]] of Object.entries(tokenPrice)) {
|
|
if (model.startsWith(key)) {
|
|
return ((tokens.prompt_tokens * promptPrice) + (tokens.completion_tokens * completionPrice))
|
|
}
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
const submitForm = async (recorded: boolean = false): Promise<void> => {
|
|
// Compose the input message
|
|
const inputMessage: Message = { role: 'user', content: input.value }
|
|
addMessage(chatId, inputMessage)
|
|
|
|
// Clear the input value
|
|
input.value = ''
|
|
input.blur()
|
|
|
|
// Resize back to single line height
|
|
input.style.height = 'auto'
|
|
|
|
const response = await sendRequest(chat.messages)
|
|
|
|
if (response.error) {
|
|
addMessage(chatId, {
|
|
role: 'error',
|
|
content: `Error: ${response.error.message}`
|
|
})
|
|
} 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
const suggestName = async (): Promise<void> => {
|
|
const suggestMessage: Message = {
|
|
role: 'user',
|
|
content: "Can you give me a 5 word summary of this conversation's topic?"
|
|
}
|
|
addMessage(chatId, suggestMessage)
|
|
|
|
const response = await sendRequest(chat.messages)
|
|
|
|
if (response.error) {
|
|
addMessage(chatId, {
|
|
role: 'error',
|
|
content: `Error: ${response.error.message}`
|
|
})
|
|
} else {
|
|
response.choices.forEach((choice) => {
|
|
choice.message.usage = response.usage
|
|
addMessage(chatId, choice.message)
|
|
chat.name = choice.message.content
|
|
chatsStorage.set($chatsStorage)
|
|
})
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
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 showSettings = async () => {
|
|
settings.classList.add('is-active')
|
|
|
|
// 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))
|
|
|
|
// Update the models in the settings
|
|
modelSetting.options = filteredModels
|
|
settingsMap = settingsMap
|
|
}
|
|
|
|
const closeSettings = () => {
|
|
settings.classList.remove('is-active')
|
|
}
|
|
|
|
const clearSettings = () => {
|
|
settingsMap.forEach((setting) => {
|
|
const input = settings.querySelector(`#settings-${setting.key}`) as HTMLInputElement
|
|
input.value = ''
|
|
})
|
|
}
|
|
|
|
const recordToggle = () => {
|
|
// Check if already recording - if so, stop - else start
|
|
if (recording) {
|
|
recognition?.stop()
|
|
recording = false
|
|
} else {
|
|
recognition?.start()
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<nav class="level chat-header">
|
|
<div class="level-left">
|
|
<div class="level-item">
|
|
<p class="subtitle is-5">
|
|
{chat.name || `Chat ${chat.id}`}
|
|
<a
|
|
href={'#'}
|
|
class="greyscale ml-2 is-hidden has-text-weight-bold editbutton"
|
|
title="Rename chat"
|
|
on:click|preventDefault={() => {
|
|
showChatNameSettings()
|
|
}}
|
|
>
|
|
✏️
|
|
</a>
|
|
<a
|
|
href={'#'}
|
|
class="greyscale ml-2 is-hidden has-text-weight-bold editbutton"
|
|
title="Suggest a chat name"
|
|
on:click|preventDefault={suggestName}
|
|
>
|
|
💡
|
|
</a>
|
|
<a
|
|
href={'#'}
|
|
class="greyscale ml-2 is-hidden editbutton"
|
|
title="Delete this chat"
|
|
on:click|preventDefault={deleteChat}
|
|
>
|
|
🗑️
|
|
</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="level-right">
|
|
<p class="level-item">
|
|
<button
|
|
class="button is-warning"
|
|
on:click={() => {
|
|
clearMessages(chatId)
|
|
}}><span class="greyscale mr-2">🗑️</span> Clear messages</button
|
|
>
|
|
</p>
|
|
</div>
|
|
</nav>
|
|
|
|
{#each chat.messages as message}
|
|
{#if message.role === 'user'}
|
|
<article
|
|
class="message is-info user-message"
|
|
class:has-text-right={message.content.split('\n').filter((line) => line.trim()).length === 1}
|
|
>
|
|
<div class="message-body content">
|
|
<a
|
|
href={'#'}
|
|
class="greyscale is-pulled-right ml-2 is-hidden editbutton"
|
|
on:click={() => {
|
|
input.value = message.content
|
|
input.focus()
|
|
}}
|
|
>
|
|
✏️
|
|
</a>
|
|
<SvelteMarkdown
|
|
source={message.content}
|
|
options={markedownOptions}
|
|
renderers={{
|
|
code: Code
|
|
}}
|
|
/>
|
|
</div>
|
|
</article>
|
|
{:else if message.role === 'system' || message.role === 'error'}
|
|
<article class="message is-danger assistant-message">
|
|
<div class="message-body content">
|
|
<SvelteMarkdown
|
|
source={message.content}
|
|
options={markedownOptions}
|
|
renderers={{
|
|
code: Code
|
|
}}
|
|
/>
|
|
</div>
|
|
</article>
|
|
{:else}
|
|
<article class="message is-success assistant-message">
|
|
<div class="message-body content">
|
|
<SvelteMarkdown
|
|
source={message.content}
|
|
options={markedownOptions}
|
|
renderers={{
|
|
code: Code
|
|
}}
|
|
/>
|
|
{#if message.usage}
|
|
<p class="is-size-7">
|
|
This message was generated on <em>{message.model || modelSetting.default}</em> using <span class="has-text-weight-bold">{message.usage.total_tokens}</span>
|
|
tokens ~=
|
|
<span class="has-text-weight-bold">${getPrice(message.usage, message.model || modelSetting.default).toFixed(6)}</span>
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
</article>
|
|
{/if}
|
|
{/each}
|
|
|
|
{#if updating}
|
|
<progress class="progress is-small is-dark" max="100" />
|
|
{/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"
|
|
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) => {
|
|
// Resize the textarea to fit the content - auto is important to reset the height after deleting content
|
|
input.style.height = 'auto'
|
|
input.style.height = input.scrollHeight + 'px'
|
|
}}
|
|
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">🎤</span></button
|
|
>
|
|
</p>
|
|
<p class="control">
|
|
<button class="button" on:click|preventDefault={showSettings}><span class="greyscale">⚙️</span></button>
|
|
</p>
|
|
<p class="control">
|
|
<button class="button is-info" type="submit">Send</button>
|
|
</p>
|
|
</form>
|
|
|
|
<svelte:window
|
|
on:keydown={(event) => {
|
|
if (event.key === 'Escape') {
|
|
closeSettings()
|
|
closeChatNameSettings()
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
<div class="modal" bind:this={settings}>
|
|
<div class="modal-background" on:click={closeSettings} />
|
|
<div class="modal-card">
|
|
<header class="modal-card-head">
|
|
<p class="modal-card-title">Settings</p>
|
|
</header>
|
|
<section class="modal-card-body">
|
|
{#each settingsMap as setting}
|
|
<div class="field is-horizontal">
|
|
<div class="field-label is-normal">
|
|
<label class="label" for="settings-{setting.key}">{setting.name}</label>
|
|
</div>
|
|
<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}"
|
|
min={setting.min}
|
|
max={setting.max}
|
|
step={setting.step}
|
|
placeholder={String(setting.default)}
|
|
/>
|
|
{:else if setting.type === 'select'}
|
|
<div class="select">
|
|
<select id="settings-{setting.key}" title="{setting.title}">
|
|
{#each setting.options as option}
|
|
<option value={option} selected={option === setting.default}>{option}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</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>
|
|
|
|
<!-- 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 -->
|