This commit is contained in:
2025-07-06 01:32:43 +09:00
parent 574e04fa19
commit 153fa0cdac
16 changed files with 763 additions and 145 deletions

View File

@@ -3,16 +3,19 @@
import { wrap } from 'svelte-spa-router/wrap' import { wrap } from 'svelte-spa-router/wrap'
import Navbar from './lib/Navbar.svelte' import Navbar from './lib/Navbar.svelte'
import Sidebar from './lib/Sidebar.svelte' import Sidebar, { sidebarCollapsed } from './lib/Sidebar.svelte'
import Home from './lib/Home.svelte' import Home from './lib/Home.svelte'
import Chat from './lib/Chat.svelte' import Chat from './lib/Chat.svelte'
import NewChat from './lib/NewChat.svelte' import NewChat from './lib/NewChat.svelte'
import { chatsStorage } from './lib/Storage.svelte' import { chatsStorage } from './lib/Storage.svelte'
import { Modals, closeModal } from 'svelte-modals' import { Modals, closeModal } from 'svelte-modals'
import { dispatchModalEsc, checkModalEsc } from './lib/Util.svelte' import { dispatchModalEsc, checkModalEsc, migrateChatData } from './lib/Util.svelte'
import { set as setOpenAI } from './lib/providers/openai/util.svelte' import { set as setOpenAI } from './lib/providers/openai/util.svelte'
import { hasActiveModels } from './lib/Models.svelte' import { hasActiveModels } from './lib/Models.svelte'
// Run migration on app startup to convert old numeric chat IDs to UUIDs
migrateChatData()
// 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
// Example: https://niek.github.io/chatgpt-web/#/?key=sk-... // Example: https://niek.github.io/chatgpt-web/#/?key=sk-...
const urlParams: URLSearchParams = new URLSearchParams($querystring) const urlParams: URLSearchParams = new URLSearchParams($querystring)
@@ -34,7 +37,7 @@
'/chat/:chatId': wrap({ '/chat/:chatId': wrap({
component: Chat, component: Chat,
conditions: (detail) => { conditions: (detail) => {
return $chatsStorage.find((chat) => chat.id === parseInt(detail?.params?.chatId as string)) !== undefined return $chatsStorage.find((chat) => chat.id === detail?.params?.chatId as string) !== undefined
} }
}), }),
@@ -51,10 +54,10 @@
</script> </script>
<Navbar /> <Navbar />
<div class="side-bar-column"> <div class="side-bar-column" class:collapsed={$sidebarCollapsed}>
<Sidebar /> <Sidebar />
</div> </div>
<div class="main-content-column" id="content"> <div class="main-content-column" class:collapsed={$sidebarCollapsed} id="content">
{#key $location} {#key $location}
<Router {routes} on:conditionsFailed={() => replace('/')}/> <Router {routes} on:conditionsFailed={() => replace('/')}/>
{/key} {/key}

View File

@@ -96,7 +96,9 @@ html {
/* Sizes */ /* Sizes */
--sidebarTop: 0px; --sidebarTop: 0px;
--sidebarWidth: max(300px, 20%); --sidebarWidth: max(300px, 20%);
--sidebarCollapsedWidth: 60px;
--mainContentWidth: calc(100% - var(--sidebarWidth)); --mainContentWidth: calc(100% - var(--sidebarWidth));
--mainContentWidthCollapsed: calc(100% - var(--sidebarCollapsedWidth));
--sectionPaddingTop: 0px; --sectionPaddingTop: 0px;
@@ -579,13 +581,17 @@ $modal-background-background-color-dark: rgba($dark, 0.86) !default; // remove t
width: var(--mainContentWidth); width: var(--mainContentWidth);
padding-top: var(--sectionPaddingTop); padding-top: var(--sectionPaddingTop);
position: relative; position: relative;
transition: width 0.3s ease;
}
.main-content-column.collapsed {
width: var(--mainContentWidthCollapsed);
} }
aside.menu.main-menu { aside.menu.main-menu {
z-index:50; z-index:50;
position: fixed; position: fixed;
width: var(--sidebarWidth); width: var(--sidebarWidth);
padding-right: 20px;
top: var(--sidebarTop); top: var(--sidebarTop);
bottom:0px; bottom:0px;
} }
@@ -681,6 +687,12 @@ aside.menu.main-menu .menu-expanse {
position: fixed; position: fixed;
bottom: 0px; bottom: 0px;
width: var(--mainContentWidth); width: var(--mainContentWidth);
transition: width 0.3s ease;
}
.main-content-column.collapsed ~ .pin-footer,
.main-content-column.collapsed .pin-footer {
width: var(--mainContentWidthCollapsed);
} }
.prompt-input-container { .prompt-input-container {
@@ -688,6 +700,7 @@ aside.menu.main-menu .menu-expanse {
position: fixed; position: fixed;
bottom: 0px; bottom: 0px;
width: var(--mainContentWidth); width: var(--mainContentWidth);
transition: width 0.3s ease;
padding: padding:
var(--chatInputPaddingTop) var(--chatInputPaddingTop)
@@ -704,6 +717,11 @@ aside.menu.main-menu .menu-expanse {
} }
} }
.main-content-column.collapsed ~ .prompt-input-container,
.main-content-column.collapsed .prompt-input-container {
width: var(--mainContentWidthCollapsed);
}
@media only screen and (max-width: 900px) { @media only screen and (max-width: 900px) {
.prompt-input-container { .prompt-input-container {
.control.send .button { .control.send .button {
@@ -744,6 +762,10 @@ aside.menu.main-menu .menu-expanse {
margin-left: 8px; margin-left: 8px;
} }
.navbar .uncollapse-menu .button {
border: none;
background-color: transparent;
}
.main-menu .menu-nav-bar .chat-option-menu { .main-menu .menu-nav-bar .chat-option-menu {
padding-right: 2px; padding-right: 2px;
} }
@@ -938,3 +960,448 @@ aside.menu.main-menu .menu-expanse {
.modal.chat-settings .field-body { .modal.chat-settings .field-body {
max-width: calc(100% - 40px); max-width: calc(100% - 40px);
} }
/* ===== MODERN SIDEBAR STYLING ===== */
/* Base Sidebar */
.modern-sidebar {
transition: width 0.3s ease;
border-right: 1px solid rgba(255, 255, 255, 0.1);
&.collapsed {
width: var(--sidebarCollapsedWidth) !important;
}
.sidebar-content {
display: flex;
flex-direction: column;
height: 100%;
padding: 0;
background-color: var(--BgColorSidebarDark);
}
}
/* Header Section */
.modern-sidebar .sidebar-header {
padding: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0;
}
.header-content-collapsed {
display: flex;
align-items: center;
flex-direction: column;
justify-content: space-between;
gap: 12px;
}
.logo-container {
display: flex;
align-items: center;
text-decoration: none;
color: #ffffff;
font-weight: 600;
font-size: 14px;
img {
margin-right: 8px;
width: 20px;
height: 20px;
}
.app-title {
white-space: nowrap;
overflow: hidden;
}
}
.logo-container-collapsed {
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
color: #ffffff;
margin-bottom: 6px;
img {
width: 20px;
height: 20px;
}
}
.collapse-section {
display: flex;
align-items: center;
gap: 8px;
}
.collapse-button {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
color: #ffffff;
padding: 6px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.3);
}
}
.chat-option-menu-container {
display: flex;
align-items: center;
}
.chat-option-menu-container-collapsed {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
color: #ffffff;
padding: 6px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.3);
}
}
}
/* Chat option menu when collapsed */
.modern-sidebar .chat-option-menu-container-collapsed .dropdown .dropdown-menu {
top: 100% !important;
bottom: auto !important;
left: 0 !important;
right: auto !important;
min-width: 150px;
}
/* Chat List */
.modern-sidebar .chat-list {
flex: 1;
overflow-y: auto;
padding: 0;
margin: 0;
&.collapsed {
padding: 0;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
padding: 20px;
text-align: center;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
margin: 0;
padding: 0;
}
.chat-menu-item {
display: block;
padding: 12px 16px;
color: rgba(255, 255, 255, 0.8);
text-decoration: none;
border-radius: 0;
transition: all 0.2s ease;
position: relative;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
&:hover {
background-color: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
&.is-active {
background-color: rgba(0, 102, 204, 0.2);
color: #ffffff;
border-left: 3px solid #0066cc;
}
.chat-item-name {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.chat-icon {
margin-right: 8px;
opacity: 0.7;
}
}
.delete-button,
.edit-button {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
display: none;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
}
.edit-button {
right: 32px;
}
&:hover {
.delete-button,
.edit-button {
display: block;
}
}
}
}
/* Footer */
.modern-sidebar .sidebar-footer {
padding: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
.footer-controls {
display: flex;
gap: 12px;
justify-content: space-between;
}
.footer-controls-collapsed {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.new-chat-section {
display: flex;
justify-content: center;
}
.new-chat-section-collapsed {
display: flex;
justify-content: center;
}
.new-chat-button {
width: auto;
display: flex;
align-items: center;
background: #0066cc;
border: none;
color: #ffffff;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
font-size: 13px;
font-weight: 400;
padding: 8px 12px;
justify-content: center;
&:hover {
background-color: #0052a3;
color: #ffffff;
}
span {
margin-left: 8px;
}
}
.control-button {
display: flex;
align-items: center;
padding: 8px 12px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.8);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 13px;
font-weight: 400;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.3);
color: #ffffff;
}
span {
margin-left: 8px;
}
}
.new-chat-button-collapsed {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: #0066cc;
border: none;
color: #ffffff;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
&:hover {
background-color: #0052a3;
color: #ffffff;
}
}
.control-button-collapsed {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.8);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.3);
color: #ffffff;
}
}
.sort-controls {
display: flex;
justify-content: center;
}
}
/* Dropdown Positioning */
.modern-sidebar .dropdown {
.dropdown-menu {
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
background-color: var(--BgColorSidebarDark);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
max-height: 60vh;
overflow-y: auto;
}
&.is-up .dropdown-menu {
bottom: 100%;
top: auto;
padding-top: 0;
padding-bottom: 4px;
}
&.is-right .dropdown-menu {
right: 0;
left: auto;
}
}
/* Fix dropdown positioning in header to prevent off-screen */
.modern-sidebar .sidebar-header .dropdown .dropdown-menu {
top: calc(100% + 8px) !important;
bottom: auto !important;
padding-top: 0;
padding-bottom: 0;
}
/* Ensure chat option menu dropdown is visible */
.modern-sidebar .chat-option-menu-container .dropdown .dropdown-menu {
top: 100% !important;
bottom: auto !important;
left: auto !important;
right: 0 !important;
min-width: 150px;
}
/* Footer dropdown positioning */
.modern-sidebar .sidebar-footer .dropdown .dropdown-menu {
bottom: 100% !important;
top: auto !important;
left: 50% !important;
right: auto !important;
transform: translateX(-50%);
min-width: 150px;
}
/* Layout Updates */
.side-bar-column.collapsed {
width: var(--sidebarCollapsedWidth);
}
/* Sidebar Override */
aside.menu.main-menu.modern-sidebar {
background: var(--BgColorSidebarDark);
box-shadow: none;
border-right: 1px solid rgba(255, 255, 255, 0.1);
.menu-expanse {
background-color: transparent;
box-shadow: none;
}
}
/* Collapsed Mode Adjustments */
.modern-sidebar.collapsed {
.chat-list {
overflow: hidden;
}
}

View File

@@ -43,7 +43,7 @@
import { getModelDetail } from './Models.svelte' import { getModelDetail } from './Models.svelte'
export let params = { chatId: '' } export let params = { chatId: '' }
const chatId: number = parseInt(params.chatId) const chatId: string = params.chatId
let chatRequest = new ChatRequest() let chatRequest = new ChatRequest()
let input: HTMLTextAreaElement let input: HTMLTextAreaElement
@@ -53,8 +53,8 @@
// Optimize chat lookup to avoid expensive find() on every chats update // Optimize chat lookup to avoid expensive find() on every chats update
let chat: Chat let chat: Chat
let chatSettings: ChatSettings let chatSettings: any
let showSettingsModal let showSettingsModal: any
// Only update chat when chatId changes or when the specific chat is updated // Only update chat when chatId changes or when the specific chat is updated
$: { $: {
@@ -105,7 +105,7 @@
$: afterChatLoad($currentChatId) $: afterChatLoad($currentChatId)
setCurrentChat(0) setCurrentChat('')
// Make sure chat object is ready to go // Make sure chat object is ready to go
updateChatSettings(chatId) updateChatSettings(chatId)
@@ -303,7 +303,7 @@
const userMessagesCount = chat.messages.filter(message => message.role === 'user').length const userMessagesCount = chat.messages.filter(message => message.role === 'user').length
const assiMessagesCount = chat.messages.filter(message => message.role === 'assistant').length const assiMessagesCount = chat.messages.filter(message => message.role === 'assistant').length
if (userMessagesCount == 3 && chat.name.startsWith('Chat ')) { if (userMessagesCount == 3 && chat.name.startsWith('New Chat')) {
suggestName() suggestName()
} }
@@ -392,7 +392,7 @@
<div class="level-left"> <div class="level-left">
<div class="level-item"> <div class="level-item">
<p class="subtitle is-5"> <p class="subtitle is-5">
<span>{chat.name || `Chat ${chat.id}`}</span> <span>{chat.name || `New Chat`}</span>
<a href={'#'} class="greyscale ml-2 is-hidden has-text-weight-bold editbutton" title="Rename chat" on:click|preventDefault={promptRename}><Fa icon={faPenToSquare} /></a> <a href={'#'} class="greyscale ml-2 is-hidden has-text-weight-bold editbutton" title="Rename chat" on:click|preventDefault={promptRename}><Fa icon={faPenToSquare} /></a>
<a href={'#'} class="greyscale ml-2 is-hidden has-text-weight-bold editbutton" title="Suggest a chat name" on:click|preventDefault={suggestName}><Fa icon={faLightbulb} /></a> <a href={'#'} class="greyscale ml-2 is-hidden has-text-weight-bold editbutton" title="Suggest a chat name" on:click|preventDefault={suggestName}><Fa icon={faLightbulb} /></a>
<!-- <a href={'#'} class="greyscale ml-2 is-hidden has-text-weight-bold editbutton" title="Copy this chat" on:click|preventDefault={() => { copyChat(chatId) }}><Fa icon={faClone} /></a> --> <!-- <a href={'#'} class="greyscale ml-2 is-hidden has-text-weight-bold editbutton" title="Copy this chat" on:click|preventDefault={() => { copyChat(chatId) }}><Fa icon={faClone} /></a> -->
@@ -410,7 +410,7 @@
<Messages messages={$currentChatMessages} chatId={chatId} chat={chat} /> <Messages messages={$currentChatMessages} chatId={chatId} chat={chat} />
{#if chatRequest.updating === true || $currentChatId === 0} {#if chatRequest.updating === true || !$currentChatId}
<article class="message is-success assistant-message"> <article class="message is-success assistant-message">
<div class="message-body content"> <div class="message-body content">
<span class="is-loading" ></span> <span class="is-loading" ></span>
@@ -419,7 +419,7 @@
</article> </article>
{/if} {/if}
{#if $currentChatId !== 0 && ($currentChatMessages.length === 0 || ($currentChatMessages.length === 1 && $currentChatMessages[0].role === 'system'))} {#if $currentChatId && ($currentChatMessages.length === 0 || ($currentChatMessages.length === 1 && $currentChatMessages[0].role === 'system'))}
<Prompts bind:input /> <Prompts bind:input />
{/if} {/if}
</div> </div>

View File

@@ -95,7 +95,7 @@
<a class="is-pulled-right is-hidden px-1 py-0 has-text-weight-bold edit-button" href={'$'} on:click|preventDefault={() => edit()}><Fa icon={faPencil} /></a> <a class="is-pulled-right is-hidden px-1 py-0 has-text-weight-bold edit-button" href={'$'} on:click|preventDefault={() => edit()}><Fa icon={faPencil} /></a>
<a class="is-pulled-right is-hidden px-1 py-0 has-text-weight-bold delete-button" href={'$'} on:click|preventDefault={() => delChat()}><Fa icon={faTrash} /></a> <a class="is-pulled-right is-hidden px-1 py-0 has-text-weight-bold delete-button" href={'$'} on:click|preventDefault={() => delChat()}><Fa icon={faTrash} /></a>
{/if} {/if}
<span class="chat-item-name"><Fa class="mr-2 chat-icon" size="xs" icon="{faMessage}"/>{chat.name || `Chat ${chat.id}`}</span> <span class="chat-item-name"><Fa class="mr-2 chat-icon" size="xs" icon="{faMessage}"/>{chat.name || `New Chat`}</span>
</a> </a>
{/if} {/if}
</li> </li>

View File

@@ -28,6 +28,7 @@
import { startNewChatWithWarning, startNewChatFromChatId, errorNotice, encodeHTMLEntities } from './Util.svelte' import { startNewChatWithWarning, startNewChatFromChatId, errorNotice, encodeHTMLEntities } from './Util.svelte'
import type { ChatSettings } from './Types.svelte' import type { ChatSettings } from './Types.svelte'
import { hasActiveModels } from './Models.svelte' import { hasActiveModels } from './Models.svelte'
import { sidebarCollapsed } from './Sidebar.svelte'
export let chatId export let chatId
export const show = (showHide:boolean = true) => { export const show = (showHide:boolean = true) => {
@@ -228,12 +229,19 @@
<div class="dropdown {style}" class:is-active={showChatMenu} use:clickOutside={() => { showChatMenu = false }}> <div class="dropdown {style}" class:is-active={showChatMenu} use:clickOutside={() => { showChatMenu = false }}>
<div class="dropdown-trigger"> <div class="dropdown-trigger">
{#if !sidebarCollapsed }
<button class="button is-ghost default-text" aria-haspopup="true" <button class="button is-ghost default-text" aria-haspopup="true"
aria-controls="dropdown-menu3" aria-controls="dropdown-menu3"
on:click|preventDefault|stopPropagation={() => { showChatMenu = !showChatMenu }} on:click|preventDefault|stopPropagation={() => { showChatMenu = !showChatMenu }}
> >
<span class="icon "><Fa icon={faEllipsis}/></span> <span class="icon "><Fa icon={faEllipsis}/></span>
</button> </button>
{:else}
<div class="icon" aria-controls="dropdown-menu3"
on:click|preventDefault|stopPropagation={() => { showChatMenu = !showChatMenu }}
>
<Fa icon={faEllipsis}/></div>
{/if}
</div> </div>
<div class="dropdown-menu" id="dropdown-menu3" role="menu"> <div class="dropdown-menu" id="dropdown-menu3" role="menu">
<div class="dropdown-content"> <div class="dropdown-content">

View File

@@ -37,7 +37,7 @@
import { getChatModelOptions, getImageModelOptions } from './Models.svelte' import { getChatModelOptions, getImageModelOptions } from './Models.svelte'
import { faClipboard } from '@fortawesome/free-regular-svg-icons' import { faClipboard } from '@fortawesome/free-regular-svg-icons'
export let chatId:number export let chatId:string
export const show = () => { showSettings() } export const show = () => { showSettings() }
let showSettingsModal = 0 let showSettingsModal = 0

View File

@@ -16,7 +16,7 @@
import renderMathInElement from 'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/contrib/auto-render.mjs' import renderMathInElement from 'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/contrib/auto-render.mjs'
export let message:Message export let message:Message
export let chatId:number export let chatId:string
export let chat:Chat export let chat:Chat
$: chatSettings = chat.settings $: chatSettings = chat.settings

View File

@@ -17,11 +17,11 @@ onMount(() => {
// console.log('started', apiKey, $lastChatId, getChat($lastChatId)) // console.log('started', apiKey, $lastChatId, getChat($lastChatId))
if (hasActiveModels() && getChat($lastChatId)) { if (hasActiveModels() && getChat($lastChatId)) {
const chatId = $lastChatId const chatId = $lastChatId
$lastChatId = 0 $lastChatId = ''
replace(`/chat/${chatId}`) replace(`/chat/${chatId}`)
} }
} }
$lastChatId = 0 $lastChatId = ''
}) })
afterUpdate(() => { afterUpdate(() => {

View File

@@ -5,7 +5,7 @@
import EditMessage from './EditMessage.svelte' import EditMessage from './EditMessage.svelte'
export let messages : Message[] export let messages : Message[]
export let chatId: number export let chatId: string
export let chat: Chat export let chat: Chat
$: chatSettings = chat.settings $: chatSettings = chat.settings

View File

@@ -1,17 +1,16 @@
<script lang="ts"> <script lang="ts">
import { params } from 'svelte-spa-router' import { params } from 'svelte-spa-router'
import { pinMainMenu } from './Storage.svelte' import { pinMainMenu } from './Storage.svelte'
import logo from '../assets/logo.svg'
import ChatOptionMenu from './ChatOptionMenu.svelte' import ChatOptionMenu from './ChatOptionMenu.svelte'
import logo from '../assets/logo.svg'
import Fa from 'svelte-fa/src/fa.svelte' import Fa from 'svelte-fa/src/fa.svelte'
import { faBars, faXmark } from '@fortawesome/free-solid-svg-icons/index' import { faBars, faXmark } from '@fortawesome/free-solid-svg-icons/index'
$: activeChatId = $params && $params.chatId ? parseInt($params.chatId) : undefined
$: activeChatId = $params && $params.chatId ? parseInt($params.chatId) : undefined
</script> </script>
<nav class="navbar is-fixed-top" aria-label="main navigation"> <nav class="navbar is-fixed-top" aria-label="main navigation">
<div class="navbar-brand"> <div class="navbar-brand">
<div class="navbar-item"> <div class="navbar-item uncollapse-menu">
{#if $pinMainMenu} {#if $pinMainMenu}
<button class="button" on:click|stopPropagation={() => { $pinMainMenu = false }}> <button class="button" on:click|stopPropagation={() => { $pinMainMenu = false }}>
@@ -27,10 +26,6 @@ $: activeChatId = $params && $params.chatId ? parseInt($params.chatId) : undefin
</button> </button>
{/if} {/if}
</div> </div>
<a class="navbar-item" href={'#/'}>
<img src={logo} alt="ChatGPT-web" width="24" height="24" />
<p class="ml-2 is-size-6 has-text-weight-bold">ChatGPT-web</p>
</a>
<div class="chat-option-menu navbar-item is-pulled-right"> <div class="chat-option-menu navbar-item is-pulled-right">
<ChatOptionMenu bind:chatId={activeChatId} /> <ChatOptionMenu bind:chatId={activeChatId} />
</div> </div>

View File

@@ -83,19 +83,19 @@ export const cleanContent = (settings: ChatSettings, content: string|undefined):
return (content || '').replace(/::NOTE::[\s\S]*?::NOTE::\s*/g, '') return (content || '').replace(/::NOTE::[\s\S]*?::NOTE::\s*/g, '')
} }
export const prepareProfilePrompt = (chatId:number) => { export const prepareProfilePrompt = (chatId:string) => {
const settings = getChatSettings(chatId) const settings = getChatSettings(chatId)
return mergeProfileFields(settings, settings.systemPrompt).trim() return mergeProfileFields(settings, settings.systemPrompt).trim()
} }
export const prepareSummaryPrompt = (chatId:number, maxTokens:number) => { export const prepareSummaryPrompt = (chatId:string, maxTokens:number) => {
const settings = getChatSettings(chatId) const settings = getChatSettings(chatId)
const currentSummaryPrompt = settings.summaryPrompt const currentSummaryPrompt = settings.summaryPrompt
// ~.75 words per token. We'll use 0.70 for a little extra margin. // ~.75 words per token. We'll use 0.70 for a little extra margin.
return mergeProfileFields(settings, currentSummaryPrompt, Math.floor(maxTokens * 0.70)).trim() return mergeProfileFields(settings, currentSummaryPrompt, Math.floor(maxTokens * 0.70)).trim()
} }
export const setSystemPrompt = (chatId: number) => { export const setSystemPrompt = (chatId: string) => {
const messages = getMessages(chatId) const messages = getMessages(chatId)
const systemPromptMessage:Message = { const systemPromptMessage:Message = {
role: 'system', role: 'system',
@@ -108,7 +108,7 @@ export const setSystemPrompt = (chatId: number) => {
} }
// Restart currently loaded profile // Restart currently loaded profile
export const restartProfile = (chatId:number, noApply:boolean = false) => { export const restartProfile = (chatId:string, noApply:boolean = false) => {
const settings = getChatSettings(chatId) const settings = getChatSettings(chatId)
if (!settings.profile && !noApply) return applyProfile(chatId, '', true) if (!settings.profile && !noApply) return applyProfile(chatId, '', true)
// Clear current messages // Clear current messages
@@ -135,7 +135,7 @@ export const newNameForProfile = (name:string) => {
} }
// Apply currently selected profile // Apply currently selected profile
export const applyProfile = (chatId:number, key:string = '', resetChat:boolean = false) => { export const applyProfile = (chatId:string, key:string = '', resetChat:boolean = false) => {
resetChatSettings(chatId, resetChat) // Fully reset resetChatSettings(chatId, resetChat) // Fully reset
if (!resetChat) return if (!resetChat) return
return restartProfile(chatId, true) return restartProfile(chatId, true)

View File

@@ -63,7 +63,7 @@ export const getExcludeFromProfile = () => {
return excludeFromProfile return excludeFromProfile
} }
const hideModelSetting = (chatId: number, setting: ChatSetting) => { const hideModelSetting = (chatId: string, setting: ChatSetting) => {
return getModelDetail(getChatSettings(chatId).model).hideSetting(chatId, setting) return getModelDetail(getChatSettings(chatId).model).hideSetting(chatId, setting)
} }
@@ -142,9 +142,9 @@ const excludeFromProfile = {
export const chatSortOptions = { export const chatSortOptions = {
name: { text: 'Name', icon: faArrowDownAZ, value: '', sortFn: (a, b) => { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0 } }, name: { text: 'Name', icon: faArrowDownAZ, value: '', sortFn: (a, b) => { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0 } },
created: { text: 'Created', icon: faArrowDown91, value: '', sortFn: (a, b) => { return ((b.created || 0) - (a.created || 0)) || (b.id - a.id) } }, created: { text: 'Created', icon: faArrowDown91, value: '', sortFn: (a, b) => { return ((b.created || 0) - (a.created || 0)) || a.id.localeCompare(b.id) } },
lastUse: { text: 'Last Use', icon: faArrowDown91, value: '', sortFn: (a, b) => { return ((b.lastUse || 0) - (a.lastUse || 0)) || (b.id - a.id) } }, lastUse: { text: 'Last Use', icon: faArrowDown91, value: '', sortFn: (a, b) => { return ((b.lastUse || 0) - (a.lastUse || 0)) || a.id.localeCompare(b.id) } },
lastAccess: { text: 'Last View', icon: faArrowDown91, value: '', sortFn: (a, b) => { return ((b.lastAccess || 0) - (a.lastAccess || 0)) || (b.id - a.id) } } lastAccess: { text: 'Last View', icon: faArrowDown91, value: '', sortFn: (a, b) => { return ((b.lastAccess || 0) - (a.lastAccess || 0)) || a.id.localeCompare(b.id) } }
} as Record<string, ChatSortOption> } as Record<string, ChatSortOption>
Object.entries(chatSortOptions).forEach(([k, o]) => { o.value = k }) Object.entries(chatSortOptions).forEach(([k, o]) => { o.value = k })

View File

@@ -1,9 +1,16 @@
<script context="module" lang="ts">
import { writable } from 'svelte/store'
// Export sidebar collapse state so other components can react to it
export const sidebarCollapsed = writable(false)
</script>
<script lang="ts"> <script lang="ts">
import { params } from 'svelte-spa-router' import { params } from 'svelte-spa-router'
import ChatMenuItem from './ChatMenuItem.svelte' import ChatMenuItem from './ChatMenuItem.svelte'
import { chatsStorage, pinMainMenu, checkStateChange, getChatSortOption, setChatSortOption } from './Storage.svelte' import { chatsStorage, pinMainMenu, checkStateChange, getChatSortOption, setChatSortOption } from './Storage.svelte'
import Fa from 'svelte-fa/src/fa.svelte' import Fa from 'svelte-fa/src/fa.svelte'
import { faSquarePlus, faKey, faDownload, faRotate, faUpload } from '@fortawesome/free-solid-svg-icons/index' import { faSquarePlus, faKey, faBars, faAngleLeft, faAngleRight } from '@fortawesome/free-solid-svg-icons/index'
import ChatOptionMenu from './ChatOptionMenu.svelte' import ChatOptionMenu from './ChatOptionMenu.svelte'
import logo from '../assets/logo.svg' import logo from '../assets/logo.svg'
import { clickOutside } from 'svelte-use-click-outside' import { clickOutside } from 'svelte-use-click-outside'
@@ -17,7 +24,7 @@
let lastSortOption: any = null let lastSortOption: any = null
let lastChatsLength = 0 let lastChatsLength = 0
$: activeChatId = $params && $params.chatId ? parseInt($params.chatId) : undefined $: activeChatId = $params && $params.chatId ? $params.chatId : undefined
let sortOption = getChatSortOption() let sortOption = getChatSortOption()
let hasModels = hasActiveModels() let hasModels = hasActiveModels()
@@ -43,6 +50,17 @@
$: onStateChange($checkStateChange) $: onStateChange($checkStateChange)
let showSortMenu = false let showSortMenu = false
let isCollapsed = false
const toggleSidebar = () => {
isCollapsed = !isCollapsed
sidebarCollapsed.set(isCollapsed)
}
// Subscribe to the store to keep local state in sync
sidebarCollapsed.subscribe(value => {
isCollapsed = value
})
async function uploadLocalStorage (uid = 19492) { async function uploadLocalStorage (uid = 19492) {
try { try {
@@ -166,74 +184,140 @@
// setInterval(syncLocalStorage, 10000); // setInterval(syncLocalStorage, 10000);
</script> </script>
<aside class="menu main-menu" class:pinned={$pinMainMenu} use:clickOutside={() => { $pinMainMenu = false }}> <aside class="menu main-menu modern-sidebar" class:collapsed={isCollapsed} class:pinned={$pinMainMenu} use:clickOutside={() => { $pinMainMenu = false }}>
<div style="font-size:8px;position:fixed;top:1px;right:2px;">&&&BUILDVER&&&</div> <div class="sidebar-content">
<div class="menu-expanse"> <!-- Header with logo and collapse button -->
<div class="navbar-brand menu-nav-bar"> <div class="sidebar-header">
<a class="navbar-item gpt-logo" href={'#/'}> {#if !isCollapsed}
<div class="header-content">
<a class="logo-container" href={'#/'}>
<img src={logo} alt="ChatGPT-web" width="24" height="24" /> <img src={logo} alt="ChatGPT-web" width="24" height="24" />
</a> </a>
<div class="chat-option-menu navbar-item is-pulled-right"> <div class="collapse-section">
<div class="chat-option-menu-container">
<ChatOptionMenu bind:chatId={activeChatId} />
</div>
<button class="collapse-button" on:click={toggleSidebar} title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}>
<Fa icon={isCollapsed ? faAngleRight : faAngleLeft} />
</button>
</div>
</div>
{:else}
<div class="header-content-collapsed">
<a class="logo-container-collapsed" href={'#/'}>
<img src={logo} alt="ChatGPT-web" width="24" height="24" />
</a>
<div class="collapse-section">
<button class="collapse-button" on:click={toggleSidebar} title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}>
<Fa icon={isCollapsed ? faAngleRight : faAngleLeft} />
</button>
</div>
<div class="chat-option-menu-container-collapsed">
<ChatOptionMenu bind:chatId={activeChatId} /> <ChatOptionMenu bind:chatId={activeChatId} />
</div> </div>
</div> </div>
<ul class="menu-list menu-expansion-list">
{#if sortedChats.length === 0}
<li><a href={'#'} class="is-disabled">No chats yet...</a></li>
{:else}
{#key $checkStateChange}
{#each sortedChats as chat, i}
{#key chat.id}
<ChatMenuItem activeChatId={activeChatId} chat={chat} prevChat={sortedChats[i - 1]} nextChat={sortedChats[i + 1]} />
{/key}
{/each}
{/key}
{/if} {/if}
</ul> </div>
<!-- <p class="menu-label">Actions</p> -->
<div class="level is-mobile bottom-buttons p-1"> <!-- Chat list -->
<div class="level-left"> <div class="chat-list" class:collapsed={isCollapsed}>
<div class="dropdown is-left is-up" class:is-active={showSortMenu} use:clickOutside={() => { showSortMenu = false }}> {#if !isCollapsed}
<div class="dropdown-trigger"> {#if sortedChats.length === 0}
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu3" on:click|preventDefault|stopPropagation={() => { showSortMenu = !showSortMenu }}> <div class="empty-state">
<span class="icon"><Fa icon={sortOption.icon}/></span> <span>No chats yet...</span>
</button>
</div> </div>
<div class="dropdown-menu" id="dropdown-menu3" role="menu"> {:else}
<div class="dropdown-content"> {#key $checkStateChange}
{#each Object.values(chatSortOptions) as opt} {#each sortedChats as chat, i}
<a href={'#'} class="dropdown-item" class:is-active={sortOption === opt} on:click|preventDefault={() => { showSortMenu = false; setChatSortOption(opt.value) }}> {#key chat.id}
<span class="menu-icon"><Fa icon={opt.icon}/></span> <ChatMenuItem activeChatId={activeChatId} chat={chat} prevChat={sortedChats[i - 1]} nextChat={sortedChats[i + 1]} />
{opt.text} {/key}
{/each}
{/key}
{/if}
{/if}
</div>
<!-- Bottom controls -->
<div class="sidebar-footer" class:collapsed={isCollapsed}>
{#if !isCollapsed}
<div class="footer-controls">
<div class="new-chat-section">
{#if hasModels}
<button
class="new-chat-button"
on:click={() => { $pinMainMenu = false; startNewChatWithWarning(activeChatId) }}
title="Start new chat">
<Fa icon={faSquarePlus} />
<span>New Chat</span>
</button>
{:else}
<a href={'#/'} class="new-chat-button api-settings" title="Set up API key">
<Fa icon={faKey} />
<span>API Settings</span>
</a> </a>
{/each} {/if}
</div>
<div class="sort-controls">
<div class="dropdown is-up" class:is-active={showSortMenu} use:clickOutside={() => { showSortMenu = false }}>
<div class="dropdown-trigger">
<button class="control-button" on:click|preventDefault|stopPropagation={() => { showSortMenu = !showSortMenu }} title="Sort chats">
<Fa icon={sortOption.icon}/>
<span>Sort</span>
</button>
</div>
<div class="dropdown-menu" role="menu">
<div class="dropdown-content">
{#each Object.values(chatSortOptions) as opt}
<a href={'#'} class="dropdown-item" class:is-active={sortOption === opt} on:click|preventDefault={() => { showSortMenu = false; setChatSortOption(opt.value) }}>
<span class="menu-icon"><Fa icon={opt.icon}/></span>
{opt.text}
</a>
{/each}
</div>
</div>
</div> </div>
</div> </div>
</div>
<div class="is-left is-up ml-2">
<button class="button" aria-haspopup="true" on:click|preventDefault|stopPropagation={() => { loadLocalStorage() }}>
<span class="icon"><Fa icon={faUpload}/></span>
</button>
</div> </div>
<div class="is-left is-up ml-2"> {:else}
<button class="button" aria-haspopup="true" on:click|preventDefault|stopPropagation={() => { dumpLocalStorage() }}> <div class="footer-controls-collapsed">
<span class="icon"><Fa icon={faDownload}/></span> <div class="new-chat-section-collapsed">
</button> {#if hasModels}
</div> <button
</div> class="new-chat-button-collapsed"
<div class="level-right"> on:click={() => { $pinMainMenu = false; startNewChatWithWarning(activeChatId) }}
{#if !hasModels} title="Start new chat">
<div class="level-item"> <Fa icon={faSquarePlus} />
<a href={'#/'} class="panel-block" class:is-disabled={!hasModels} </button>
><span class="greyscale mr-1"><Fa icon={faKey} /></span> API Setting</a {:else}
></div> <a href={'#/'} class="new-chat-button-collapsed api-settings" title="Set up API key">
{:else} <Fa icon={faKey} />
<div class="level-item"> </a>
<button on:click={() => { $pinMainMenu = false; startNewChatWithWarning(activeChatId) }} class="panel-block button" title="Start new chat with default profile" class:is-disabled={!hasModels} {/if}
><span class="greyscale"><Fa icon={faSquarePlus} /></span></button>
</div> </div>
{/if} <div class="dropdown is-up is-right" class:is-active={showSortMenu} use:clickOutside={() => { showSortMenu = false }}>
</div> <div class="dropdown-trigger">
<button class="control-button-collapsed" on:click|preventDefault|stopPropagation={() => { showSortMenu = !showSortMenu }} title="Sort chats">
<Fa icon={sortOption.icon}/>
</button>
</div>
<div class="dropdown-menu" role="menu">
<div class="dropdown-content">
{#each Object.values(chatSortOptions) as opt}
<a href={'#'} class="dropdown-item" class:is-active={sortOption === opt} on:click|preventDefault={() => { showSortMenu = false; setChatSortOption(opt.value) }}>
<span class="menu-icon"><Fa icon={opt.icon}/></span>
{opt.text}
</a>
{/each}
</div>
</div>
</div>
</div>
{/if}
</div> </div>
</div> </div>
</aside> </aside>

View File

@@ -5,7 +5,7 @@
import { getChatSettingObjectByKey, getGlobalSettingObjectByKey, getChatDefaults, getExcludeFromProfile, chatSortOptions, globalDefaults } from './Settings.svelte' import { getChatSettingObjectByKey, getGlobalSettingObjectByKey, getChatDefaults, getExcludeFromProfile, chatSortOptions, globalDefaults } from './Settings.svelte'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { getProfile, getProfiles, isStaticProfile, newNameForProfile, restartProfile } from './Profiles.svelte' import { getProfile, getProfiles, isStaticProfile, newNameForProfile, restartProfile } from './Profiles.svelte'
import { errorNotice } from './Util.svelte' import { errorNotice, generateShortId } from './Util.svelte'
import { clearAllImages, deleteImage, setImage } from './ImageStore.svelte' import { clearAllImages, deleteImage, setImage } from './ImageStore.svelte'
// TODO: move chatsStorage to indexedDB with localStorage as a fallback for private browsing. // TODO: move chatsStorage to indexedDB with localStorage as a fallback for private browsing.
@@ -22,8 +22,8 @@
export let continueMessage = writable('') // export let continueMessage = writable('') //
export let currentChatMessages = writable([] as Message[]) export let currentChatMessages = writable([] as Message[])
export let started = writable(false) export let started = writable(false)
export let currentChatId = writable(0) export let currentChatId = writable('')
export let lastChatId = persisted('lastChatId', 0) export let lastChatId = persisted('lastChatId', '')
const chatDefaults = getChatDefaults() const chatDefaults = getChatDefaults()
@@ -31,25 +31,22 @@
return get(apiKeyStorage) return get(apiKeyStorage)
} }
export const newChatID = (): number => { export const newChatID = (): string => {
const chats = get(chatsStorage) return generateShortId()
const chatId = chats.reduce((maxId, chat) => Math.max(maxId, chat.id), 0) + 1
return chatId
} }
export const addChat = (profile:ChatSettings|undefined = undefined): number => { export const addChat = (profile:ChatSettings|undefined = undefined): string => {
const chats = get(chatsStorage) const chats = get(chatsStorage)
// Find the max chatId // Generate new short UUID
const chatId = newChatID() const chatId = newChatID()
profile = JSON.parse(JSON.stringify(profile || getProfile(''))) as ChatSettings profile = JSON.parse(JSON.stringify(profile || getProfile(''))) as ChatSettings
const nameMap = chats.reduce((a, chat) => { a[chat.name] = chat; return a }, {})
// Add a new chat // Add a new chat
chats.push({ chats.push({
id: chatId, id: chatId,
name: newName(`Chat ${chatId}`, nameMap), name: `New Chat`,
settings: profile, settings: profile,
messages: [], messages: [],
usage: {} as Record<Model, Usage>, usage: {} as Record<Model, Usage>,
@@ -65,7 +62,7 @@
return chatId return chatId
} }
export const addChatFromJSON = async (json: string): Promise<number> => { export const addChatFromJSON = async (json: string): Promise<string> => {
const chats = get(chatsStorage) const chats = get(chatsStorage)
// Find the max chatId // Find the max chatId
@@ -74,13 +71,13 @@
let chat: Chat let chat: Chat
try { try {
chat = JSON.parse(json) as Chat chat = JSON.parse(json) as Chat
if (!chat.settings || !chat.messages || isNaN(chat.id)) { if (!chat.settings || !chat.messages || !chat.id) {
errorNotice('Not valid Chat JSON') errorNotice('Not valid Chat JSON')
return 0 return ''
} }
} catch (err) { } catch (err) {
errorNotice("Can't parse file JSON") errorNotice("Can't parse file JSON")
return 0 return ''
} }
chat.id = chatId chat.id = chatId
@@ -99,7 +96,7 @@
} }
// Make sure a chat's settings are set with current values or defaults // Make sure a chat's settings are set with current values or defaults
export const updateChatSettings = (chatId:number) => { export const updateChatSettings = (chatId:string) => {
const chats = get(chatsStorage) const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat const chat = chats.find((chat) => chat.id === chatId) as Chat
if (!chat.settings) { if (!chat.settings) {
@@ -152,7 +149,7 @@
} }
// Reset all setting to current profile defaults // Reset all setting to current profile defaults
export const resetChatSettings = (chatId, resetAll:boolean = false) => { export const resetChatSettings = (chatId: string, resetAll:boolean = false) => {
const chats = get(chatsStorage) const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat const chat = chats.find((chat) => chat.id === chatId) as Chat
const profile = getProfile(chat.settings.profile) const profile = getProfile(chat.settings.profile)
@@ -179,17 +176,17 @@
chatsStorage.set(chats) chatsStorage.set(chats)
} }
export const getChat = (chatId: number):Chat => { export const getChat = (chatId: string):Chat => {
const chats = get(chatsStorage) const chats = get(chatsStorage)
return chats.find((chat) => chat.id === chatId) as Chat return chats.find((chat) => chat.id === chatId) as Chat
} }
export const getChatSettings = (chatId: number):ChatSettings => { export const getChatSettings = (chatId: string):ChatSettings => {
const chats = get(chatsStorage) const chats = get(chatsStorage)
return (chats.find((chat) => chat.id === chatId) as Chat).settings return (chats.find((chat) => chat.id === chatId) as Chat).settings
} }
export const updateRunningTotal = (chatId: number, usage: Usage, model:Model) => { export const updateRunningTotal = (chatId: string, usage: Usage, model:Model) => {
const chats = get(chatsStorage) const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat const chat = chats.find((chat) => chat.id === chatId) as Chat
let total:Usage = chat.usage[model] let total:Usage = chat.usage[model]
@@ -207,7 +204,7 @@
chatsStorage.set(chats) chatsStorage.set(chats)
} }
export const subtractRunningTotal = (chatId: number, usage: Usage, model:Model) => { export const subtractRunningTotal = (chatId: string, usage: Usage, model:Model) => {
const chats = get(chatsStorage) const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat const chat = chats.find((chat) => chat.id === chatId) as Chat
let total:Usage = chat.usage[model] let total:Usage = chat.usage[model]
@@ -225,17 +222,17 @@
chatsStorage.set(chats) chatsStorage.set(chats)
} }
export const getMessages = (chatId: number): Message[] => { export const getMessages = (chatId: string): Message[] => {
if (get(currentChatId) === chatId) return get(currentChatMessages) if (get(currentChatId) === chatId) return get(currentChatMessages)
return getChat(chatId).messages return getChat(chatId).messages
} }
let setChatTimer: any let setChatTimer: any
export const setCurrentChat = (chatId: number) => { export const setCurrentChat = (chatId: string) => {
clearTimeout(setChatTimer) clearTimeout(setChatTimer)
if (!chatId) { if (!chatId) {
currentChatId.set(0) currentChatId.set('')
lastChatId.set(0) lastChatId.set('')
currentChatMessages.set([]) currentChatMessages.set([])
return return
} }
@@ -246,8 +243,8 @@
}, 10) }, 10)
} }
const signalChangeTimers = new Map<number, any>() const signalChangeTimers = new Map<string, any>()
const setChatLastUse = (chatId: number, time: number) => { const setChatLastUse = (chatId: string, time: number) => {
const existingTimer = signalChangeTimers.get(chatId) const existingTimer = signalChangeTimers.get(chatId)
if (existingTimer) { if (existingTimer) {
clearTimeout(existingTimer) clearTimeout(existingTimer)
@@ -260,8 +257,8 @@
signalChangeTimers.set(chatId, timer) signalChangeTimers.set(chatId, timer)
} }
const setMessagesTimers = new Map<number, any>() const setMessagesTimers = new Map<string, any>()
export const setMessages = (chatId: number, messages: Message[]) => { export const setMessages = (chatId: string, messages: Message[]) => {
if (get(currentChatId) === chatId) { if (get(currentChatId) === chatId) {
// update current message cache right away // update current message cache right away
currentChatMessages.set(messages) currentChatMessages.set(messages)
@@ -289,7 +286,7 @@
} }
} }
export const updateMessages = (chatId: number) => { export const updateMessages = (chatId: string) => {
setMessages(chatId, getMessages(chatId)) setMessages(chatId, getMessages(chatId))
} }
@@ -311,11 +308,11 @@
setMessagesTimers.clear() setMessagesTimers.clear()
} }
export const addError = (chatId: number, error: string) => { export const addError = (chatId: string, error: string) => {
addMessage(chatId, { content: error } as Message) addMessage(chatId, { content: error } as Message)
} }
export const addMessage = (chatId: number, message: Message) => { export const addMessage = (chatId: string, message: Message) => {
const messages = getMessages(chatId) const messages = getMessages(chatId)
if (!message.uuid) message.uuid = uuidv4() if (!message.uuid) message.uuid = uuidv4()
if (!message.created) message.created = Date.now() if (!message.created) message.created = Date.now()
@@ -326,11 +323,11 @@
setMessages(chatId, messages) setMessages(chatId, messages)
} }
export const getMessage = (chatId: number, uuid:string):Message|undefined => { export const getMessage = (chatId: string, uuid:string):Message|undefined => {
return getMessages(chatId).find((m) => m.uuid === uuid) return getMessages(chatId).find((m) => m.uuid === uuid)
} }
export const insertMessages = (chatId: number, insertAfter: Message, newMessages: Message[]) => { export const insertMessages = (chatId: string, insertAfter: Message, newMessages: Message[]) => {
const messages = getMessages(chatId) const messages = getMessages(chatId)
const index = messages.findIndex((m) => m.uuid === insertAfter.uuid) const index = messages.findIndex((m) => m.uuid === insertAfter.uuid)
if (index === undefined || index < 0) { if (index === undefined || index < 0) {
@@ -345,7 +342,7 @@
setMessages(chatId, messages.filter(m => true)) setMessages(chatId, messages.filter(m => true))
} }
export const deleteSummaryMessage = (chatId: number, uuid: string) => { export const deleteSummaryMessage = (chatId: string, uuid: string) => {
const message = getMessage(chatId, uuid) const message = getMessage(chatId, uuid)
if (message && message.summarized) throw new Error('Unable to delete summarized message') if (message && message.summarized) throw new Error('Unable to delete summarized message')
if (message && message.summary) { // messages we summarized if (message && message.summary) { // messages we summarized
@@ -361,7 +358,7 @@
deleteMessage(chatId, uuid) deleteMessage(chatId, uuid)
} }
export const deleteMessage = (chatId: number, uuid: string) => { export const deleteMessage = (chatId: string, uuid: string) => {
const messages = getMessages(chatId) const messages = getMessages(chatId)
const index = messages.findIndex((m) => m.uuid === uuid) const index = messages.findIndex((m) => m.uuid === uuid)
const message = getMessage(chatId, uuid) const message = getMessage(chatId, uuid)
@@ -379,13 +376,13 @@
setMessages(chatId, messages.filter(m => true)) setMessages(chatId, messages.filter(m => true))
} }
const clearImages = (chatId: number, messages: Message[]) => { const clearImages = (chatId: string, messages: Message[]) => {
messages.forEach(m => { messages.forEach(m => {
if (m.image) deleteImage(chatId, m.image.id) if (m.image) deleteImage(chatId, m.image.id)
}) })
} }
export const truncateFromMessage = (chatId: number, uuid: string) => { export const truncateFromMessage = (chatId: string, uuid: string) => {
const messages = getMessages(chatId) const messages = getMessages(chatId)
const index = messages.findIndex((m) => m.uuid === uuid) const index = messages.findIndex((m) => m.uuid === uuid)
const message = getMessage(chatId, uuid) const message = getMessage(chatId, uuid)
@@ -398,18 +395,18 @@
setMessages(chatId, messages.filter(m => true)) setMessages(chatId, messages.filter(m => true))
} }
export const clearMessages = (chatId: number) => { export const clearMessages = (chatId: string) => {
clearImages(chatId, getMessages(chatId)) clearImages(chatId, getMessages(chatId))
setMessages(chatId, []) setMessages(chatId, [])
} }
export const deleteChat = (chatId: number) => { export const deleteChat = (chatId: string) => {
const chats = get(chatsStorage) const chats = get(chatsStorage)
clearImages(chatId, getMessages(chatId) || []) clearImages(chatId, getMessages(chatId) || [])
chatsStorage.set(chats.filter((chat) => chat.id !== chatId)) chatsStorage.set(chats.filter((chat) => chat.id !== chatId))
} }
export const updateChatImages = async (chatId: number, chat: Chat) => { export const updateChatImages = async (chatId: string, chat: Chat) => {
const messages = chat.messages const messages = chat.messages
for (let i = 0; i < messages.length; i++) { for (let i = 0; i < messages.length; i++) {
const m = messages[i] const m = messages[i]
@@ -417,7 +414,7 @@
} }
} }
export const copyChat = async (chatId: number) => { export const copyChat = async (chatId: string) => {
const chats = get(chatsStorage) const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat const chat = chats.find((chat) => chat.id === chatId) as Chat
const nameMap = chats.reduce((a, chat) => { a[chat.name] = chat; return a }, {}) const nameMap = chats.reduce((a, chat) => { a[chat.name] = chat; return a }, {})
@@ -454,7 +451,7 @@
} }
} }
export const setChatSettingValueByKey = (chatId: number, key: keyof ChatSettings, value) => { export const setChatSettingValueByKey = (chatId: string, key: keyof ChatSettings, value) => {
const setting = getChatSettingObjectByKey(key) const setting = getChatSettingObjectByKey(key)
if (setting) return setChatSettingValue(chatId, setting, value) if (setting) return setChatSettingValue(chatId, setting, value)
if (!(key in chatDefaults)) throw new Error('Invalid chat setting: ' + key) if (!(key in chatDefaults)) throw new Error('Invalid chat setting: ' + key)
@@ -469,7 +466,7 @@
settings[key] = cleanSettingValue(typeof d, value) settings[key] = cleanSettingValue(typeof d, value)
} }
export const setChatSettingValue = (chatId: number, setting: ChatSetting, value) => { export const setChatSettingValue = (chatId: string, setting: ChatSetting, value) => {
const chats = get(chatsStorage) const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat const chat = chats.find((chat) => chat.id === chatId) as Chat
let settings = chat.settings as any let settings = chat.settings as any
@@ -481,7 +478,7 @@
chatsStorage.set(chats) chatsStorage.set(chats)
} }
export const getChatSettingValueNullDefault = (chatId: number, setting: ChatSetting):any => { export const getChatSettingValueNullDefault = (chatId: string, setting: ChatSetting):any => {
const chats = get(chatsStorage) const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat const chat = chats.find((chat) => chat.id === chatId) as Chat
let value = chat.settings && chat.settings[setting.key] let value = chat.settings && chat.settings[setting.key]
@@ -515,7 +512,7 @@
return store.profiles || {} return store.profiles || {}
} }
export const deleteCustomProfile = (chatId:number, profileId:string) => { export const deleteCustomProfile = (chatId:string, profileId:string) => {
if (isStaticProfile(profileId)) { if (isStaticProfile(profileId)) {
throw new Error('Sorry, you can\'t delete a static profile.') throw new Error('Sorry, you can\'t delete a static profile.')
} }

View File

@@ -101,7 +101,7 @@ export type ChatSettings = {
} & Request; } & Request;
export type Chat = { export type Chat = {
id: number; id: string;
name: string; name: string;
messages: Message[]; messages: Message[];
usage: Record<Model, Usage>; usage: Record<Model, Usage>;

View File

@@ -6,6 +6,17 @@
import { replace } from 'svelte-spa-router' import { replace } from 'svelte-spa-router'
// import PromptConfirm from './PromptConfirm.svelte' // import PromptConfirm from './PromptConfirm.svelte'
import type { ChatSettings } from './Types.svelte' import type { ChatSettings } from './Types.svelte'
// Generate a short UUID (8 characters) for chat IDs using hex format (0-9a-f)
export const generateShortId = (): string => {
const chars = '0123456789abcdef'
let result = ''
for (let i = 0; i < 8; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
// Cache for auto-size elements to avoid expensive DOM queries // Cache for auto-size elements to avoid expensive DOM queries
let cachedAutoSizeElements: HTMLTextAreaElement[] = [] let cachedAutoSizeElements: HTMLTextAreaElement[] = []
let lastElementCount = 0 let lastElementCount = 0
@@ -137,13 +148,13 @@
}) })
} }
export const startNewChatFromChatId = (chatId: number) => { export const startNewChatFromChatId = (chatId: string) => {
const newChatId = addChat(getChat(chatId).settings) const newChatId = addChat(getChat(chatId).settings)
// go to new chat // go to new chat
replace(`/chat/${newChatId}`) replace(`/chat/${newChatId}`)
} }
export const startNewChatWithWarning = (activeChatId: number|undefined, profile?: ChatSettings|undefined) => { export const startNewChatWithWarning = (activeChatId: string|undefined, profile?: ChatSettings|undefined) => {
const newChat = () => { const newChat = () => {
const chatId = addChat(profile) const chatId = addChat(profile)
replace(`/chat/${chatId}`) replace(`/chat/${chatId}`)
@@ -164,11 +175,64 @@
newChat() newChat()
} }
export const valueOf = (chatId: number, value: any) => { export const valueOf = (chatId: string, value: any) => {
if (typeof value === 'function') return value(chatId) if (typeof value === 'function') return value(chatId)
return value return value
} }
// Migration function to convert old numeric chat IDs to hex UUIDs
export const migrateChatData = () => {
try {
const chatsDataString = localStorage.getItem('chats')
if (!chatsDataString) {
console.log('No chat data found to migrate')
return false
}
const chatsData = JSON.parse(chatsDataString)
if (!Array.isArray(chatsData) || chatsData.length === 0) {
console.log('No chats to migrate')
return false
}
let migratedCount = 0
const migrationMap = new Map() // old ID -> new ID mapping
// First pass: identify chats with numeric IDs and create new UUIDs
chatsData.forEach(chat => {
if (typeof chat.id === 'number') {
const newId = generateShortId()
migrationMap.set(chat.id, newId)
chat.id = newId
migratedCount++
}
})
if (migratedCount === 0) {
console.log('No numeric chat IDs found to migrate')
return false
}
// Update lastChatId if it was numeric
const lastChatIdString = localStorage.getItem('lastChatId')
if (lastChatIdString) {
const lastChatId = JSON.parse(lastChatIdString)
if (typeof lastChatId === 'number' && migrationMap.has(lastChatId)) {
localStorage.setItem('lastChatId', JSON.stringify(migrationMap.get(lastChatId)))
}
}
// Save migrated data back to localStorage
localStorage.setItem('chats', JSON.stringify(chatsData))
console.log(`Successfully migrated ${migratedCount} chats from numeric IDs to hex UUIDs`)
return true
} catch (error) {
console.error('Error during chat data migration:', error)
return false
}
}
export const escapeRegex = (string: string): string => { export const escapeRegex = (string: string): string => {
return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&') return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&')
} }