feat: Chat Folder (#766)

# PR Checklist
- [x] Have you checked if it works normally in all models? *Ignore this
if it doesn't use models.*
- [x] Have you checked if it works normally in all web, local, and node
hosted versions? If it doesn't, have you blocked it in those versions?
- [x] Have you added type definitions?

# Description

<img width="369" alt="스크린샷 2025-02-19 오후 5 11 42"
src="https://github.com/user-attachments/assets/f6819d88-e417-47d4-9f69-888e9aab21f7"
/>
<img width="369" alt="스크린샷 2025-02-19 오후 5 11 54"
src="https://github.com/user-attachments/assets/b867f97e-f9bb-4964-ba9e-a34171e79ffe"
/>

이 PR은 챗을 분류하는 폴더를 추가합니다. 폴더에는 색상을 넣을 수 있습니다.

This PR adds a folder to categorize chats. Folders can be colored. I'm
open to any feedback or suggestions.
This commit is contained in:
kwaroran
2025-03-05 05:37:42 +09:00
committed by GitHub
4 changed files with 439 additions and 147 deletions

View File

@@ -1,55 +1,116 @@
<script lang="ts">
import type { Chat, character, groupChat } from "src/ts/storage/database.svelte";
import { DBState } from 'src/ts/stores.svelte';
import TextInput from "../UI/GUI/TextInput.svelte";
import { DownloadIcon, PencilIcon, FolderUpIcon, MenuIcon, TrashIcon, GitBranchIcon, SplitIcon } from "lucide-svelte";
import { exportChat, importChat } from "src/ts/characters";
import { alertChatOptions, alertConfirm, alertError, alertNormal, alertSelect, alertStore } from "src/ts/alert";
import { language } from "src/lang";
import Button from "../UI/GUI/Button.svelte";
import { findCharacterbyId, parseKeyValue, sleep, sortableOptions } from "src/ts/util";
import CheckInput from "../UI/GUI/CheckInput.svelte";
import { createMultiuserRoom } from "src/ts/sync/multiuser";
import { MobileGUI, ReloadGUIPointer, selectedCharID } from "src/ts/stores.svelte";
import Sortable from 'sortablejs/modular/sortable.core.esm.js';
import { onDestroy, onMount } from "svelte";
import { v4 } from "uuid";
import { getChatBranches } from "src/ts/gui/branches";
import { getModuleToggles } from "src/ts/process/modules";
import Sortable from 'sortablejs/modular/sortable.core.esm.js';
import { DownloadIcon, PencilIcon, FolderUpIcon, MenuIcon, TrashIcon, GitBranchIcon, SplitIcon, FolderPlusIcon } from "lucide-svelte";
interface Props {
chara: character|groupChat;
}
import type { Chat, ChatFolder, character, groupChat } from "src/ts/storage/database.svelte";
import { DBState } from 'src/ts/stores.svelte';
import { MobileGUI, ReloadGUIPointer, selectedCharID } from "src/ts/stores.svelte";
let { chara = $bindable() }: Props = $props();
import CheckInput from "../UI/GUI/CheckInput.svelte";
import Button from "../UI/GUI/Button.svelte";
import TextInput from "../UI/GUI/TextInput.svelte";
import { exportChat, importChat } from "src/ts/characters";
import { alertChatOptions, alertConfirm, alertError, alertInput, alertNormal, alertSelect, alertStore } from "src/ts/alert";
import { findCharacterbyId, parseKeyValue, sleep, sortableOptions } from "src/ts/util";
import { createMultiuserRoom } from "src/ts/sync/multiuser";
import { getChatBranches } from "src/ts/gui/branches";
import { getModuleToggles } from "src/ts/process/modules";
import { language } from "src/lang";
interface Props {
chara: character|groupChat;
}
let { chara = $bindable() }: Props = $props();
let editMode = $state(false)
let stb: Sortable = null
let ele: HTMLDivElement = $state()
let chatsStb: Sortable[] = []
let folderStb: Sortable = null
let folderEles: HTMLDivElement = $state()
let listEle: HTMLDivElement = $state()
let sorted = $state(0)
let opened = 0
const createStb = () => {
stb = Sortable.create(ele, {
onEnd: async () => {
let idx:number[] = []
ele.querySelectorAll('[data-risu-idx]').forEach((e, i) => {
idx.push(parseInt(e.getAttribute('data-risu-idx')))
for (let chat of listEle.querySelectorAll('.risu-chat')) {
chatsStb.push(new Sortable(chat, {
group: 'chats',
onEnd: async (event) => {
const currentChatPage = chara.chatPage
const newChats: Chat[] = []
// const chats: HTMLElement = event.to
// chats.querySelectorAll()
listEle.querySelectorAll('[data-risu-chat-folder-idx]').forEach(folder => {
const folderIdx = parseInt(folder.getAttribute('data-risu-chat-folder-idx'))
folder.querySelectorAll('[data-risu-chat-idx]').forEach(chatInFolder => {
const chatIdx = parseInt(chatInFolder.getAttribute('data-risu-chat-idx'))
const newChat = chara.chats[chatIdx]
newChat.folderId = chara.chatFolders[folderIdx].id
newChats.push(newChat)
})
})
listEle.querySelectorAll('[data-risu-chat-idx]').forEach(chatEle => {
const idx = parseInt(chatEle.getAttribute('data-risu-chat-idx'))
const newChat = chara.chats[idx]
if (newChats.includes(newChat) == false) {
if (newChat.folderId != null)
newChat.folderId = null
newChats.push(newChat)
}
})
chara.chatPage = newChats.indexOf(chara.chats[currentChatPage])
chara.chats = newChats
try {
this.destroy()
} catch (e) {}
sorted += 1
await sleep(1)
createStb()
},
...sortableOptions
}))
}
folderStb = Sortable.create(folderEles, {
group: 'folders',
onEnd: async (event) => {
const newFolders: ChatFolder[] = []
const newChats: Chat[] = []
const folders: HTMLElement[] = Array.from<HTMLElement>(event.to.children)
const currentChatPage = chara.chatPage
folders.forEach(folder => {
const folderIdx = parseInt(folder.getAttribute('data-risu-chat-folder-idx'))
newFolders.push(chara.chatFolders[folderIdx])
folder.querySelectorAll('[data-risu-chat-idx]').forEach(chatEle => {
const idx = parseInt(chatEle.getAttribute('data-risu-chat-idx'))
newChats.push(chara.chats[idx])
})
})
console.log(idx)
let newValue:Chat[] = []
let newChatPage = chara.chatPage
idx.forEach((i) => {
newValue.push(chara.chats[i])
if(chara.chatPage === i){
newChatPage = newValue.length - 1
listEle.querySelectorAll('[data-risu-chat-idx]').forEach(chatEle => {
const idx = parseInt(chatEle.getAttribute('data-risu-chat-idx'))
if (newChats.includes(chara.chats[idx]) == false) {
newChats.push(chara.chats[idx])
}
})
chara.chats = newValue
chara.chatPage = newChatPage
chara.chatFolders = newFolders
chara.chatPage = newChats.indexOf(chara.chats[currentChatPage])
chara.chats = newChats
try {
stb.destroy()
} catch (error) {}
folderStb.destroy()
} catch (e) {}
sorted += 1
await sleep(1)
createStb()
@@ -61,15 +122,19 @@
onMount(createStb)
onDestroy(() => {
if(stb){
if (folderStb) {
try {
folderStb.destroy()
} catch (error) {}
}
chatsStb.map(stb => {
try {
stb.destroy()
} catch (error) {}
}
})
})
</script>
<div class="flex flex-col w-full h-[calc(100%-2rem)] max-h-[calc(100%-2rem)]">
<Button className="relative bottom-2" onclick={() => {
const cha = chara
const len = chara.chats.length
@@ -90,113 +155,309 @@
chara.chatPage = 0
$ReloadGUIPointer += 1
}}>{language.newChat}</Button>
<div class="flex flex-col w-full mt-2 overflow-y-auto flex-grow" bind:this={ele}>
{#key sorted}
{#each chara.chats as chat, i}
<button data-risu-idx={i} onclick={() => {
if(!editMode){
chara.chatPage = i
$ReloadGUIPointer += 1
}
}} class="flex items-center text-textcolor border-solid border-0 border-darkborderc p-2 cursor-pointer rounded-md"class:bg-selected={i === chara.chatPage}>
{#if editMode}
<TextInput bind:value={chara.chats[i].name} className="flex-grow min-w-0" padding={false}/>
{:else}
<span>{chat.name}</span>
{/if}
<div class="flex-grow flex justify-end">
<div role="button" tabindex="0" onkeydown={(e) => {
if(e.key === 'Enter'){
e.currentTarget.click()
}
}} class="text-textcolor2 hover:text-green-500 mr-1 cursor-pointer" onclick={async () => {
const option = await alertChatOptions()
switch(option){
case 0:{
const newChat = safeStructuredClone($state.snapshot(chara.chats[i]))
newChat.name = `Copy of ${newChat.name}`
chara.chats.unshift(newChat)
chara.chatPage = 0
chara.chats = chara.chats
break
{#key sorted}
<div class="flex flex-col mt-2 overflow-y-auto flex-grow" bind:this={listEle}>
<!-- folder div -->
<div class="flex flex-col" bind:this={folderEles}>
<!-- chat folder -->
{#each chara.chatFolders as folder, i}
<div data-risu-chat-folder-idx={i}
class="flex flex-col mb-2 border-solid border-1 border-darkborderc cursor-pointer rounded-md">
<!-- folder header -->
<button
onclick={() => {
if(!editMode) {
chara.chatFolders[i].folded = !folder.folded
$ReloadGUIPointer += 1
}
case 1:{
const chat = chara.chats[i]
if(chat.bindedPersona){
const confirm = await alertConfirm(language.doYouWantToUnbindCurrentPersona)
if(confirm){
chat.bindedPersona = ''
alertNormal(language.personaUnbindedSuccess)
}
}}
class="flex items-center text-textcolor border-solid border-0 border-darkborderc p-2 cursor-pointer rounded-md"
class:bg-red-900={folder.color === 'red'}
class:bg-yellow-900={folder.color === 'yellow'}
class:bg-green-900={folder.color === 'green'}
class:bg-blue-900={folder.color === 'blue'}
class:bg-indigo-900={folder.color === 'indigo'}
class:bg-purple-900={folder.color === 'purple'}
class:bg-pink-900={folder.color === 'pink'}
>
{#if editMode}
<TextInput bind:value={chara.chatFolders[i].name} className="flex-grow min-w-0" padding={false}/>
{:else}
<span>{folder.name}</span>
{/if}
<div class="flex-grow flex justify-end">
<div role="button" tabindex="0" onkeydown={(e) => {
if(e.key === 'Enter'){
e.currentTarget.click()
}
else{
const confirm = await alertConfirm(language.doYouWantToBindCurrentPersona)
if(confirm){
if(!DBState.db.personas[DBState.db.selectedPersona].id){
DBState.db.personas[DBState.db.selectedPersona].id = v4()
}} class="text-textcolor2 hover:text-green-500 mr-1 cursor-pointer" onclick={async (e) => {
e.stopPropagation()
const sel = parseInt(await alertSelect([language.changeFolderColor, language.cancel]))
switch (sel) {
case 0:
const colors = ["red","green","blue","yellow","indigo","purple","pink","default"]
const sel = parseInt(await alertSelect(colors))
folder.color = colors[sel]
break
}
}}>
<MenuIcon size={18}/>
</div>
<div role="button" tabindex="0" onkeydown={(e) => {
if(e.key === 'Enter'){
e.currentTarget.click()
}
}} class="text-textcolor2 hover:text-green-500 mr-1 cursor-pointer" onclick={() => {
editMode = !editMode
}}>
<PencilIcon size={18}/>
</div>
<div role="button" tabindex="0" onkeydown={(e) => {
if(e.key === 'Enter'){
e.currentTarget.click()
}
}} class="text-textcolor2 hover:text-green-500 cursor-pointer" onclick={async (e) => {
e.stopPropagation()
const d = await alertConfirm(`${language.removeConfirm}${folder.name}`)
if (d) {
$ReloadGUIPointer += 1
const folders = chara.chatFolders
folders.splice(i, 1)
chara.chats.forEach(chat => {
if (chat.folderId == folder.id) {
chat.folderId = null
}
chat.bindedPersona = DBState.db.personas[DBState.db.selectedPersona].id
console.log(DBState.db.personas[DBState.db.selectedPersona])
alertNormal(language.personaBindedSuccess)
}
})
chara.chatFolders = folders
}
break
}}>
<TrashIcon size={18}/>
</div>
</div>
</button>
<!-- chats in folder -->
<div class="risu-chat flex flex-col w-full text-textcolor border-solid border-0 border-darkborderc p-2 cursor-pointer rounded-md {folder.folded ? 'hidden' : ''}">
{#if chara.chats.filter(chat => chat.folderId == chara.chatFolders[i].id).length == 0}
<span class="no-sort flex justify-center text-textcolor2">Empty</span>
<div></div>
{:else}
{#each chara.chats.filter(chat => chat.folderId == chara.chatFolders[i].id) as chat}
<button data-risu-chat-idx={chara.chats.indexOf(chat)} onclick={() => {
if(!editMode){
chara.chatPage = chara.chats.indexOf(chat)
$ReloadGUIPointer += 1
}
case 2:{
chara.chatPage = i
createMultiuserRoom()
}
}
}}>
<MenuIcon size={18}/>
</div>
<div role="button" tabindex="0" onkeydown={(e) => {
if(e.key === 'Enter'){
e.currentTarget.click()
}
}} class="text-textcolor2 hover:text-green-500 mr-1 cursor-pointer" onclick={() => {
editMode = !editMode
}}>
<PencilIcon size={18}/>
</div>
<div role="button" tabindex="0" onkeydown={(e) => {
if(e.key === 'Enter'){
e.currentTarget.click()
}
}} class="text-textcolor2 hover:text-green-500 mr-1 cursor-pointer" onclick={async (e) => {
e.stopPropagation()
exportChat(i)
}}>
<DownloadIcon size={18}/>
</div>
<div role="button" tabindex="0" onkeydown={(e) => {
if(e.key === 'Enter'){
e.currentTarget.click()
}
}} class="text-textcolor2 hover:text-green-500 cursor-pointer" onclick={async (e) => {
e.stopPropagation()
if(chara.chats.length === 1){
alertError(language.errors.onlyOneChat)
return
}
const d = await alertConfirm(`${language.removeConfirm}${chat.name}`)
if(d){
chara.chatPage = 0
$ReloadGUIPointer += 1
let chats = chara.chats
chats.splice(i, 1)
chara.chats = chats
}
}}>
<TrashIcon size={18}/>
}} class="risu-chats flex items-center text-textcolor border-solid border-0 border-darkborderc p-2 cursor-pointer rounded-md"class:bg-selected={chara.chats.indexOf(chat) === chara.chatPage}>
{#if editMode}
<TextInput bind:value={chat.name} className="flex-grow min-w-0" padding={false}/>
{:else}
<span>{chat.name}</span>
{/if}
<div class="flex-grow flex justify-end">
<div role="button" tabindex="0" onkeydown={(e) => {
if(e.key === 'Enter'){
e.currentTarget.click()
}
}} class="text-textcolor2 hover:text-green-500 mr-1 cursor-pointer" onclick={async () => {
const option = await alertChatOptions()
switch(option){
case 0:{
const newChat = safeStructuredClone($state.snapshot(chara.chats[chara.chats.indexOf(chat)]))
newChat.name = `Copy of ${newChat.name}`
chara.chats.unshift(newChat)
chara.chatPage = 0
chara.chats = chara.chats
break
}
case 1:{
if(chat.bindedPersona){
const confirm = await alertConfirm(language.doYouWantToUnbindCurrentPersona)
if(confirm){
chat.bindedPersona = ''
alertNormal(language.personaUnbindedSuccess)
}
}
else{
const confirm = await alertConfirm(language.doYouWantToBindCurrentPersona)
if(confirm){
if(!DBState.db.personas[DBState.db.selectedPersona].id){
DBState.db.personas[DBState.db.selectedPersona].id = v4()
}
chat.bindedPersona = DBState.db.personas[DBState.db.selectedPersona].id
console.log(DBState.db.personas[DBState.db.selectedPersona])
alertNormal(language.personaBindedSuccess)
}
}
break
}
case 2:{
chara.chatPage = chara.chats.indexOf(chat)
createMultiuserRoom()
}
}
}}>
<MenuIcon size={18}/>
</div>
<div role="button" tabindex="0" onkeydown={(e) => {
if(e.key === 'Enter'){
e.currentTarget.click()
}
}} class="text-textcolor2 hover:text-green-500 mr-1 cursor-pointer" onclick={() => {
editMode = !editMode
}}>
<PencilIcon size={18}/>
</div>
<div role="button" tabindex="0" onkeydown={(e) => {
if(e.key === 'Enter'){
e.currentTarget.click()
}
}} class="text-textcolor2 hover:text-green-500 mr-1 cursor-pointer" onclick={async (e) => {
e.stopPropagation()
exportChat(chara.chats.indexOf(chat))
}}>
<DownloadIcon size={18}/>
</div>
<div role="button" tabindex="0" onkeydown={(e) => {
if(e.key === 'Enter'){
e.currentTarget.click()
}
}} class="text-textcolor2 hover:text-green-500 cursor-pointer" onclick={async (e) => {
e.stopPropagation()
if(chara.chats.length === 1){
alertError(language.errors.onlyOneChat)
return
}
const d = await alertConfirm(`${language.removeConfirm}${chat.name}`)
if(d){
chara.chatPage = 0
$ReloadGUIPointer += 1
let chats = chara.chats
chats.splice(chara.chats.indexOf(chat), 1)
chara.chats = chats
}
}}>
<TrashIcon size={18}/>
</div>
</div>
</button>
{/each}
{/if}
</div>
</div>
</button>
{/each}
{/key}
{/each}
</div>
<!-- chat without folder div -->
<div class="risu-chat flex flex-col">
{#each chara.chats as chat, i}
{#if chat.folderId == null}
<button data-risu-chat-idx={i} onclick={() => {
if(!editMode){
chara.chatPage = i
$ReloadGUIPointer += 1
}
}}
class="flex items-center text-textcolor border-solid border-0 border-darkborderc p-2 cursor-pointer rounded-md"
class:bg-selected={i === chara.chatPage}>
{#if editMode}
<TextInput bind:value={chara.chats[i].name} className="flex-grow min-w-0" padding={false}/>
{:else}
<span>{chat.name}</span>
{/if}
<div class="flex-grow flex justify-end">
<div role="button" tabindex="0" onkeydown={(e) => {
if(e.key === 'Enter'){
e.currentTarget.click()
}
}} class="text-textcolor2 hover:text-green-500 mr-1 cursor-pointer" onclick={async () => {
const option = await alertChatOptions()
switch(option){
case 0:{
const newChat = safeStructuredClone($state.snapshot(chara.chats[i]))
newChat.name = `Copy of ${newChat.name}`
chara.chats.unshift(newChat)
chara.chatPage = 0
chara.chats = chara.chats
break
}
case 1:{
const chat = chara.chats[i]
if(chat.bindedPersona){
const confirm = await alertConfirm(language.doYouWantToUnbindCurrentPersona)
if(confirm){
chat.bindedPersona = ''
alertNormal(language.personaUnbindedSuccess)
}
}
else{
const confirm = await alertConfirm(language.doYouWantToBindCurrentPersona)
if(confirm){
if(!DBState.db.personas[DBState.db.selectedPersona].id){
DBState.db.personas[DBState.db.selectedPersona].id = v4()
}
chat.bindedPersona = DBState.db.personas[DBState.db.selectedPersona].id
console.log(DBState.db.personas[DBState.db.selectedPersona])
alertNormal(language.personaBindedSuccess)
}
}
break
}
case 2:{
chara.chatPage = i
createMultiuserRoom()
}
}
}}>
<MenuIcon size={18}/>
</div>
<div role="button" tabindex="0" onkeydown={(e) => {
if(e.key === 'Enter'){
e.currentTarget.click()
}
}} class="text-textcolor2 hover:text-green-500 mr-1 cursor-pointer" onclick={() => {
editMode = !editMode
}}>
<PencilIcon size={18}/>
</div>
<div role="button" tabindex="0" onkeydown={(e) => {
if(e.key === 'Enter'){
e.currentTarget.click()
}
}} class="text-textcolor2 hover:text-green-500 mr-1 cursor-pointer" onclick={async (e) => {
e.stopPropagation()
exportChat(i)
}}>
<DownloadIcon size={18}/>
</div>
<div role="button" tabindex="0" onkeydown={(e) => {
if(e.key === 'Enter'){
e.currentTarget.click()
}
}} class="text-textcolor2 hover:text-green-500 cursor-pointer" onclick={async (e) => {
e.stopPropagation()
if(chara.chats.length === 1){
alertError(language.errors.onlyOneChat)
return
}
const d = await alertConfirm(`${language.removeConfirm}${chat.name}`)
if(d){
chara.chatPage = 0
$ReloadGUIPointer += 1
let chats = chara.chats
chats.splice(i, 1)
chara.chats = chats
}
}}>
<TrashIcon size={18}/>
</div>
</div>
</button>
{/if}
{/each}
</div>
</div>
{/key}
<div class="border-t border-selected mt-2">
<div class="flex mt-2 ml-2 items-center">
<button class="text-textcolor2 hover:text-green-500 mr-2 cursor-pointer" onclick={() => {
@@ -217,11 +478,22 @@
}}>
<SplitIcon size={18}/>
</button>
<button class="ml-auto text-textcolor2 hover:text-green-500 mr-2 cursor-pointer" onclick={() => {
const folders = chara.chatFolders
const length = chara.chatFolders.length
folders.unshift({
id: v4(),
name: `New Folder ${length + 1}`,
folded: false,
})
chara.chatFolders = folders
$ReloadGUIPointer += 1
}}>
<FolderPlusIcon size={18}/>
</button>
</div>
{#if DBState.db.characters[$selectedCharID]?.chaId !== '§playground'}
{#if DBState.db.characters[$selectedCharID]?.chaId !== '§playground'}
{#if parseKeyValue(DBState.db.customPromptTemplateToggle + getModuleToggles()).length > 4}
<div class="h-48 border-darkborderc p-2 border rounded flex flex-col items-start mt-2 overflow-y-auto">
<div class="flex mt-2 items-center w-full" class:justify-end={$MobileGUI}>
@@ -268,10 +540,9 @@
{/if}
{/if}
</div>
{#if chara.type === 'group'}
<div class="flex mt-2 items-center">
<CheckInput bind:check={chara.orderByOrder} name={language.orderByOrder}/>
</div>
<div class="flex mt-2 items-center">
<CheckInput bind:check={chara.orderByOrder} name={language.orderByOrder}/>
</div>
{/if}
</div>

View File

@@ -32,7 +32,9 @@ export function createNewGroup(){
note: '',
name: 'Chat 1',
localLore: []
}], chatPage: 0,
}],
chatFolders: [],
chatPage: 0,
viewScreen: 'none',
globalLore: [],
characters: [],
@@ -402,6 +404,11 @@ export async function importChat(){
return
}
if(db.characters[selectedID].chatFolders
.filter(folder => folder.id === newChat.folderId).length === 0) {
newChat.folderId = null
}
db.characters[selectedID].chats.unshift(newChat)
setDatabase(db)
alertNormal(language.successImport)
@@ -585,6 +592,7 @@ export function createBlankChar():character{
name: 'Chat 1',
localLore: []
}],
chatFolders: [],
chatPage: 0,
emotionImages: [],
bias: [],

View File

@@ -979,6 +979,7 @@ export interface character{
desc:string
notes:string
chats:Chat[]
chatFolders: ChatFolder[]
chatPage: number
viewScreen: 'emotion'|'none'|'imggen'|'vn',
bias: [string, number][]
@@ -1117,6 +1118,7 @@ export interface groupChat{
image?:string
firstMessage:string
chats:Chat[]
chatFolders: ChatFolder[]
chatPage: number
name:string
viewScreen: 'single'|'multiple'|'none'|'emp',
@@ -1324,6 +1326,14 @@ export interface Chat{
bindedPersona?:string
fmIndex?:number
hypaV3Data?:SerializableHypaV3Data
folderId?:string
}
export interface ChatFolder{
id:string
name?:string
color?:string
folded:boolean
}
export interface Message{

View File

@@ -1015,4 +1015,7 @@ export const sortableOptions = {
delay: 300, // time in milliseconds to define when the sorting should start
delayOnTouchOnly: true,
filter: '.no-sort',
onMove: (event) => {
return event.related.className.indexOf('no-sort') === -1
}
} as const