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 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 Chat from './lib/Chat.svelte'
import NewChat from './lib/NewChat.svelte'
import { chatsStorage } from './lib/Storage.svelte'
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 { 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
// Example: https://niek.github.io/chatgpt-web/#/?key=sk-...
const urlParams: URLSearchParams = new URLSearchParams($querystring)
@@ -34,7 +37,7 @@
'/chat/:chatId': wrap({
component: Chat,
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>
<Navbar />
<div class="side-bar-column">
<div class="side-bar-column" class:collapsed={$sidebarCollapsed}>
<Sidebar />
</div>
<div class="main-content-column" id="content">
<div class="main-content-column" class:collapsed={$sidebarCollapsed} id="content">
{#key $location}
<Router {routes} on:conditionsFailed={() => replace('/')}/>
{/key}

View File

@@ -96,7 +96,9 @@ html {
/* Sizes */
--sidebarTop: 0px;
--sidebarWidth: max(300px, 20%);
--sidebarCollapsedWidth: 60px;
--mainContentWidth: calc(100% - var(--sidebarWidth));
--mainContentWidthCollapsed: calc(100% - var(--sidebarCollapsedWidth));
--sectionPaddingTop: 0px;
@@ -579,13 +581,17 @@ $modal-background-background-color-dark: rgba($dark, 0.86) !default; // remove t
width: var(--mainContentWidth);
padding-top: var(--sectionPaddingTop);
position: relative;
transition: width 0.3s ease;
}
.main-content-column.collapsed {
width: var(--mainContentWidthCollapsed);
}
aside.menu.main-menu {
z-index:50;
position: fixed;
width: var(--sidebarWidth);
padding-right: 20px;
top: var(--sidebarTop);
bottom:0px;
}
@@ -681,6 +687,12 @@ aside.menu.main-menu .menu-expanse {
position: fixed;
bottom: 0px;
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 {
@@ -688,6 +700,7 @@ aside.menu.main-menu .menu-expanse {
position: fixed;
bottom: 0px;
width: var(--mainContentWidth);
transition: width 0.3s ease;
padding:
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) {
.prompt-input-container {
.control.send .button {
@@ -744,6 +762,10 @@ aside.menu.main-menu .menu-expanse {
margin-left: 8px;
}
.navbar .uncollapse-menu .button {
border: none;
background-color: transparent;
}
.main-menu .menu-nav-bar .chat-option-menu {
padding-right: 2px;
}
@@ -938,3 +960,448 @@ aside.menu.main-menu .menu-expanse {
.modal.chat-settings .field-body {
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'
export let params = { chatId: '' }
const chatId: number = parseInt(params.chatId)
const chatId: string = params.chatId
let chatRequest = new ChatRequest()
let input: HTMLTextAreaElement
@@ -53,8 +53,8 @@
// Optimize chat lookup to avoid expensive find() on every chats update
let chat: Chat
let chatSettings: ChatSettings
let showSettingsModal
let chatSettings: any
let showSettingsModal: any
// Only update chat when chatId changes or when the specific chat is updated
$: {
@@ -105,7 +105,7 @@
$: afterChatLoad($currentChatId)
setCurrentChat(0)
setCurrentChat('')
// Make sure chat object is ready to go
updateChatSettings(chatId)
@@ -303,7 +303,7 @@
const userMessagesCount = chat.messages.filter(message => message.role === 'user').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()
}
@@ -392,7 +392,7 @@
<div class="level-left">
<div class="level-item">
<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="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> -->
@@ -410,7 +410,7 @@
<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">
<div class="message-body content">
<span class="is-loading" ></span>
@@ -419,7 +419,7 @@
</article>
{/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 />
{/if}
</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 delete-button" href={'$'} on:click|preventDefault={() => delChat()}><Fa icon={faTrash} /></a>
{/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>
{/if}
</li>

View File

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

View File

@@ -37,7 +37,7 @@
import { getChatModelOptions, getImageModelOptions } from './Models.svelte'
import { faClipboard } from '@fortawesome/free-regular-svg-icons'
export let chatId:number
export let chatId:string
export const show = () => { showSettings() }
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'
export let message:Message
export let chatId:number
export let chatId:string
export let chat:Chat
$: chatSettings = chat.settings

View File

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

View File

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

View File

@@ -1,17 +1,16 @@
<script lang="ts">
import { params } from 'svelte-spa-router'
import { pinMainMenu } from './Storage.svelte'
import logo from '../assets/logo.svg'
import ChatOptionMenu from './ChatOptionMenu.svelte'
import logo from '../assets/logo.svg'
import Fa from 'svelte-fa/src/fa.svelte'
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>
<nav class="navbar is-fixed-top" aria-label="main navigation">
<div class="navbar-brand">
<div class="navbar-item">
<div class="navbar-item uncollapse-menu">
{#if $pinMainMenu}
<button class="button" on:click|stopPropagation={() => { $pinMainMenu = false }}>
@@ -27,10 +26,6 @@ $: activeChatId = $params && $params.chatId ? parseInt($params.chatId) : undefin
</button>
{/if}
</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">
<ChatOptionMenu bind:chatId={activeChatId} />
</div>

View File

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

View File

@@ -63,7 +63,7 @@ export const getExcludeFromProfile = () => {
return excludeFromProfile
}
const hideModelSetting = (chatId: number, setting: ChatSetting) => {
const hideModelSetting = (chatId: string, setting: ChatSetting) => {
return getModelDetail(getChatSettings(chatId).model).hideSetting(chatId, setting)
}
@@ -142,9 +142,9 @@ const excludeFromProfile = {
export const chatSortOptions = {
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) } },
lastUse: { text: 'Last Use', icon: faArrowDown91, value: '', sortFn: (a, b) => { return ((b.lastUse || 0) - (a.lastUse || 0)) || (b.id - a.id) } },
lastAccess: { text: 'Last View', icon: faArrowDown91, value: '', sortFn: (a, b) => { return ((b.lastAccess || 0) - (a.lastAccess || 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)) || a.id.localeCompare(b.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>
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">
import { params } from 'svelte-spa-router'
import ChatMenuItem from './ChatMenuItem.svelte'
import { chatsStorage, pinMainMenu, checkStateChange, getChatSortOption, setChatSortOption } from './Storage.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 logo from '../assets/logo.svg'
import { clickOutside } from 'svelte-use-click-outside'
@@ -17,7 +24,7 @@
let lastSortOption: any = null
let lastChatsLength = 0
$: activeChatId = $params && $params.chatId ? parseInt($params.chatId) : undefined
$: activeChatId = $params && $params.chatId ? $params.chatId : undefined
let sortOption = getChatSortOption()
let hasModels = hasActiveModels()
@@ -43,6 +50,17 @@
$: onStateChange($checkStateChange)
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) {
try {
@@ -166,74 +184,140 @@
// setInterval(syncLocalStorage, 10000);
</script>
<aside class="menu main-menu" class:pinned={$pinMainMenu} use:clickOutside={() => { $pinMainMenu = false }}>
<div style="font-size:8px;position:fixed;top:1px;right:2px;">&&&BUILDVER&&&</div>
<div class="menu-expanse">
<div class="navbar-brand menu-nav-bar">
<a class="navbar-item gpt-logo" href={'#/'}>
<aside class="menu main-menu modern-sidebar" class:collapsed={isCollapsed} class:pinned={$pinMainMenu} use:clickOutside={() => { $pinMainMenu = false }}>
<div class="sidebar-content">
<!-- Header with logo and collapse button -->
<div class="sidebar-header">
{#if !isCollapsed}
<div class="header-content">
<a class="logo-container" href={'#/'}>
<img src={logo} alt="ChatGPT-web" width="24" height="24" />
</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} />
</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}
</ul>
<!-- <p class="menu-label">Actions</p> -->
<div class="level is-mobile bottom-buttons p-1">
<div class="level-left">
<div class="dropdown is-left is-up" class:is-active={showSortMenu} use:clickOutside={() => { showSortMenu = false }}>
<div class="dropdown-trigger">
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu3" on:click|preventDefault|stopPropagation={() => { showSortMenu = !showSortMenu }}>
<span class="icon"><Fa icon={sortOption.icon}/></span>
</button>
</div>
<!-- Chat list -->
<div class="chat-list" class:collapsed={isCollapsed}>
{#if !isCollapsed}
{#if sortedChats.length === 0}
<div class="empty-state">
<span>No chats yet...</span>
</div>
<div class="dropdown-menu" id="dropdown-menu3" 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}
{: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}
</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>
{/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 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 class="is-left is-up ml-2">
<button class="button" aria-haspopup="true" on:click|preventDefault|stopPropagation={() => { dumpLocalStorage() }}>
<span class="icon"><Fa icon={faDownload}/></span>
</button>
</div>
</div>
<div class="level-right">
{#if !hasModels}
<div class="level-item">
<a href={'#/'} class="panel-block" class:is-disabled={!hasModels}
><span class="greyscale mr-1"><Fa icon={faKey} /></span> API Setting</a
></div>
{:else}
<div class="level-item">
<button on:click={() => { $pinMainMenu = false; startNewChatWithWarning(activeChatId) }} class="panel-block button" title="Start new chat with default profile" class:is-disabled={!hasModels}
><span class="greyscale"><Fa icon={faSquarePlus} /></span></button>
{:else}
<div class="footer-controls-collapsed">
<div class="new-chat-section-collapsed">
{#if hasModels}
<button
class="new-chat-button-collapsed"
on:click={() => { $pinMainMenu = false; startNewChatWithWarning(activeChatId) }}
title="Start new chat">
<Fa icon={faSquarePlus} />
</button>
{:else}
<a href={'#/'} class="new-chat-button-collapsed api-settings" title="Set up API key">
<Fa icon={faKey} />
</a>
{/if}
</div>
{/if}
</div>
<div class="dropdown is-up is-right" class:is-active={showSortMenu} use:clickOutside={() => { showSortMenu = false }}>
<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>
</aside>

View File

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

View File

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

View File

@@ -6,6 +6,17 @@
import { replace } from 'svelte-spa-router'
// import PromptConfirm from './PromptConfirm.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
let cachedAutoSizeElements: HTMLTextAreaElement[] = []
let lastElementCount = 0
@@ -137,13 +148,13 @@
})
}
export const startNewChatFromChatId = (chatId: number) => {
export const startNewChatFromChatId = (chatId: string) => {
const newChatId = addChat(getChat(chatId).settings)
// go to new chat
replace(`/chat/${newChatId}`)
}
export const startNewChatWithWarning = (activeChatId: number|undefined, profile?: ChatSettings|undefined) => {
export const startNewChatWithWarning = (activeChatId: string|undefined, profile?: ChatSettings|undefined) => {
const newChat = () => {
const chatId = addChat(profile)
replace(`/chat/${chatId}`)
@@ -164,11 +175,64 @@
newChat()
}
export const valueOf = (chatId: number, value: any) => {
export const valueOf = (chatId: string, value: any) => {
if (typeof value === 'function') return value(chatId)
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 => {
return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&')
}