chatgpt-web/src/lib/ChatSettingsModal.svelte

386 lines
13 KiB
Svelte

<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'
import { afterUpdate } from '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
afterUpdate(() => {
sizeTextElements()
})
const closeSettings = () => {
showProfileMenu = false
$checkStateChange++
showSettingsModal = 0
}
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(), 0)
}
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={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={() => { showProfileMenu = false; exportProfileAsJSON(chatId) }}
>
<span class="menu-icon"><Fa icon={faDownload}/></span> Backup 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={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()
}
}}
/>