Componentize Settings - UI Changes
This commit is contained in:
parent
70049f9b64
commit
da5745ffdf
|
@ -37,10 +37,11 @@
|
||||||
|
|
||||||
'*': Home
|
'*': Home
|
||||||
}
|
}
|
||||||
|
document.body.classList.add('has-navbar-fixed-top')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Navbar />
|
|
||||||
|
|
||||||
|
<Navbar />
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container is-fullhd">
|
<div class="container is-fullhd">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
|
|
39
src/app.scss
39
src/app.scss
|
@ -17,18 +17,36 @@
|
||||||
section.section {
|
section.section {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-option-menu.navbar-item {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* temp. fix to keep navbar from getting huge on small screen devices
|
||||||
|
if the right menu is put in the proper navbar-end container */
|
||||||
|
.navbar-brand {
|
||||||
|
/* margin-right: 0; */
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item .menu-icon {
|
||||||
|
padding-right: .5em;
|
||||||
|
}
|
||||||
|
.dropdown-menu {
|
||||||
|
max-height: calc(100vh - 60px);;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
cursor: default;
|
||||||
|
opacity: .65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rotate {
|
.rotate {
|
||||||
animation: rotating 10s linear infinite;
|
animation: rotating 10s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.is-disabled {
|
|
||||||
pointer-events: none;
|
|
||||||
cursor: default;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.greyscale {
|
.greyscale {
|
||||||
filter: grayscale(100%);
|
filter: grayscale(100%);
|
||||||
}
|
}
|
||||||
|
@ -100,7 +118,16 @@ $modal-content-width: 1000px;
|
||||||
$modal-background-background-color-dark: rgba($dark, 0.86) !default; // remove this once a new version of bulma-prefers-dark is released
|
$modal-background-background-color-dark: rgba($dark, 0.86) !default; // remove this once a new version of bulma-prefers-dark is released
|
||||||
@import "/node_modules/bulma-prefers-dark/build/bulma-prefers-dark.sass";
|
@import "/node_modules/bulma-prefers-dark/build/bulma-prefers-dark.sass";
|
||||||
|
|
||||||
|
/* For the message notes on light mode */
|
||||||
|
.message-note, .running-totals {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
|
/* For the message notes on dark mode */
|
||||||
|
.message-note, .running-totals {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
.modal-card-body {
|
.modal-card-body {
|
||||||
// remove this once https: //github.com/jloh/bulma-prefers-dark/pull/90 is merged and released
|
// remove this once https: //github.com/jloh/bulma-prefers-dark/pull/90 is merged and released
|
||||||
background-color: $background-dark;
|
background-color: $background-dark;
|
||||||
|
|
|
@ -4,62 +4,40 @@
|
||||||
saveChatStore,
|
saveChatStore,
|
||||||
apiKeyStorage,
|
apiKeyStorage,
|
||||||
chatsStorage,
|
chatsStorage,
|
||||||
globalStorage,
|
|
||||||
addMessage,
|
addMessage,
|
||||||
insertMessages,
|
insertMessages,
|
||||||
clearMessages,
|
|
||||||
copyChat,
|
|
||||||
getChatSettingValueNullDefault,
|
getChatSettingValueNullDefault,
|
||||||
saveCustomProfile,
|
|
||||||
deleteCustomProfile,
|
|
||||||
setGlobalSettingValueByKey,
|
|
||||||
updateChatSettings,
|
updateChatSettings,
|
||||||
resetChatSettings,
|
updateRunningTotal,
|
||||||
setChatSettingValue,
|
checkStateChange,
|
||||||
addChatFromJSON,
|
showSetChatSettings
|
||||||
updateRunningTotal
|
|
||||||
} from './Storage.svelte'
|
} from './Storage.svelte'
|
||||||
import { getChatSettingObjectByKey, getChatSettingList, getRequestSettingList, getChatDefaults, defaultModel } from './Settings.svelte'
|
import { getRequestSettingList, defaultModel } from './Settings.svelte'
|
||||||
import {
|
import {
|
||||||
type Request,
|
type Request,
|
||||||
type Response,
|
type Response,
|
||||||
type Message,
|
type Message,
|
||||||
type ChatSetting,
|
type Chat
|
||||||
type ResponseModels,
|
|
||||||
type SettingSelect,
|
|
||||||
type Chat,
|
|
||||||
type SelectOption,
|
|
||||||
supportedModels
|
|
||||||
} from './Types.svelte'
|
} from './Types.svelte'
|
||||||
import Prompts from './Prompts.svelte'
|
import Prompts from './Prompts.svelte'
|
||||||
import Messages from './Messages.svelte'
|
import Messages from './Messages.svelte'
|
||||||
import { applyProfile, getProfile, getProfileSelect, prepareSummaryPrompt, getDefaultProfileKey } from './Profiles.svelte'
|
import { applyProfile, getProfile, prepareSummaryPrompt } from './Profiles.svelte'
|
||||||
|
|
||||||
import { afterUpdate, onMount } from 'svelte'
|
import { afterUpdate, onMount } from 'svelte'
|
||||||
import { replace } from 'svelte-spa-router'
|
|
||||||
import Fa from 'svelte-fa/src/fa.svelte'
|
import Fa from 'svelte-fa/src/fa.svelte'
|
||||||
import {
|
import {
|
||||||
faArrowUpFromBracket,
|
faArrowUpFromBracket,
|
||||||
faPaperPlane,
|
faPaperPlane,
|
||||||
faGear,
|
faGear,
|
||||||
faPenToSquare,
|
faPenToSquare,
|
||||||
faTrash,
|
|
||||||
faMicrophone,
|
faMicrophone,
|
||||||
faLightbulb,
|
faLightbulb
|
||||||
faClone,
|
|
||||||
faEllipsisVertical,
|
|
||||||
faFloppyDisk,
|
|
||||||
faThumbtack,
|
|
||||||
faDownload,
|
|
||||||
faUpload,
|
|
||||||
faEraser,
|
|
||||||
faRotateRight
|
|
||||||
} from '@fortawesome/free-solid-svg-icons/index'
|
} from '@fortawesome/free-solid-svg-icons/index'
|
||||||
// import { encode } from 'gpt-tokenizer'
|
// import { encode } from 'gpt-tokenizer'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
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'
|
import { countPromptTokens, getMaxModelPrompt, getPrice } from './Stats.svelte'
|
||||||
|
import { autoGrowInputOnEvent, sizeTextElements } from './Util.svelte'
|
||||||
|
import ChatSettingsModal from './ChatSettingsModal.svelte'
|
||||||
|
|
||||||
// This makes it possible to override the OpenAI API base URL in the .env file
|
// 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'
|
const apiBase = import.meta.env.VITE_API_BASE || 'https://api.openai.com'
|
||||||
|
@ -74,19 +52,33 @@
|
||||||
let chatNameSettings: HTMLFormElement
|
let chatNameSettings: HTMLFormElement
|
||||||
let recognition: any = null
|
let recognition: any = null
|
||||||
let recording = false
|
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
|
$: chat = $chatsStorage.find((chat) => chat.id === chatId) as Chat
|
||||||
$: chatSettings = chat.settings
|
$: chatSettings = chat.settings
|
||||||
$: globalStore = $globalStorage
|
let showSettingsModal
|
||||||
|
|
||||||
|
let scDelay
|
||||||
|
const onStateChange = (...args:any) => {
|
||||||
|
clearTimeout(scDelay)
|
||||||
|
setTimeout(() => {
|
||||||
|
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
|
||||||
|
submitForm(false, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($showSetChatSettings) {
|
||||||
|
$showSetChatSettings = false
|
||||||
|
showSettingsModal()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
$: onStateChange($checkStateChange, $showSetChatSettings)
|
||||||
|
|
||||||
// Make sure chat object is ready to go
|
// Make sure chat object is ready to go
|
||||||
updateChatSettings(chatId)
|
updateChatSettings(chatId)
|
||||||
|
@ -452,14 +444,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = () => {
|
const showChatNameSettings = () => {
|
||||||
chatNameSettings.classList.add('is-active');
|
chatNameSettings.classList.add('is-active');
|
||||||
(chatNameSettings.querySelector('#settings-chat-name') as HTMLInputElement).focus();
|
(chatNameSettings.querySelector('#settings-chat-name') as HTMLInputElement).focus();
|
||||||
|
@ -480,78 +464,6 @@
|
||||||
chatNameSettings.classList.remove('is-active')
|
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 = () => {
|
const recordToggle = () => {
|
||||||
// Check if already recording - if so, stop - else start
|
// Check if already recording - if so, stop - else start
|
||||||
if (recording) {
|
if (recording) {
|
||||||
|
@ -562,147 +474,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<nav class="level chat-header">
|
<nav class="level chat-header">
|
||||||
|
@ -720,49 +491,6 @@
|
||||||
|
|
||||||
<div class="level-right">
|
<div class="level-right">
|
||||||
<div class="level-item">
|
<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> -->
|
<!-- <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>
|
||||||
</div>
|
</div>
|
||||||
|
@ -806,7 +534,7 @@
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button title="Chat/Profile Settings" class="button" on:click|preventDefault={showSettings}><Fa icon={faGear} /></button>
|
<button title="Chat/Profile Settings" class="button" on:click|preventDefault={showSettingsModal}><Fa icon={faGear} /></button>
|
||||||
</p>
|
</p>
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button title="Add message, don't send yet" class="button is-ghost" on:click|preventDefault={addNewMessage}><Fa icon={faArrowUpFromBracket} /></button>
|
<button title="Add message, don't send yet" class="button is-ghost" on:click|preventDefault={addNewMessage}><Fa icon={faArrowUpFromBracket} /></button>
|
||||||
|
@ -825,153 +553,7 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<svelte:window
|
<ChatSettingsModal chatId={chatId} bind:show={showSettingsModal} />
|
||||||
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 -->
|
<!-- rename modal -->
|
||||||
<form class="modal" bind:this={chatNameSettings} on:submit={saveChatNameSettings}>
|
<form class="modal" bind:this={chatNameSettings} on:submit={saveChatNameSettings}>
|
||||||
|
@ -1006,6 +588,14 @@
|
||||||
</form>
|
</form>
|
||||||
<!-- end -->
|
<!-- end -->
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
on:keydown={(event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeChatNameSettings()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.running-total-container {
|
.running-total-container {
|
||||||
min-height:2em;
|
min-height:2em;
|
||||||
|
@ -1013,7 +603,4 @@
|
||||||
/* padding-left: 1.9em; */
|
/* padding-left: 1.9em; */
|
||||||
margin-bottom:-2.6em
|
margin-bottom:-2.6em
|
||||||
}
|
}
|
||||||
.running-totals {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -0,0 +1,118 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Fa from 'svelte-fa/src/fa.svelte'
|
||||||
|
import {
|
||||||
|
faGear,
|
||||||
|
faTrash,
|
||||||
|
faClone,
|
||||||
|
faEllipsisVertical,
|
||||||
|
faDownload,
|
||||||
|
faUpload,
|
||||||
|
faEraser,
|
||||||
|
faRotateRight,
|
||||||
|
faSquarePlus,
|
||||||
|
faKey,
|
||||||
|
faFileExport,
|
||||||
|
faTrashCan
|
||||||
|
} from '@fortawesome/free-solid-svg-icons/index'
|
||||||
|
import { addChatFromJSON, chatsStorage, clearChats, clearMessages, copyChat, showSetChatSettings } from './Storage.svelte'
|
||||||
|
import { exportAsMarkdown, exportChatAsJSON } from './Export.svelte'
|
||||||
|
import { applyProfile } from './Profiles.svelte'
|
||||||
|
import { replace } from 'svelte-spa-router'
|
||||||
|
import { clickOutside } from 'svelte-use-click-outside'
|
||||||
|
|
||||||
|
export let chatId
|
||||||
|
export const show = () => {
|
||||||
|
showChatMenu = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let showChatMenu = false
|
||||||
|
let chatFileInput
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 confirmClearChats = () => {
|
||||||
|
if (window.confirm('Are you sure you want to delete ALL of your chats?')) {
|
||||||
|
clearChats()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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" class:is-disabled={!chatId} on:click|preventDefault={() => { if (chatId) showChatMenu = false; $showSetChatSettings = true }}>
|
||||||
|
<span class="menu-icon"><Fa icon={faGear}/></span> Chat Profile Settings
|
||||||
|
</a>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
<a href={'#/chat/new'} class="dropdown-item">
|
||||||
|
<span class="menu-icon"><Fa icon={faSquarePlus}/></span> New Chat
|
||||||
|
</a>
|
||||||
|
<a href={'#'} class="dropdown-item" class:is-disabled={!chatId} on:click|preventDefault={() => { if (chatId) showChatMenu = false; copyChat(chatId) }}>
|
||||||
|
<span class="menu-icon"><Fa icon={faClone}/></span> Clone Chat
|
||||||
|
</a>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
<a href={'#'} class="dropdown-item" class:is-disabled={!chatId} on:click|preventDefault={() => { showChatMenu = false; exportChatAsJSON(chatId) }}>
|
||||||
|
<span class="menu-icon"><Fa icon={faDownload}/></span> Save Chat JSON
|
||||||
|
</a>
|
||||||
|
<a href={'#'} class="dropdown-item" on:click|preventDefault={() => { if (chatId) showChatMenu = false; chatFileInput.click() }}>
|
||||||
|
<span class="menu-icon"><Fa icon={faUpload}/></span> Restore Chat JSON
|
||||||
|
</a>
|
||||||
|
<a href={'#'} class="dropdown-item" on:click|preventDefault={() => { if (chatId) showChatMenu = false; exportAsMarkdown(chatId) }}>
|
||||||
|
<span class="menu-icon"><Fa icon={faFileExport}/></span> Export Chat Markdown
|
||||||
|
</a>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
<a href={'#'} class="dropdown-item" class:is-disabled={!chatId} on:click|preventDefault={() => { if (chatId) applyProfile(chatId, '', true) /* closeSettings() */ }}>
|
||||||
|
<span class="menu-icon"><Fa icon={faRotateRight}/></span> Restart Chat Session
|
||||||
|
</a>
|
||||||
|
<a href={'#'} class="dropdown-item" class:is-disabled={!chatId} on:click|preventDefault={() => { if (chatId) showChatMenu = false; clearMessages(chatId) }}>
|
||||||
|
<span class="menu-icon"><Fa icon={faEraser}/></span> Clear Chat Messages
|
||||||
|
</a>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
<a href={'#'} class="dropdown-item" class:is-disabled={!chatId} on:click|preventDefault={() => { if (chatId) showChatMenu = false; deleteChat() }}>
|
||||||
|
<span class="menu-icon"><Fa icon={faTrash}/></span> Delete Chat
|
||||||
|
</a>
|
||||||
|
<a href={'#'} class="dropdown-item" class:is-disabled={!chatId} on:click|preventDefault={() => { if (chatId) showChatMenu = false; confirmClearChats() }}>
|
||||||
|
<span class="menu-icon"><Fa icon={faTrashCan}/></span> Delete ALL Chats
|
||||||
|
</a>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
<a href={'#/'} class="dropdown-item">
|
||||||
|
<span><Fa icon={faKey}/></span> API Key
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input style="display:none" type="file" accept=".json" on:change={(e) => importChatFromFile(e)} bind:this={chatFileInput} >
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
on:keydown={(event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
showChatMenu = false
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
|
@ -0,0 +1,383 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { applyProfile, getDefaultProfileKey, getProfile, getProfileSelect } from './Profiles.svelte'
|
||||||
|
import { getChatDefaults, getChatSettingList, getChatSettingObjectByKey } from './Settings.svelte'
|
||||||
|
import {
|
||||||
|
saveChatStore,
|
||||||
|
apiKeyStorage,
|
||||||
|
chatsStorage,
|
||||||
|
globalStorage,
|
||||||
|
saveCustomProfile,
|
||||||
|
deleteCustomProfile,
|
||||||
|
setGlobalSettingValueByKey,
|
||||||
|
resetChatSettings,
|
||||||
|
setChatSettingValue,
|
||||||
|
checkStateChange
|
||||||
|
} from './Storage.svelte'
|
||||||
|
import { supportedModels, type Chat, type ChatSetting, type ResponseModels, type SettingSelect, type SelectOption } from './Types.svelte'
|
||||||
|
import { sizeTextElements, autoGrowInputOnEvent } from './Util.svelte'
|
||||||
|
import Fa from 'svelte-fa/src/fa.svelte'
|
||||||
|
import {
|
||||||
|
faTrash,
|
||||||
|
faClone,
|
||||||
|
faEllipsisVertical,
|
||||||
|
faFloppyDisk,
|
||||||
|
faThumbtack,
|
||||||
|
faDownload,
|
||||||
|
faUpload
|
||||||
|
} from '@fortawesome/free-solid-svg-icons/index'
|
||||||
|
import { exportProfileAsJSON } from './Export.svelte'
|
||||||
|
|
||||||
|
export let chatId:number
|
||||||
|
export const show = () => { showSettings() }
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
|
||||||
|
let showSettingsModal = 0
|
||||||
|
let showProfileMenu:boolean = false
|
||||||
|
let profileFileInput
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
const closeSettings = () => {
|
||||||
|
showProfileMenu = false
|
||||||
|
$checkStateChange++
|
||||||
|
showSettingsModal = 0
|
||||||
|
// if (chat.startSession) {
|
||||||
|
// chat.startSession = false
|
||||||
|
// saveChatStore()
|
||||||
|
// // submitForm(false, true)
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSettings = () => {
|
||||||
|
resetChatSettings(chatId)
|
||||||
|
showSettingsModal++ // Make sure the dialog updates
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshSettings = async () => {
|
||||||
|
showSettingsModal && showSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 updateProfileSelectOptions = () => {
|
||||||
|
const profileSelect = getChatSettingObjectByKey('profile') as ChatSetting & SettingSelect
|
||||||
|
profileSelect.options = getProfileSelect()
|
||||||
|
chatDefaults.profile = getDefaultProfileKey()
|
||||||
|
// const defaultProfile = globalStore.defaultProfile || profileSelect.options[0].value
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 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 class="menu-icon"><Fa icon={faFloppyDisk}/></span> Save Profile
|
||||||
|
</a>
|
||||||
|
<a href={'#'} class="dropdown-item" on:click|preventDefault={cloneProfile}>
|
||||||
|
<span class="menu-icon"><Fa icon={faClone}/></span> Clone Profile
|
||||||
|
</a>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
<a href={'#'}
|
||||||
|
class="dropdown-item"
|
||||||
|
on:click|preventDefault={() => { showProfileMenu = false; exportProfileAsJSON(chatId) }}
|
||||||
|
>
|
||||||
|
<span class="menu-icon"><Fa icon={faDownload}/></span> Save Profile JSON
|
||||||
|
</a>
|
||||||
|
<a href={'#'} class="dropdown-item" on:click|preventDefault={() => { showProfileMenu = false; profileFileInput.click() }}>
|
||||||
|
<span class="menu-icon"><Fa icon={faUpload}/></span> Restore Profile JSON
|
||||||
|
</a>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
<a href={'#'} class="dropdown-item" on:click|preventDefault={pinDefaultProfile}>
|
||||||
|
<span class="menu-icon"><Fa icon={faThumbtack}/></span> Set as Default Profile
|
||||||
|
</a>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
<a href={'#'} class="dropdown-item" on:click|preventDefault={deleteProfile}>
|
||||||
|
<span class="menu-icon"><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</button>
|
||||||
|
<button class="button is-warning" on:click={clearSettings}>Reset</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input style="display:none" type="file" accept=".json" on:change={(e) => importProfileFromFile(e)} bind:this={profileFileInput} >
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
on:keydown={(event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeSettings()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
|
@ -7,6 +7,7 @@
|
||||||
import type { Message, Model, Chat } from './Types.svelte'
|
import type { Message, Model, Chat } from './Types.svelte'
|
||||||
import Fa from 'svelte-fa/src/fa.svelte'
|
import Fa from 'svelte-fa/src/fa.svelte'
|
||||||
import { faTrash, faDiagramPredecessor, faDiagramNext } from '@fortawesome/free-solid-svg-icons/index'
|
import { faTrash, faDiagramPredecessor, faDiagramNext } from '@fortawesome/free-solid-svg-icons/index'
|
||||||
|
import { scrollIntoViewWithOffset } from './Util.svelte'
|
||||||
|
|
||||||
export let message:Message
|
export let message:Message
|
||||||
export let chatId:number
|
export let chatId:number
|
||||||
|
@ -102,7 +103,7 @@
|
||||||
}
|
}
|
||||||
const el = document.getElementById('message-' + uuid)
|
const el = document.getElementById('message-' + uuid)
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollIntoView({ behavior: 'smooth' })
|
scrollIntoViewWithOffset(el, 60)
|
||||||
} else {
|
} else {
|
||||||
console.error("Can't find element with message ID", uuid)
|
console.error("Can't find element with message ID", uuid)
|
||||||
}
|
}
|
||||||
|
@ -205,7 +206,6 @@
|
||||||
.message-note {
|
.message-note {
|
||||||
padding-top: .6em;
|
padding-top: .6em;
|
||||||
margin-bottom: -0.6em;
|
margin-bottom: -0.6em;
|
||||||
opacity: 0.5;
|
|
||||||
}
|
}
|
||||||
.message-edit {
|
.message-edit {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { apiKeyStorage } from './Storage.svelte'
|
import { apiKeyStorage } from './Storage.svelte'
|
||||||
|
|
||||||
|
$: apiKey = $apiKeyStorage
|
||||||
|
|
||||||
$: apiKey = $apiKeyStorage
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article class="message">
|
<article class="message">
|
||||||
|
|
|
@ -1,12 +1,21 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import logo from '../assets/logo.svg'
|
import { params } from 'svelte-spa-router'
|
||||||
|
import logo from '../assets/logo.svg'
|
||||||
|
import ChatOptionMenu from './ChatOptionMenu.svelte'
|
||||||
|
let showChatMenu
|
||||||
|
|
||||||
|
$: activeChatId = $params && $params.chatId ? parseInt($params.chatId) : undefined
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="navbar" aria-label="main navigation">
|
<nav class="navbar is-fixed-top" aria-label="main navigation">
|
||||||
<div class="navbar-brand">
|
<div class="navbar-brand">
|
||||||
<a class="navbar-item" href={'#/'}>
|
<a class="navbar-item" href={'#/'}>
|
||||||
<img src={logo} alt="ChatGPT-web" width="28" height="28" />
|
<img src={logo} alt="ChatGPT-web" width="28" height="28" />
|
||||||
<p class="ml-2 is-size-4 has-text-weight-bold">ChatGPT-web</p>
|
<p class="ml-2 is-size-4 has-text-weight-bold">ChatGPT-web</p>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<div class="chat-option-menu navbar-item is-pulled-right">
|
||||||
|
<ChatOptionMenu bind:show={showChatMenu} bind:chatId={activeChatId} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { params, replace } from 'svelte-spa-router'
|
import { params, replace } from 'svelte-spa-router'
|
||||||
|
|
||||||
import { apiKeyStorage, chatsStorage, clearChats, deleteChat, addChatFromJSON } from './Storage.svelte'
|
import { apiKeyStorage, chatsStorage, deleteChat } from './Storage.svelte'
|
||||||
import { exportAsMarkdown, exportChatAsJSON } from './Export.svelte'
|
|
||||||
import Fa from 'svelte-fa/src/fa.svelte'
|
import Fa from 'svelte-fa/src/fa.svelte'
|
||||||
import { faSquarePlus, faTrash, faKey, faDownload, faUpload, faFileExport } from '@fortawesome/free-solid-svg-icons/index'
|
import { faSquarePlus, faTrash, faKey } from '@fortawesome/free-solid-svg-icons/index'
|
||||||
|
|
||||||
$: sortedChats = $chatsStorage.sort((a, b) => b.id - a.id)
|
$: sortedChats = $chatsStorage.sort((a, b) => b.id - a.id)
|
||||||
|
|
||||||
$: activeChatId = $params && $params.chatId ? parseInt($params.chatId) : undefined
|
$: activeChatId = $params && $params.chatId ? parseInt($params.chatId) : undefined
|
||||||
|
|
||||||
function delChat (chatId) {
|
|
||||||
|
function delChat (chatId:number) {
|
||||||
if (activeChatId === chatId) {
|
if (activeChatId === chatId) {
|
||||||
// Find the max chatId other than the current one
|
// Find the max chatId other than the current one
|
||||||
const newChatId = sortedChats.reduce((maxId, chat) => {
|
const newChatId = sortedChats.reduce((maxId, chat) => {
|
||||||
|
@ -30,17 +30,17 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileinput
|
// let fileinput
|
||||||
|
|
||||||
const onFileSelected = (e) => {
|
// const onFileSelected = (e) => {
|
||||||
const image = e.target.files[0]
|
// const image = e.target.files[0]
|
||||||
const reader = new FileReader()
|
// const reader = new FileReader()
|
||||||
reader.readAsText(image)
|
// reader.readAsText(image)
|
||||||
reader.onload = e => {
|
// reader.onload = e => {
|
||||||
const json = (e.target || {}).result as string
|
// const json = (e.target || {}).result as string
|
||||||
addChatFromJSON(json)
|
// addChatFromJSON(json)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside class="menu">
|
<aside class="menu">
|
||||||
|
@ -65,17 +65,20 @@
|
||||||
</ul>
|
</ul>
|
||||||
<p class="menu-label">Actions</p>
|
<p class="menu-label">Actions</p>
|
||||||
<ul class="menu-list">
|
<ul class="menu-list">
|
||||||
|
{#if !$apiKeyStorage}
|
||||||
<li>
|
<li>
|
||||||
<a href={'#/'} class="panel-block" class:is-disabled={!$apiKeyStorage} class:is-active={!activeChatId}
|
<a href={'#/'} class="panel-block" class:is-disabled={!$apiKeyStorage} class:is-active={!activeChatId}
|
||||||
><span class="greyscale mr-2"><Fa icon={faKey} /></span> API key</a
|
><span class="greyscale mr-2"><Fa icon={faKey} /></span> API key</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
{:else}
|
||||||
<li>
|
<li>
|
||||||
<a href={'#/chat/new'} class="panel-block" class:is-disabled={!$apiKeyStorage}
|
<a href={'#/chat/new'} class="panel-block" class:is-disabled={!$apiKeyStorage}
|
||||||
><span class="greyscale mr-2"><Fa icon={faSquarePlus} /></span> New chat</a
|
><span class="greyscale mr-2"><Fa icon={faSquarePlus} /></span> New chat</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
{/if}
|
||||||
|
<!-- <li>
|
||||||
<a class="panel-block"
|
<a class="panel-block"
|
||||||
href="{'#/'}"
|
href="{'#/'}"
|
||||||
class:is-disabled={!$apiKeyStorage}
|
class:is-disabled={!$apiKeyStorage}
|
||||||
|
@ -113,8 +116,8 @@
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{/if}
|
{/if} -->
|
||||||
<li>
|
<!-- <li>
|
||||||
<a
|
<a
|
||||||
href={'#/'}
|
href={'#/'}
|
||||||
class="panel-block"
|
class="panel-block"
|
||||||
|
@ -122,7 +125,7 @@
|
||||||
on:click|preventDefault={() => { fileinput.click() }}><span class="greyscale mr-2"><Fa icon={faUpload} /></span> Load chat</a
|
on:click|preventDefault={() => { fileinput.click() }}><span class="greyscale mr-2"><Fa icon={faUpload} /></span> Load chat</a
|
||||||
>
|
>
|
||||||
<input style="display:none" type="file" accept=".json" on:change={(e) => onFileSelected(e)} bind:this={fileinput} >
|
<input style="display:none" type="file" accept=".json" on:change={(e) => onFileSelected(e)} bind:this={fileinput} >
|
||||||
</li>
|
</li> -->
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<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, writable } from 'svelte/store'
|
||||||
import type { Chat, ChatSettings, GlobalSettings, Message, ChatSetting, GlobalSetting, Usage, Model } from './Types.svelte'
|
import type { Chat, ChatSettings, GlobalSettings, Message, ChatSetting, GlobalSetting, Usage, Model } from './Types.svelte'
|
||||||
import { getChatSettingObjectByKey, getGlobalSettingObjectByKey, getChatDefaults, getExcludeFromProfile } from './Settings.svelte'
|
import { getChatSettingObjectByKey, getGlobalSettingObjectByKey, getChatDefaults, getExcludeFromProfile } from './Settings.svelte'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
@ -9,6 +9,9 @@
|
||||||
export const chatsStorage = persisted('chats', [] as Chat[])
|
export const chatsStorage = persisted('chats', [] as Chat[])
|
||||||
export const globalStorage = persisted('global', {} as GlobalSettings)
|
export const globalStorage = persisted('global', {} as GlobalSettings)
|
||||||
export const apiKeyStorage = persisted('apiKey', '' as string)
|
export const apiKeyStorage = persisted('apiKey', '' as string)
|
||||||
|
export let checkStateChange = writable(0) // Trigger for Chat
|
||||||
|
export let showSetChatSettings = writable(false) //
|
||||||
|
|
||||||
|
|
||||||
const chatDefaults = getChatDefaults()
|
const chatDefaults = getChatDefaults()
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
<script context="module" lang="ts">
|
||||||
|
export 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const autoGrowInput = (el: HTMLTextAreaElement) => {
|
||||||
|
el.style.height = '38px' // don't use "auto" here. Firefox will over-size.
|
||||||
|
el.style.height = el.scrollHeight + 'px'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scrollIntoViewWithOffset = (element:HTMLElement, offset:number) => {
|
||||||
|
window.scrollTo({
|
||||||
|
behavior: 'smooth',
|
||||||
|
top:
|
||||||
|
element.getBoundingClientRect().top -
|
||||||
|
document.body.getBoundingClientRect().top -
|
||||||
|
offset
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
Loading…
Reference in New Issue