This commit is contained in:
Niek van der Maas 2023-03-20 15:35:07 +01:00
parent 271a88e3a0
commit 5fb12c1f41
7 changed files with 332 additions and 332 deletions

View File

@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import Router, { location } from "svelte-spa-router"; import Router, { location } from 'svelte-spa-router'
import routes from "./routes"; import routes from './routes'
import Navbar from "./lib/Navbar.svelte"; import Navbar from './lib/Navbar.svelte'
import Sidebar from "./lib/Sidebar.svelte"; import Sidebar from './lib/Sidebar.svelte'
import Footer from "./lib/Footer.svelte"; import Footer from './lib/Footer.svelte'
import { apiKeyStorage, chatsStorage } from "./lib/Storage.svelte"; import { apiKeyStorage, chatsStorage } from './lib/Storage.svelte'
$: sortedChats = $chatsStorage.sort((a, b) => b.id - a.id); $: sortedChats = $chatsStorage.sort((a, b) => b.id - a.id)
$: apiKey = $apiKeyStorage; $: apiKey = $apiKeyStorage
// Check if the API key is passed in as a "key" query parameter - if so, save it // Check if the API key is passed in as a "key" query parameter - if so, save it
const urlParams: URLSearchParams = new URLSearchParams(window.location.search); const urlParams: URLSearchParams = new URLSearchParams(window.location.search)
if (urlParams.has("key")) { if (urlParams.has('key')) {
apiKeyStorage.set(urlParams.get("key") as string); apiKeyStorage.set(urlParams.get('key') as string)
} }
</script> </script>

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
//import { fetchEventSource } from "@microsoft/fetch-event-source"; // import { fetchEventSource } from "@microsoft/fetch-event-source";
import { apiKeyStorage, chatsStorage, addMessage, clearMessages } from "./Storage.svelte"; import { apiKeyStorage, chatsStorage, addMessage, clearMessages } from './Storage.svelte'
import { import {
type Request, type Request,
type Response, type Response,
@ -9,135 +9,135 @@
type Settings, type Settings,
supportedModels, supportedModels,
type ResponseModels, type ResponseModels,
type SettingsSelect, type SettingsSelect
} from "./Types.svelte"; } from './Types.svelte'
import Code from "./Code.svelte"; import Code from './Code.svelte'
import { afterUpdate, onMount } from "svelte"; import { afterUpdate, onMount } from 'svelte'
import { replace } from "svelte-spa-router"; import { replace } from 'svelte-spa-router'
import SvelteMarkdown from "svelte-markdown"; import SvelteMarkdown from 'svelte-markdown'
export let params = { chatId: undefined }; export let params = { chatId: undefined }
let chatId: number = parseInt(params.chatId); const chatId: number = parseInt(params.chatId)
let updating: boolean = false; let updating: boolean = false
let input: HTMLTextAreaElement; let input: HTMLTextAreaElement
let settings: HTMLDivElement; let settings: HTMLDivElement
let recognition: any = null; let recognition: any = null
let recording = false; let recording = false
const settingsMap: Settings[] = [ const settingsMap: Settings[] = [
{ {
key: "model", key: 'model',
name: "Model", name: 'Model',
default: "gpt-3.5-turbo", default: 'gpt-3.5-turbo',
options: supportedModels, options: supportedModels,
type: "select", type: 'select'
}, },
{ {
key: "temperature", key: 'temperature',
name: "Sampling Temperature", name: 'Sampling Temperature',
default: 1, default: 1,
min: 0, min: 0,
max: 2, max: 2,
step: 0.1, step: 0.1,
type: "number", type: 'number'
}, },
{ {
key: "top_p", key: 'top_p',
name: "Nucleus Sampling", name: 'Nucleus Sampling',
default: 1, default: 1,
min: 0, min: 0,
max: 1, max: 1,
step: 0.1, step: 0.1,
type: "number", type: 'number'
}, },
{ {
key: "n", key: 'n',
name: "Number of Messages", name: 'Number of Messages',
default: 1, default: 1,
min: 1, min: 1,
max: 10, max: 10,
step: 1, step: 1,
type: "number", type: 'number'
}, },
{ {
key: "max_tokens", key: 'max_tokens',
name: "Max Tokens", name: 'Max Tokens',
default: 0, default: 0,
min: 0, min: 0,
max: 32768, max: 32768,
step: 1024, step: 1024,
type: "number", type: 'number'
}, },
{ {
key: "presence_penalty", key: 'presence_penalty',
name: "Presence Penalty", name: 'Presence Penalty',
default: 0, default: 0,
min: -2, min: -2,
max: 2, max: 2,
step: 0.2, step: 0.2,
type: "number", type: 'number'
}, },
{ {
key: "frequency_penalty", key: 'frequency_penalty',
name: "Frequency Penalty", name: 'Frequency Penalty',
default: 0, default: 0,
min: -2, min: -2,
max: 2, max: 2,
step: 0.2, step: 0.2,
type: "number", type: 'number'
}, }
]; ]
$: chat = $chatsStorage.find((chat) => chat.id === chatId); $: chat = $chatsStorage.find((chat) => chat.id === chatId)
const token_price = 0.000002; // $0.002 per 1000 tokens const tokenPrice = 0.000002 // $0.002 per 1000 tokens
// Focus the input on mount // Focus the input on mount
onMount(async () => { onMount(async () => {
input.focus(); input.focus()
// Try to detect speech recognition support // Try to detect speech recognition support
if ("SpeechRecognition" in window) { if ('SpeechRecognition' in window) {
// @ts-ignore // @ts-ignore
recognition = new SpeechRecognition(); recognition = new window.SpeechRecognition()
} else if ("webkitSpeechRecognition" in window) { } else if ('webkitSpeechRecognition' in window) {
// @ts-ignore // @ts-ignore
recognition = new webkitSpeechRecognition(); recognition = new window.webkitSpeechRecognition() // eslint-disable-line new-cap
} }
if (recognition) { if (recognition) {
recognition.interimResults = false; recognition.interimResults = false
recognition.onstart = () => { recognition.onstart = () => {
recording = true; recording = true
}; }
recognition.onresult = (event) => { recognition.onresult = (event) => {
// Stop speech recognition, submit the form and remove the pulse // Stop speech recognition, submit the form and remove the pulse
const last = event.results.length - 1; const last = event.results.length - 1
const text = event.results[last][0].transcript; const text = event.results[last][0].transcript
input.value = text; input.value = text
recognition.stop(); recognition.stop()
recording = false; recording = false
submitForm(true); submitForm(true)
}; }
} else { } else {
console.log("Speech recognition not supported"); console.log('Speech recognition not supported')
} }
}); })
// Scroll to the bottom of the chat on update // Scroll to the bottom of the chat on update
afterUpdate(() => { afterUpdate(() => {
// Scroll to the bottom of the page after any updates to the messages array // Scroll to the bottom of the page after any updates to the messages array
window.scrollTo(0, document.body.scrollHeight); window.scrollTo(0, document.body.scrollHeight)
input.focus(); input.focus()
}); })
// Marked options // Marked options
const markedownOptions = { const markedownOptions = {
gfm: true, // Use GitHub Flavored Markdown gfm: true, // Use GitHub Flavored Markdown
breaks: true, // Enable line breaks in markdown breaks: true, // Enable line breaks in markdown
mangle: false, // Do not mangle email addresses mangle: false // Do not mangle email addresses
}; }
const sendRequest = async (messages: Message[]): Promise<Response> => { const sendRequest = async (messages: Message[]): Promise<Response> => {
// Send API request // Send API request
@ -160,154 +160,154 @@
}); });
*/ */
// Show updating bar // Show updating bar
updating = true; updating = true
let response: Response; let response: Response
try { try {
const request: Request = { const request: Request = {
// Submit only the role and content of the messages, provide the previous messages as well for context // Submit only the role and content of the messages, provide the previous messages as well for context
messages: messages messages: messages
.map((message): Message => { .map((message): Message => {
const { role, content } = message; const { role, content } = message
return { role, content }; return { role, content }
}) })
// Skip error messages // Skip error messages
.filter((message) => message.role !== "error"), .filter((message) => message.role !== 'error'),
// Provide the settings by mapping the settingsMap to key/value pairs // Provide the settings by mapping the settingsMap to key/value pairs
...settingsMap.reduce((acc, setting) => { ...settingsMap.reduce((acc, setting) => {
const value = (settings.querySelector(`#settings-${setting.key}`) as HTMLInputElement).value; const value = (settings.querySelector(`#settings-${setting.key}`) as HTMLInputElement).value
if (value) { if (value) {
acc[setting.key] = setting.type === "number" ? parseFloat(value) : value; acc[setting.key] = setting.type === 'number' ? parseFloat(value) : value
} }
return acc; return acc
}, {}), }, {})
}; }
response = await ( response = await (
await fetch("https://api.openai.com/v1/chat/completions", { await fetch('https://api.openai.com/v1/chat/completions', {
method: "POST", method: 'POST',
headers: { headers: {
Authorization: `Bearer ${$apiKeyStorage}`, Authorization: `Bearer ${$apiKeyStorage}`,
"Content-Type": "application/json", 'Content-Type': 'application/json'
}, },
body: JSON.stringify(request), body: JSON.stringify(request)
}) })
).json(); ).json()
} catch (e) { } catch (e) {
response = { error: { message: e.message } } as Response; response = { error: { message: e.message } } as Response
} }
// Hide updating bar // Hide updating bar
updating = false; updating = false
return response; return response
}; }
const submitForm = async (recorded: boolean = false): Promise<void> => { const submitForm = async (recorded: boolean = false): Promise<void> => {
// Compose the input message // Compose the input message
const inputMessage: Message = { role: "user", content: input.value }; const inputMessage: Message = { role: 'user', content: input.value }
addMessage(chatId, inputMessage); addMessage(chatId, inputMessage)
// Clear the input value // Clear the input value
input.value = ""; input.value = ''
input.blur(); input.blur()
// Resize back to single line height // Resize back to single line height
input.style.height = "auto"; input.style.height = 'auto'
const response = await sendRequest(chat.messages); const response = await sendRequest(chat.messages)
if (response.error) { if (response.error) {
addMessage(chatId, { addMessage(chatId, {
role: "error", role: 'error',
content: `Error: ${response.error.message}`, content: `Error: ${response.error.message}`
}); })
} else { } else {
response.choices.map((choice) => { response.choices.forEach((choice) => {
choice.message.usage = response.usage; choice.message.usage = response.usage
// Remove whitespace around the message that the OpenAI API sometimes returns // Remove whitespace around the message that the OpenAI API sometimes returns
choice.message.content = choice.message.content.trim(); choice.message.content = choice.message.content.trim()
addMessage(chatId, choice.message); addMessage(chatId, choice.message)
// Use TTS to read the response, if query was recorded // Use TTS to read the response, if query was recorded
if (recorded && "SpeechSynthesisUtterance" in window) { if (recorded && 'SpeechSynthesisUtterance' in window) {
const utterance = new SpeechSynthesisUtterance(choice.message.content); const utterance = new SpeechSynthesisUtterance(choice.message.content)
speechSynthesis.speak(utterance); window.speechSynthesis.speak(utterance)
} }
}); })
} }
}; }
const suggestName = async (): Promise<void> => { const suggestName = async (): Promise<void> => {
const suggestMessage: Message = { const suggestMessage: Message = {
role: "user", role: 'user',
content: "Can you give me a 5 word summary of this conversation's topic?", content: "Can you give me a 5 word summary of this conversation's topic?"
}; }
addMessage(chatId, suggestMessage); addMessage(chatId, suggestMessage)
const response = await sendRequest(chat.messages); const response = await sendRequest(chat.messages)
if (response.error) { if (response.error) {
addMessage(chatId, { addMessage(chatId, {
role: "error", role: 'error',
content: `Error: ${response.error.message}`, content: `Error: ${response.error.message}`
}); })
} else { } else {
response.choices.map((choice) => { response.choices.forEach((choice) => {
choice.message.usage = response.usage; choice.message.usage = response.usage
addMessage(chatId, choice.message); addMessage(chatId, choice.message)
chat.name = choice.message.content; chat.name = choice.message.content
chatsStorage.set($chatsStorage); chatsStorage.set($chatsStorage)
}); })
} }
}; }
const deleteChat = () => { const deleteChat = () => {
if (confirm("Are you sure you want to delete this chat?")) { if (window.confirm('Are you sure you want to delete this chat?')) {
replace("/").then(() => { replace('/').then(() => {
chatsStorage.update((chats) => chats.filter((chat) => chat.id !== chatId)); chatsStorage.update((chats) => chats.filter((chat) => chat.id !== chatId))
}); })
} }
}; }
const showSettings = async () => { const showSettings = async () => {
settings.classList.add("is-active"); settings.classList.add('is-active')
// Load available models from OpenAI // Load available models from OpenAI
const allModels = (await ( const allModels = (await (
await fetch("https://api.openai.com/v1/models", { await fetch('https://api.openai.com/v1/models', {
method: "GET", method: 'GET',
headers: { headers: {
Authorization: `Bearer ${$apiKeyStorage}`, Authorization: `Bearer ${$apiKeyStorage}`,
"Content-Type": "application/json", 'Content-Type': 'application/json'
}, }
}) })
).json()) as ResponseModels; ).json()) as ResponseModels
const filteredModels = supportedModels.filter((model) => allModels.data.find((m) => m.id === model)); const filteredModels = supportedModels.filter((model) => allModels.data.find((m) => m.id === model));
// Update the models in the settings // Update the models in the settings
(settingsMap[0] as SettingsSelect).options = filteredModels; (settingsMap[0] as SettingsSelect).options = filteredModels
}; }
const closeSettings = () => { const closeSettings = () => {
settings.classList.remove("is-active"); settings.classList.remove('is-active')
}; }
const clearSettings = () => { const clearSettings = () => {
settingsMap.forEach((setting) => { settingsMap.forEach((setting) => {
const input = settings.querySelector(`#settings-${setting.key}`) as HTMLInputElement; const input = settings.querySelector(`#settings-${setting.key}`) as HTMLInputElement
input.value = ""; input.value = ''
}); })
}; }
const recordToggle = () => { const recordToggle = () => {
// Check if already recording - if so, stop - else start // Check if already recording - if so, stop - else start
if (recording) { if (recording) {
recognition?.stop(); recognition?.stop()
recording = false; recording = false
} else { } else {
recognition?.start(); recognition?.start()
} }
}; }
</script> </script>
<nav class="level chat-header"> <nav class="level chat-header">
@ -316,21 +316,21 @@
<p class="subtitle is-5"> <p class="subtitle is-5">
{chat.name || `Chat ${chat.id}`} {chat.name || `Chat ${chat.id}`}
<a <a
href={"#"} href={'#'}
class="greyscale ml-2 is-hidden has-text-weight-bold editbutton" class="greyscale ml-2 is-hidden has-text-weight-bold editbutton"
title="Rename chat" title="Rename chat"
on:click|preventDefault={() => { on:click|preventDefault={() => {
let newChatName = prompt("Enter a new name for this chat", chat.name); const newChatName = window.prompt('Enter a new name for this chat', chat.name)
if (newChatName) { if (newChatName) {
chat.name = newChatName; chat.name = newChatName
chatsStorage.set($chatsStorage); chatsStorage.set($chatsStorage)
} }
}} }}
> >
✏️ ✏️
</a> </a>
<a <a
href={"#"} href={'#'}
class="greyscale ml-2 is-hidden has-text-weight-bold editbutton" class="greyscale ml-2 is-hidden has-text-weight-bold editbutton"
title="Suggest a chat name" title="Suggest a chat name"
on:click|preventDefault={suggestName} on:click|preventDefault={suggestName}
@ -338,7 +338,7 @@
💡 💡
</a> </a>
<a <a
href={"#"} href={'#'}
class="greyscale ml-2 is-hidden editbutton" class="greyscale ml-2 is-hidden editbutton"
title="Delete this chat" title="Delete this chat"
on:click|preventDefault={deleteChat} on:click|preventDefault={deleteChat}
@ -354,7 +354,7 @@
<button <button
class="button is-warning" class="button is-warning"
on:click={() => { on:click={() => {
clearMessages(chatId); clearMessages(chatId)
}}><span class="greyscale mr-2">🗑️</span> Clear messages</button }}><span class="greyscale mr-2">🗑️</span> Clear messages</button
> >
</p> </p>
@ -362,18 +362,18 @@
</nav> </nav>
{#each chat.messages as message} {#each chat.messages as message}
{#if message.role === "user"} {#if message.role === 'user'}
<article <article
class="message is-info user-message" class="message is-info user-message"
class:has-text-right={message.content.split("\n").filter((line) => line.trim()).length === 1} class:has-text-right={message.content.split('\n').filter((line) => line.trim()).length === 1}
> >
<div class="message-body content"> <div class="message-body content">
<a <a
href={"#"} href={'#'}
class="greyscale is-pulled-right ml-2 is-hidden editbutton" class="greyscale is-pulled-right ml-2 is-hidden editbutton"
on:click={() => { on:click={() => {
input.value = message.content; input.value = message.content
input.focus(); input.focus()
}} }}
> >
✏️ ✏️
@ -382,19 +382,19 @@
source={message.content} source={message.content}
options={markedownOptions} options={markedownOptions}
renderers={{ renderers={{
code: Code, code: Code
}} }}
/> />
</div> </div>
</article> </article>
{:else if message.role === "system" || message.role === "error"} {:else if message.role === 'system' || message.role === 'error'}
<article class="message is-danger"> <article class="message is-danger">
<div class="message-body content"> <div class="message-body content">
<SvelteMarkdown <SvelteMarkdown
source={message.content} source={message.content}
options={markedownOptions} options={markedownOptions}
renderers={{ renderers={{
code: Code, code: Code
}} }}
/> />
</div> </div>
@ -406,14 +406,14 @@
source={message.content} source={message.content}
options={markedownOptions} options={markedownOptions}
renderers={{ renderers={{
code: Code, code: Code
}} }}
/> />
{#if message.usage} {#if message.usage}
<p class="is-size-7"> <p class="is-size-7">
This message was generated using <span class="has-text-weight-bold">{message.usage.total_tokens}</span> This message was generated using <span class="has-text-weight-bold">{message.usage.total_tokens}</span>
tokens ~= tokens ~=
<span class="has-text-weight-bold">${(message.usage.total_tokens * token_price).toFixed(6)}</span> <span class="has-text-weight-bold">${(message.usage.total_tokens * tokenPrice).toFixed(6)}</span>
</p> </p>
{/if} {/if}
</div> </div>
@ -433,15 +433,15 @@
rows="1" rows="1"
on:keydown={(e) => { on:keydown={(e) => {
// Only send if Enter is pressed, not Shift+Enter // Only send if Enter is pressed, not Shift+Enter
if (e.key === "Enter" && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
submitForm(); submitForm()
e.preventDefault(); e.preventDefault()
} }
}} }}
on:input={(e) => { on:input={(e) => {
// Resize the textarea to fit the content - auto is important to reset the height after deleting content // Resize the textarea to fit the content - auto is important to reset the height after deleting content
input.style.height = "auto"; input.style.height = 'auto'
input.style.height = input.scrollHeight + "px"; input.style.height = input.scrollHeight + 'px'
}} }}
bind:this={input} bind:this={input}
/> />
@ -461,8 +461,8 @@
<svelte:window <svelte:window
on:keydown={(event) => { on:keydown={(event) => {
if (event.key === "Escape") { if (event.key === 'Escape') {
closeSettings(); closeSettings()
} }
}} }}
/> />
@ -482,7 +482,7 @@
</div> </div>
<div class="field-body"> <div class="field-body">
<div class="field"> <div class="field">
{#if setting.type === "number"} {#if setting.type === 'number'}
<input <input
class="input" class="input"
inputmode="decimal" inputmode="decimal"
@ -493,11 +493,11 @@
step={setting.step} step={setting.step}
placeholder={String(setting.default)} placeholder={String(setting.default)}
/> />
{:else if setting.type === "select"} {:else if setting.type === 'select'}
<div class="select"> <div class="select">
<select id="settings-{setting.key}"> <select id="settings-{setting.key}">
{#each setting.options as option} {#each setting.options as option}
<option value={option} selected={option == setting.default}>{option}</option> <option value={option} selected={option === setting.default}>{option}</option>
{/each} {/each}
</select> </select>
</div> </div>

View File

@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Highlight } from "svelte-highlight"; import { Highlight } from 'svelte-highlight'
// Import both dark and light styles // Import both dark and light styles
import { github, githubDark } from "svelte-highlight/styles"; import { github, githubDark } from 'svelte-highlight/styles'
// Style depends on system theme // Style depends on system theme
const style = window.matchMedia("(prefers-color-scheme: dark)").matches ? githubDark : github; const style = window.matchMedia('(prefers-color-scheme: dark)').matches ? githubDark : github
// Copy function for the code block // Copy function for the code block
import copy from "copy-to-clipboard"; import copy from 'copy-to-clipboard'
// Import all supported languages // Import all supported languages
import { import {
@ -22,80 +22,80 @@
shell, shell,
php, php,
plaintext, plaintext,
type LanguageType, type LanguageType
} from "svelte-highlight/languages"; } from 'svelte-highlight/languages'
export const type: "code" = "code"; export const type: 'code' = 'code'
export const raw: string = ""; export const raw: string = ''
export const codeBlockStyle: "indented" | undefined = undefined; export const codeBlockStyle: 'indented' | undefined = undefined
export let lang: string | undefined = undefined; export let lang: string | undefined
export let text: string; export let text: string
// Map lang string to LanguageType // Map lang string to LanguageType
let language: LanguageType<string>; let language: LanguageType<string>
switch (lang) { switch (lang) {
case "js": case 'js':
case "javascript": case 'javascript':
language = javascript; language = javascript
break; break
case "py": case 'py':
case "python": case 'python':
language = python; language = python
break; break
case "ts": case 'ts':
case "typescript": case 'typescript':
language = typescript; language = typescript
break; break
case "rb": case 'rb':
case "ruby": case 'ruby':
language = ruby; language = ruby
break; break
case "go": case 'go':
case "golang": case 'golang':
language = go; language = go
break; break
case "java": case 'java':
language = java; language = java
break; break
case "sql": case 'sql':
language = sql; language = sql
break; break
case "sh": case 'sh':
case "shell": case 'shell':
case "bash": case 'bash':
language = shell; language = shell
break; break
case "php": case 'php':
language = php; language = php
break; break
default: default:
language = plaintext; language = plaintext
} }
// For copying code - reference: https://vyacheslavbasharov.com/blog/adding-click-to-copy-code-markdown-blog // For copying code - reference: https://vyacheslavbasharov.com/blog/adding-click-to-copy-code-markdown-blog
const copyFunction = (event) => { const copyFunction = (event) => {
// Get the button the user clicked on // Get the button the user clicked on
const clickedElement = event.target as HTMLButtonElement; const clickedElement = event.target as HTMLButtonElement
// Get the next element // Get the next element
const nextElement = clickedElement.nextElementSibling; const nextElement = clickedElement.nextElementSibling
// Modify the appearance of the button // Modify the appearance of the button
const originalButtonContent = clickedElement.innerHTML; const originalButtonContent = clickedElement.innerHTML
clickedElement.classList.add("is-success"); clickedElement.classList.add('is-success')
clickedElement.innerHTML = "Copied!"; clickedElement.innerHTML = 'Copied!'
// Retrieve the code in the code block // Retrieve the code in the code block
const codeBlock = (nextElement.querySelector("pre > code") as HTMLPreElement).innerText; const codeBlock = (nextElement.querySelector('pre > code') as HTMLPreElement).innerText
copy(codeBlock); copy(codeBlock)
// Restored the button after copying the text in 1 second. // Restored the button after copying the text in 1 second.
setTimeout(() => { setTimeout(() => {
clickedElement.innerHTML = originalButtonContent; clickedElement.innerHTML = originalButtonContent
clickedElement.classList.remove("is-success"); clickedElement.classList.remove('is-success')
clickedElement.blur(); clickedElement.blur()
}, 1000); }, 1000)
}; }
</script> </script>
<svelte:head> <svelte:head>

View File

@ -1,28 +1,28 @@
<script context="module" lang="ts"> <script context="module" lang="ts">
import { get } from "svelte/store"; import { get } from 'svelte/store'
import { chatsStorage } from "./Storage.svelte"; import { chatsStorage } from './Storage.svelte'
export const exportAsMarkdown = (chatId: number) => { export const exportAsMarkdown = (chatId: number) => {
const chats = get(chatsStorage); const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId); const chat = chats.find((chat) => chat.id === chatId)
const messages = chat.messages; const messages = chat.messages
console.log(chat); console.log(chat)
let markdownContent = `# ${chat.name}\n`; let markdownContent = `# ${chat.name}\n`
messages.forEach((message) => { messages.forEach((message) => {
const author = message.role; const author = message.role
const content = message.content; const content = message.content
const messageMarkdown = `## ${author}\n${content}\n\n`; const messageMarkdown = `## ${author}\n${content}\n\n`
markdownContent += messageMarkdown; markdownContent += messageMarkdown
}); })
const blob = new Blob([markdownContent], { type: "text/markdown" }); const blob = new Blob([markdownContent], { type: 'text/markdown' })
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob)
const a = document.createElement("a"); const a = document.createElement('a')
a.download = `${chat.name}.md`; a.download = `${chat.name}.md`
a.href = url; a.href = url
document.body.appendChild(a); document.body.appendChild(a)
a.click(); a.click()
document.body.removeChild(a); document.body.removeChild(a)
}; }
</script> </script>

View File

@ -1,21 +1,21 @@
<script lang="ts"> <script lang="ts">
import { params, replace } from "svelte-spa-router"; import { params, replace } from 'svelte-spa-router'
import { clearChats } from "./Storage.svelte"; import { clearChats } from './Storage.svelte'
import { exportAsMarkdown } from "./Export.svelte"; import { exportAsMarkdown } from './Export.svelte'
import type { Chat } from "./Types.svelte"; import type { Chat } from './Types.svelte'
export let sortedChats: Chat[]; export let sortedChats: Chat[]
export let apiKey: string; export let apiKey: string
$: activeChatId = $params && $params.chatId ? parseInt($params.chatId) : undefined; $: activeChatId = $params && $params.chatId ? parseInt($params.chatId) : undefined
</script> </script>
<aside class="menu"> <aside class="menu">
<p class="menu-label">Chats</p> <p class="menu-label">Chats</p>
<ul class="menu-list"> <ul class="menu-list">
{#if sortedChats.length === 0} {#if sortedChats.length === 0}
<li><a href={"#"}>No chats yet...</a></li> <li><a href={'#'}>No chats yet...</a></li>
{:else} {:else}
<li> <li>
<ul> <ul>
@ -33,35 +33,35 @@
<p class="menu-label">Actions</p> <p class="menu-label">Actions</p>
<ul class="menu-list"> <ul class="menu-list">
<li> <li>
<a href={"#/"} class="panel-block" class:is-disabled={!apiKey} class:is-active={!activeChatId} <a href={'#/'} class="panel-block" class:is-disabled={!apiKey} class:is-active={!activeChatId}
><span class="greyscale mr-2">🔑</span> API key</a ><span class="greyscale mr-2">🔑</span> API key</a
> >
</li> </li>
<li> <li>
<a href={"#/chat/new"} class="panel-block" class:is-disabled={!apiKey} <a href={'#/chat/new'} class="panel-block" class:is-disabled={!apiKey}
><span class="greyscale mr-2"></span> New chat</a ><span class="greyscale mr-2"></span> New chat</a
> >
</li> </li>
<li> <li>
<a <a
href={"#/"} href={'#/'}
class="panel-block" class="panel-block"
class:is-disabled={!apiKey} class:is-disabled={!apiKey}
on:click={() => { on:click={() => {
replace("#/").then(() => { replace('#/').then(() => {
clearChats(); clearChats()
}); })
}}><span class="greyscale mr-2">🗑️</span> Clear chats</a }}><span class="greyscale mr-2">🗑️</span> Clear chats</a
> >
</li> </li>
{#if activeChatId} {#if activeChatId}
<li> <li>
<a <a
href={"#/"} href={'#/'}
class="panel-block" class="panel-block"
class:is-disabled={!apiKey} class:is-disabled={!apiKey}
on:click|preventDefault={() => { on:click|preventDefault={() => {
exportAsMarkdown(activeChatId); exportAsMarkdown(activeChatId)
}}><span class="greyscale mr-2">📥</span> Export chat</a }}><span class="greyscale mr-2">📥</span> Export chat</a
> >
</li> </li>

View File

@ -1,57 +1,57 @@
<script context="module" lang="ts"> <script context="module" lang="ts">
import { persisted } from "svelte-local-storage-store"; import { persisted } from 'svelte-local-storage-store'
import { get } from "svelte/store"; import { get } from 'svelte/store'
import type { Chat, Message } from "./Types.svelte"; import type { Chat, Message } from './Types.svelte'
export const chatsStorage = persisted("chats", [] as Chat[]); export const chatsStorage = persisted('chats', [] as Chat[])
export const apiKeyStorage = persisted("apiKey", null as string); export const apiKeyStorage = persisted('apiKey', null as string)
export const addChat = (): number => { export const addChat = (): number => {
const chats = get(chatsStorage); const chats = get(chatsStorage)
// Find the max chatId // Find the max chatId
const chatId = chats.reduce((maxId, chat) => Math.max(maxId, chat.id), 0) + 1; const chatId = chats.reduce((maxId, chat) => Math.max(maxId, chat.id), 0) + 1
// Add a new chat // Add a new chat
chats.push({ chats.push({
id: chatId, id: chatId,
name: `Chat ${chatId}`, name: `Chat ${chatId}`,
messages: [], messages: []
}); })
chatsStorage.set(chats); chatsStorage.set(chats)
return chatId; return chatId
}; }
export const clearChats = () => { export const clearChats = () => {
chatsStorage.set([]); chatsStorage.set([])
}; }
export const addMessage = (chatId: number, message: Message) => { export const addMessage = (chatId: number, message: Message) => {
const chats = get(chatsStorage); const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId); const chat = chats.find((chat) => chat.id === chatId)
chat.messages.push(message); chat.messages.push(message)
chatsStorage.set(chats); chatsStorage.set(chats)
}; }
export const editMessage = (chatId: number, index: number, newMessage: Message) => { export const editMessage = (chatId: number, index: number, newMessage: Message) => {
const chats = get(chatsStorage); const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId); const chat = chats.find((chat) => chat.id === chatId)
chat.messages[index] = newMessage; chat.messages[index] = newMessage
chat.messages.splice(index + 1); // remove the rest of the messages chat.messages.splice(index + 1) // remove the rest of the messages
chatsStorage.set(chats); chatsStorage.set(chats)
}; }
export const clearMessages = (chatId: number) => { export const clearMessages = (chatId: number) => {
const chats = get(chatsStorage); const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId); const chat = chats.find((chat) => chat.id === chatId)
chat.messages = []; chat.messages = []
chatsStorage.set(chats); chatsStorage.set(chats)
}; }
export const deleteChat = (chatId: number) => { export const deleteChat = (chatId: number) => {
const chats = get(chatsStorage); const chats = get(chatsStorage)
const chatIndex = chats.findIndex((chat) => chat.id === chatId); const chatIndex = chats.findIndex((chat) => chat.id === chatId)
chats.splice(chatIndex, 1); chats.splice(chatIndex, 1)
chatsStorage.set(chats); chatsStorage.set(chats)
}; }
</script> </script>

View File

@ -1,21 +1,32 @@
<script context="module" lang="ts"> <script context="module" lang="ts">
export type Usage = {
completion_tokens: number;
prompt_tokens: number;
total_tokens: number;
};
export type Message = {
role: 'user' | 'assistant' | 'system' | 'error';
content: string;
usage?: Usage;
};
export type Chat = { export type Chat = {
id: number; id: number;
name: string; name: string;
messages: Message[]; messages: Message[];
}; };
export type Message = { // See: https://platform.openai.com/docs/models/model-endpoint-compatibility
role: "user" | "assistant" | "system" | "error"; export const supportedModels = [
content: string; 'gpt-4',
usage?: Usage; 'gpt-4-0314',
}; 'gpt-4-32k',
'gpt-4-32k-0314',
export type Usage = { 'gpt-3.5-turbo',
completion_tokens: number; 'gpt-3.5-turbo-0301'
prompt_tokens: number; ]
total_tokens: number; type Model = typeof supportedModels[number];
};
export type Request = { export type Request = {
model?: Model; model?: Model;
@ -32,19 +43,8 @@
user?: string; user?: string;
}; };
// See: https://platform.openai.com/docs/models/model-endpoint-compatibility
export const supportedModels = [
"gpt-4",
"gpt-4-0314",
"gpt-4-32k",
"gpt-4-32k-0314",
"gpt-3.5-turbo",
"gpt-3.5-turbo-0301",
];
type Model = typeof supportedModels[number];
type SettingsNumber = { type SettingsNumber = {
type: "number"; type: 'number';
default: number; default: number;
min: number; min: number;
max: number; max: number;
@ -52,7 +52,7 @@
}; };
export type SettingsSelect = { export type SettingsSelect = {
type: "select"; type: 'select';
default: Model; default: Model;
options: Model[]; options: Model[];
}; };
@ -86,7 +86,7 @@
export type Response = ResponseOK & ResponseError; export type Response = ResponseOK & ResponseError;
export type ResponseModels = { export type ResponseModels = {
object: "list"; object: 'list';
data: { data: {
id: string; id: string;
}[]; }[];