Bring character schema up to v3 standards, add support v3 import support

This commit is contained in:
Jody Doolittle 2025-06-11 02:56:31 -07:00
parent f1534dad54
commit ffd2621fc8
17 changed files with 3606 additions and 1433 deletions

View file

@ -3,7 +3,6 @@
"singleQuote": false,
"tabWidth": 4,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
@ -12,5 +11,9 @@
"parser": "svelte"
}
}
]
],
"printWidth": 80,
"useTabs": true,
"bracketSpacing": true,
"htmlWhitespaceSensitivity": "ignore"
}

BIN
bun.lockb

Binary file not shown.

View file

@ -0,0 +1,35 @@
PRAGMA foreign_keys=OFF;
--> statement-breakpoint
CREATE TABLE `__new_characters` (
`id` integer PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`name` text NOT NULL,
`nickname` text,
`character_version` text DEFAULT '1.0',
`description` text,
`personality` text,
`scenario` text,
`first_message` text,
`alternate_greetings` text,
`example_dialogues` text,
`metadata` text,
`avatar` text,
`creator_notes` text,
`creator_notes_multilingual` text,
`group_only_greetings` text DEFAULT '[]',
`post_history_instructions` text,
`source` text DEFAULT '[]',
`assets` text DEFAULT '[]',
`created_at` text DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text,
`lorebook_id` integer,
`extensions` text DEFAULT '[]',
`is_favorite` integer DEFAULT false,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`lorebook_id`) REFERENCES `lorebooks`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
INSERT INTO `__new_characters`("id", "user_id", "name", "description", "personality", "scenario", "first_message", "example_dialogues", "metadata", "avatar", "created_at", "updated_at", "lorebook_id", "is_favorite") SELECT "id", "user_id", "name", "description", "personality", "scenario", "first_message", "example_dialogues", "metadata", "avatar", "created_at", "updated_at", "lorebook_id", "is_favorite" FROM `characters`;--> statement-breakpoint
DROP TABLE `characters`;--> statement-breakpoint
ALTER TABLE `__new_characters` RENAME TO `characters`;--> statement-breakpoint
PRAGMA foreign_keys=ON;

File diff suppressed because it is too large Load diff

View file

@ -29,6 +29,13 @@
"when": 1749526176881,
"tag": "0003_magical_pride",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1749630355164,
"tag": "0004_fantastic_sumo",
"breakpoints": true
}
]
}

View file

@ -37,8 +37,6 @@
"drizzle-kit": "^0.30.2",
"env-paths": "^3.0.0",
"js-yaml": "^4.1.0",
"png-chunk-text": "^1.0.0",
"png-chunks-extract": "^1.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
@ -50,12 +48,14 @@
"vite": "^6.2.6"
},
"dependencies": {
"@lenml/char-card-reader": "^1.0.6",
"@lucide/svelte": "^0.511.0",
"@types/lodash": "^4.17.17",
"@types/pngjs": "^6.0.5",
"better-sqlite3": "^11.8.0",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.40.0",
"file-type": "^21.0.0",
"gpt-tokenizer": "^3.0.0",
"handlebars": "^4.7.8",
"llama-tokenizer-js": "^1.2.2",

View file

@ -1,322 +1,628 @@
<script lang="ts">
import { Avatar } from "@skeletonlabs/skeleton-svelte"
import * as Icons from "@lucide/svelte"
import skio from "sveltekit-io"
import { onMount } from "svelte"
import CharacterUnsavedChangesModal from "../modals/CharacterUnsavedChangesModal.svelte"
import { Avatar, Switch } from "@skeletonlabs/skeleton-svelte"
import * as Icons from "@lucide/svelte"
import skio from "sveltekit-io"
import { onMount } from "svelte"
import CharacterUnsavedChangesModal from "../modals/CharacterUnsavedChangesModal.svelte"
interface EditCharacterData {
id?: number
name: string
avatar: string
description: string
personality: string
scenario: string
firstMessage: string
exampleDialogues: string
_avatarFile?: File | undefined
_avatar: string
}
interface EditCharacterData {
id?: number
name: string
nickname: string
avatar: string
description: string
personality: string
scenario: string
firstMessage: string
alternateGreetings: string[]
exampleDialogues: string
creatorNotes: string
creatorNotesMultilingual: Record<string, string>
groupOnlyGreetings: string[]
postHistoryInstructions: string
isFavorite: boolean
_avatarFile?: File | undefined
_avatar: string
}
export interface Props {
characterId?: number
isSafeToClose: boolean
closeForm: () => void
}
export interface Props {
characterId?: number
isSafeToClose: boolean
closeForm: () => void
}
let { characterId, isSafeToClose = $bindable(), closeForm }: Props = $props()
let {
characterId,
isSafeToClose = $bindable(),
closeForm
}: Props = $props()
const socket = skio.get()
const socket = skio.get()
let editCharacterData: EditCharacterData = $state({
id: undefined,
name: "",
avatar: "",
description: "",
personality: "",
scenario: "",
firstMessage: "",
exampleDialogues: "",
_avatarFile: undefined,
_avatar: "",
})
let originalCharacterData: EditCharacterData = $state({
...editCharacterData
})
let expanded = $state({
description: true,
personality: false,
scenario: false,
firstMessage: false,
exampleDialogues: false
})
let character = $state(undefined)
let mode: "create" | "edit" = $derived.by(() => (!!character ? "edit" : "create"))
let isDataValid = $derived(
!!editCharacterData?.name?.trim() && !!editCharacterData?.name?.trim()
)
let showCancelModal = $state(false)
let editCharacterData: EditCharacterData = $state({
id: undefined,
name: "",
nickname: "",
avatar: "",
description: "",
personality: "",
scenario: "",
firstMessage: "",
alternateGreetings: [],
exampleDialogues: "",
creatorNotes: "",
creatorNotesMultilingual: {},
groupOnlyGreetings: [],
postHistoryInstructions: "",
isFavorite: false,
characterVersion: "",
_avatarFile: undefined,
_avatar: ""
})
let originalCharacterData: EditCharacterData = $state({
...editCharacterData
})
let expanded = $state({
description: true,
personality: false,
scenario: false,
firstMessage: false,
exampleDialogues: false,
creatorNotes: false,
creatorNotesMultilingual: false,
alternateGreetings: false,
groupOnlyGreetings: false,
postHistoryInstructions: false
})
let character = $state(undefined)
let mode: "create" | "edit" = $derived.by(() =>
!!character ? "edit" : "create"
)
let isDataValid = $derived(
!!editCharacterData?.name?.trim() && !!editCharacterData?.name?.trim()
)
let showCancelModal = $state(false)
let newLangKey = $state("")
let newLangNote = $state("")
socket.on("createCharacter", (res) => {
if (!res.error) {
closeForm()
}
})
socket.on("createCharacter", (res) => {
if (!res.error) {
closeForm()
}
})
socket.on("updateCharacter", (res) => {
if (!res.error) {
closeForm()
}
})
socket.on("updateCharacter", (res) => {
if (!res.error) {
closeForm()
}
})
// Events: avatarChange, save, cancel
function handleAvatarChange(e: Event) {
const input = e.target as HTMLInputElement | null
if (!input || !input.files || input.files.length === 0) return
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
// Only set preview, do not upload yet
const previewReader = new FileReader()
previewReader.onload = (ev2) => {
editCharacterData._avatar = ev2.target?.result as string
}
previewReader.readAsDataURL(file)
// Store file for later upload
editCharacterData._avatarFile = file
}
// Events: avatarChange, save, cancel
function handleAvatarChange(e: Event) {
const input = e.target as HTMLInputElement | null
if (!input || !input.files || input.files.length === 0) return
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
// Only set preview, do not upload yet
const previewReader = new FileReader()
previewReader.onload = (ev2) => {
editCharacterData._avatar = ev2.target?.result as string
}
previewReader.readAsDataURL(file)
// Store file for later upload
editCharacterData._avatarFile = file
}
function onSave() {
if (mode === "create") {
// Create new character
handleCreate()
} else if (mode === "edit" && character) {
// Update existing character
handleUpdate()
}
}
function onSave() {
if (mode === "create") {
// Create new character
handleCreate()
} else if (mode === "edit" && character) {
// Update existing character
handleUpdate()
}
}
function handleCreate() {
const newCharacter = { ...editCharacterData }
const avatarFile = newCharacter._avatarFile
delete newCharacter._avatarFile
socket.emit("createCharacter", {
character: newCharacter,
avatarFile
})
}
function handleCreate() {
const newCharacter = { ...editCharacterData }
const avatarFile = newCharacter._avatarFile
delete newCharacter._avatarFile
socket.emit("createCharacter", {
character: newCharacter,
avatarFile
})
}
function handleUpdate() {
const updatedCharacter = { ...editCharacterData }
const avatarFile = updatedCharacter._avatarFile
delete updatedCharacter._avatarFile
socket.emit("updateCharacter", {
character: updatedCharacter,
avatarFile
})
}
function handleUpdate() {
const updatedCharacter = { ...editCharacterData }
const avatarFile = updatedCharacter._avatarFile
delete updatedCharacter._avatarFile
socket.emit("updateCharacter", {
character: updatedCharacter,
avatarFile
})
}
function handleCancelModalOnOpenChange(e: OpenChangeDetails) {
if (!e.open) {
showCancelModal = false
}
}
function handleCancelModalOnOpenChange(e: OpenChangeDetails) {
if (!e.open) {
showCancelModal = false
}
}
function handleCancel() {
if (isSafeToClose) {
closeForm()
} else {
showCancelModal = true
}
}
function handleCancel() {
if (isSafeToClose) {
closeForm()
} else {
showCancelModal = true
}
}
function handleCancelModalDiscard() {
showCancelModal = false
closeForm()
}
function handleCancelModalDiscard() {
showCancelModal = false
closeForm()
}
function handleCancelModalCancel() {
showCancelModal = false
}
function handleCancelModalCancel() {
showCancelModal = false
}
onMount(() => {
if (characterId) {
socket.once("character", (message) => {
console.log("[CharacterForm] Received character data:", message.character)
character = message.character
editCharacterData = message.character
originalCharacterData = { ...editCharacterData }
})
socket.emit("character", { id: characterId })
}
})
onMount(() => {
if (characterId) {
socket.once("character", (message) => {
console.log(
"[CharacterForm] Received character data:",
message.character
)
character = message.character
editCharacterData = {...editCharacterData, ...message.character}
originalCharacterData = { ...editCharacterData }
})
socket.emit("character", { id: characterId })
}
})
$effect(() => {
isSafeToClose = JSON.stringify(editCharacterData) === JSON.stringify(originalCharacterData)
})
$effect(() => {
isSafeToClose =
JSON.stringify(editCharacterData) ===
JSON.stringify(originalCharacterData)
})
// Helper for editing arrays
function addToArray(arr: string[], value = "") {
arr.push(value)
}
function removeFromArray(arr: string[], idx: number) {
arr.splice(idx, 1)
}
// Helper for editing object
function setObjectKey(
obj: Record<string, string>,
key: string,
value: string
) {
obj[key] = value
}
function removeObjectKey(obj: Record<string, string>, key: string) {
delete obj[key]
}
</script>
<div class="border-primary bg-background animate-fade-in min-h-full rounded-lg border p-4 shadow-lg">
<h2 class="mb-4 text-lg font-bold">
{mode === "edit" ? `Edit: ${character.name}` : "Create Character"}
</h2>
<div class="mt-4 mb-4 flex gap-2">
<button
type="button"
class="btn btn-sm preset-filled-surface-500 w-full"
onclick={handleCancel}>Cancel</button
>
<button
type="button"
class="btn btn-sm preset-filled-primary-500 w-full"
onclick={onSave}
disabled={!isDataValid || isSafeToClose}
>
{mode === "edit" ? "Update" : "Create"}
</button>
</div>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-4">
<span>
<Avatar
src={editCharacterData._avatar || editCharacterData.avatar}
size="w-[4em] h-[4em]"
name={editCharacterData.name ??
(mode === "edit" ? "Edit Character" : "New Character")}
background="preset-filled-primary-500"
>
<Icons.User size={36} />
</Avatar>
</span>
<div class="flex flex-col gap-2 w-full">
<div class="flex items-center justify-center w-full">
<label for="dropzone-file" class="flex flex-col items-center justify-center w-full border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 dark:hover:bg-gray-800 dark:bg-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:hover:border-gray-500 dark:hover:bg-gray-600">
<div class="flex flex-col items-center justify-center w-full">
<svg class="w-8 h-8 my-4 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 16">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"/>
</svg>
</div>
<input id="dropzone-file" type="file" class="hidden" accept="image/*"
onchange={handleAvatarChange} />
</label>
</div>
<button
type="button"
class="btn btn-xs preset-tonal-error mt-1"
onclick={() => {
editCharacterData._avatarFile = undefined
editCharacterData._avatar = ""
}}
disabled={!editCharacterData._avatarFile}
>
Clear Selection
</button>
</div>
</div>
<div class="flex flex-col gap-1">
<label class="font-semibold" for="charName">Name*</label>
<input
id="charName"
type="text"
bind:value={editCharacterData.name}
class="input input-sm bg-background border-muted w-full rounded border"
/>
</div>
<div class="flex flex-col gap-2">
<button
type="button"
class="flex items-center gap-2 text-sm font-semibold"
onclick={() => (expanded.description = !expanded.description)}
>
<span>Description*</span>
<span class="ml-1">{expanded.description ? "▼" : "►"}</span>
</button>
{#if expanded.description}
<textarea
rows="8"
bind:value={editCharacterData.description}
class="input input-sm bg-background border-muted w-full rounded border"
placeholder="Description..."
></textarea>
{/if}
</div>
<div class="flex flex-col gap-2">
<button
type="button"
class="flex items-center gap-2 text-sm font-semibold"
onclick={() => (expanded.personality = !expanded.personality)}
>
<span>Personality</span>
<span class="ml-1">{expanded.personality ? "▼" : "►"}</span>
</button>
{#if expanded.personality}
<textarea
rows="8"
bind:value={editCharacterData.personality}
class="input input-sm bg-background border-muted w-full rounded border"
placeholder="Personality..."
></textarea>
{/if}
</div>
<div class="flex flex-col gap-2">
<button
type="button"
class="flex items-center gap-2 text-sm font-semibold"
onclick={() => (expanded.scenario = !expanded.scenario)}
>
<span>Scenario</span>
<span class="ml-1">{expanded.scenario ? "▼" : "►"}</span>
</button>
{#if expanded.scenario}
<textarea
rows="8"
bind:value={editCharacterData.scenario}
class="input input-sm bg-background border-muted w-full rounded border"
placeholder="Scenario..."
></textarea>
{/if}
</div>
<div class="flex flex-col gap-2">
<button
type="button"
class="flex items-center gap-2 text-sm font-semibold"
onclick={() => (expanded.firstMessage = !expanded.firstMessage)}
>
<span>First Message</span>
<span class="ml-1">{expanded.firstMessage ? "▼" : "►"}</span>
</button>
{#if expanded.firstMessage}
<textarea
rows="8"
bind:value={editCharacterData.firstMessage}
class="input input-sm bg-background border-muted w-full rounded border"
placeholder="First message..."
></textarea>
{/if}
</div>
<div class="flex flex-col gap-2">
<button
type="button"
class="flex items-center gap-2 text-sm font-semibold"
onclick={() => (expanded.exampleDialogues = !expanded.exampleDialogues)}
>
<span>Example Dialogues</span>
<span class="ml-1">{expanded.exampleDialogues ? "▼" : "►"}</span>
</button>
{#if expanded.exampleDialogues}
<textarea
rows="8"
bind:value={editCharacterData.exampleDialogues}
class="input input-sm bg-background border-muted w-full rounded border"
placeholder="Example dialogues..."
></textarea>
{/if}
</div>
</div>
<div
class="border-primary bg-background animate-fade-in min-h-full rounded-lg border p-4 shadow-lg"
>
<h2 class="mb-4 text-lg font-bold">
{mode === "edit" ? `Edit: ${character.nickname || character.name}` : "Create Character"}
</h2>
<div class="mt-4 mb-4 flex gap-2">
<button
type="button"
class="btn btn-sm preset-filled-surface-500 w-full"
onclick={handleCancel}
>
Cancel
</button>
<button
type="button"
class="btn btn-sm preset-filled-primary-500 w-full"
onclick={onSave}
disabled={!isDataValid || isSafeToClose}
>
{mode === "edit" ? "Update" : "Create"}
</button>
</div>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-4">
<span>
<Avatar
src={editCharacterData._avatar || editCharacterData.avatar}
size="w-[4em] h-[4em]"
name={editCharacterData.name ??
(mode === "edit" ? "Edit Character" : "New Character")}
background="preset-filled-primary-500"
imageClasses="object-cover"
>
<Icons.User size={36} />
</Avatar>
</span>
<div class="flex w-full flex-col gap-2">
<div class="flex w-full items-center justify-center">
<label
for="dropzone-file"
class="flex w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-gray-500 dark:hover:bg-gray-600 dark:hover:bg-gray-800"
>
<div
class="flex w-full flex-col items-center justify-center"
>
<svg
class="my-4 h-8 w-8 text-gray-500 dark:text-gray-400"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 20 16"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
/>
</svg>
</div>
<input
id="dropzone-file"
type="file"
class="hidden"
accept="image/*"
onchange={handleAvatarChange}
/>
</label>
</div>
<button
type="button"
class="btn btn-sm preset-tonal-error mt-1"
onclick={() => {
editCharacterData._avatarFile = undefined
editCharacterData._avatar = ""
}}
disabled={!editCharacterData._avatarFile}
>
Clear Selection
</button>
</div>
</div>
<div class="flex flex-col gap-1">
<label class="font-semibold" for="charName">Name*</label>
<input
id="charName"
type="text"
bind:value={editCharacterData.name}
class="input input-sm bg-background border-muted w-full rounded border"
/>
</div>
<div class="flex flex-col gap-1">
<label class="font-semibold" for="charNickname">Nickname</label>
<input
id="charNickname"
type="text"
bind:value={editCharacterData.nickname}
class="input input-sm bg-background border-muted w-full rounded border"
/>
</div>
<div class="flex flex-col gap-1">
<label class="font-semibold" for="charVersion">
Character Version
</label>
<input
id="charVersion"
type="text"
bind:value={editCharacterData.characterVersion}
class="input input-sm bg-background border-muted w-full rounded border"
placeholder="1.0"
/>
</div>
<div class="flex flex-col gap-2">
<button
type="button"
class="flex items-center gap-2 text-sm font-semibold"
onclick={() => (expanded.description = !expanded.description)}
>
<span>Description*</span>
<span class="ml-1">{expanded.description ? "▼" : "►"}</span>
</button>
{#if expanded.description}
<textarea
rows="8"
bind:value={editCharacterData.description}
class="input input-sm bg-background border-muted w-full rounded border"
placeholder="Description..."
></textarea>
{/if}
</div>
<div class="flex flex-col gap-2">
<button
type="button"
class="flex items-center gap-2 text-sm font-semibold"
onclick={() => (expanded.personality = !expanded.personality)}
>
<span>Personality</span>
<span class="ml-1">{expanded.personality ? "▼" : "►"}</span>
</button>
{#if expanded.personality}
<textarea
rows="8"
bind:value={editCharacterData.personality}
class="input input-sm bg-background border-muted w-full rounded border"
placeholder="Personality..."
></textarea>
{/if}
</div>
<div class="flex flex-col gap-2">
<button
type="button"
class="flex items-center gap-2 text-sm font-semibold"
onclick={() => (expanded.scenario = !expanded.scenario)}
>
<span>Scenario</span>
<span class="ml-1">{expanded.scenario ? "▼" : "►"}</span>
</button>
{#if expanded.scenario}
<textarea
rows="8"
bind:value={editCharacterData.scenario}
class="input input-sm bg-background border-muted w-full rounded border"
placeholder="Scenario..."
></textarea>
{/if}
</div>
<div class="flex flex-col gap-2">
<button
type="button"
class="flex items-center gap-2 text-sm font-semibold"
onclick={() => (expanded.firstMessage = !expanded.firstMessage)}
>
<span>First Message</span>
<span class="ml-1">{expanded.firstMessage ? "▼" : "►"}</span>
</button>
{#if expanded.firstMessage}
<textarea
rows="8"
bind:value={editCharacterData.firstMessage}
class="input input-sm bg-background border-muted w-full rounded border"
placeholder="First message..."
></textarea>
{/if}
</div>
<div class="flex flex-col gap-2">
<button
type="button"
class="flex items-center gap-2 text-sm font-semibold"
onclick={() =>
(expanded.alternateGreetings =
!expanded.alternateGreetings)}
>
<span>Alternate Greetings</span>
<span class="ml-1">
{expanded.alternateGreetings ? "▼" : "►"}
</span>
</button>
{#if expanded.alternateGreetings}
<div class="flex flex-col gap-1">
{#each editCharacterData.alternateGreetings as greeting, idx (idx)}
<div class="flex items-center gap-2">
<input
type="text"
bind:value={
editCharacterData.alternateGreetings[idx]
}
class="input input-xs bg-background border-muted flex-1 rounded border"
placeholder="Greeting..."
/>
<button
class="btn btn-sm preset-filled-error-500"
type="button"
onclick={() =>
removeFromArray(
editCharacterData.alternateGreetings,
idx
)}
>
<Icons.Minus class="h-4 w-4" />
</button>
</div>
{/each}
<button
class="btn btn-sm mt-1 preset-filled-primary-500"
type="button"
onclick={() =>
addToArray(editCharacterData.alternateGreetings)}
>
<Icons.Plus class="h-4 w-4" />
Add Greeting
</button>
</div>
{/if}
</div>
<div class="flex flex-col gap-2">
<button
type="button"
class="flex items-center gap-2 text-sm font-semibold"
onclick={() => (expanded.creatorNotes = !expanded.creatorNotes)}
>
<span>Creator Notes</span>
<span class="ml-1">{expanded.creatorNotes ? "▼" : "►"}</span>
</button>
{#if expanded.creatorNotes}
<textarea
rows="4"
bind:value={editCharacterData.creatorNotes}
class="input input-sm bg-background border-muted w-full rounded border"
placeholder="Notes from the character creator..."
></textarea>
{/if}
</div>
<div class="flex flex-col gap-2">
<button
type="button"
class="flex items-center gap-2 text-sm font-semibold"
onclick={() =>
(expanded.creatorNotesMultilingual =
!expanded.creatorNotesMultilingual)}
>
<span>Creator Notes (Multilingual)</span>
<span class="ml-1">
{expanded.creatorNotesMultilingual ? "▼" : "►"}
</span>
</button>
{#if expanded.creatorNotesMultilingual}
<div class="flex flex-col gap-1">
{#each Object.entries(editCharacterData.creatorNotesMultilingual) as [lang, note], idx (lang)}
<div class="flex items-center gap-2">
<input
type="text"
value={lang}
class="input input-xs bg-background border-muted w-16 rounded border"
readonly
/>
<input
type="text"
bind:value={
editCharacterData.creatorNotesMultilingual[
lang
]
}
class="input input-xs bg-background border-muted flex-1 rounded border"
placeholder="Note..."
/>
<button
class="btn btn-sm preset-filled-success-500"
type="button"
onclick={() =>
removeObjectKey(
editCharacterData.creatorNotesMultilingual,
lang
)}
>
-
</button>
</div>
{/each}
<div class="mt-1 flex gap-2">
<input
type="text"
class="input input-xs bg-background border-muted w-16 rounded border"
bind:value={newLangKey}
placeholder="Lang"
/>
<input
type="text"
class="input input-xs bg-background border-muted flex-1 rounded border"
bind:value={newLangNote}
placeholder="Note..."
/>
<button
class="btn btn-sm preset-filled-success-500"
type="button"
onclick={() => {
if (newLangKey) {
setObjectKey(
editCharacterData.creatorNotesMultilingual,
newLangKey,
newLangNote
)
newLangKey = ""
newLangNote = ""
}
}}
>
<Icons.Plus class="h-4 w-4" />
</button>
</div>
</div>
{/if}
</div>
<div class="flex flex-col gap-2">
<button
type="button"
class="flex items-center gap-2 text-sm font-semibold"
onclick={() =>
(expanded.groupOnlyGreetings =
!expanded.groupOnlyGreetings)}
>
<span>Group-Only Greetings</span>
<span class="ml-1">
{expanded.groupOnlyGreetings ? "▼" : "►"}
</span>
</button>
{#if expanded.groupOnlyGreetings}
<div class="flex flex-col gap-1">
{#each editCharacterData.groupOnlyGreetings as greeting, idx (idx)}
<div class="flex items-center gap-2">
<input
type="text"
bind:value={
editCharacterData.groupOnlyGreetings[idx]
}
class="input input-xs bg-background border-muted flex-1 rounded border"
placeholder="Group greeting..."
/>
<button
class="btn btn-sm preset-filled-success-500"
type="button"
onclick={() =>
removeFromArray(
editCharacterData.groupOnlyGreetings,
idx
)}
>
-
</button>
</div>
{/each}
<button
class="btn btn-sm mt-1 preset-filled-primary-500"
type="button"
onclick={() =>
addToArray(editCharacterData.groupOnlyGreetings)}
>
<Icons.Plus class="h-4 w-4" />
Add Group Greeting
</button>
</div>
{/if}
</div>
<div class="flex flex-col gap-2">
<button
type="button"
class="flex items-center gap-2 text-sm font-semibold"
onclick={() =>
(expanded.postHistoryInstructions =
!expanded.postHistoryInstructions)}
>
<span>Post-History Instructions</span>
<span class="ml-1">
{expanded.postHistoryInstructions ? "▼" : "►"}
</span>
</button>
{#if expanded.postHistoryInstructions}
<textarea
rows="4"
bind:value={editCharacterData.postHistoryInstructions}
class="input input-sm bg-background border-muted w-full rounded border"
placeholder="Instructions for post-history processing..."
></textarea>
{/if}
</div>
<div class="mt-2 flex items-center gap-2">
<Switch name="example" checked={editCharacterData.isFavorite} onCheckedChange={(e) => (editCharacterData.isFavorite = e.checked)} />
Favorite
</div>
</div>
</div>
<CharacterUnsavedChangesModal
open={showCancelModal}
onOpenChange={handleCancelModalOnOpenChange}
onConfirm={handleCancelModalDiscard}
onCancel={handleCancelModalCancel}
/>
open={showCancelModal}
onOpenChange={handleCancelModalOnOpenChange}
onConfirm={handleCancelModalDiscard}
onCancel={handleCancelModalCancel}
/>

View file

@ -118,7 +118,7 @@
bind:value={selectedCharacterId}
>
{#each characters as c}
<option value={c.id}>{c.name}</option>
<option value={c.id}>{c.nickname || c.name}</option>
{/each}
</select>
</div>

View file

@ -179,6 +179,7 @@
name={editPersonaData.name ??
(mode === "edit" ? "Edit Character" : "New Character")}
background="preset-filled-primary-500"
imageClasses="object-cover"
>
<Icons.User size={36} />
</Avatar>
@ -217,7 +218,7 @@
</div>
<button
type="button"
class="btn btn-xs preset-tonal-error mt-1"
class="btn btn-sm preset-tonal-error mt-1"
onclick={() => {
editPersonaData._avatarFile = undefined
editPersonaData._avatar = ""

View file

@ -1,300 +1,333 @@
<script lang="ts">
import skio from "sveltekit-io"
import { onMount } from "svelte"
import { Avatar, FileUpload, Modal } from "@skeletonlabs/skeleton-svelte"
import * as Icons from "@lucide/svelte"
import CharacterForm from "../characterForms/CharacterForm.svelte"
import CharacterUnsavedChangesModal from "../modals/CharacterUnsavedChangesModal.svelte"
import { toaster } from "$lib/client/utils/toaster"
import skio from "sveltekit-io"
import { onMount } from "svelte"
import { Avatar, FileUpload, Modal } from "@skeletonlabs/skeleton-svelte"
import * as Icons from "@lucide/svelte"
import CharacterForm from "../characterForms/CharacterForm.svelte"
import CharacterUnsavedChangesModal from "../modals/CharacterUnsavedChangesModal.svelte"
import { toaster } from "$lib/client/utils/toaster"
interface Props {
onclose?: () => Promise<boolean> | undefined
}
interface Props {
onclose?: () => Promise<boolean> | undefined
}
let { onclose = $bindable() }: Props = $props()
let { onclose = $bindable() }: Props = $props()
const socket = skio.get()
const socket = skio.get()
let charactersList: Sockets.CharactersList.Response["charactersList"] = $state([])
let search = $state("")
let characterId: number | undefined = $state()
let isCreating = $state(false)
let isSafeToCloseCharacterForm = $state(true)
let showDeleteModal = $state(false)
let characterToDelete: number | undefined = $state(undefined)
let showUnsavedChangesModal = $state(false)
let confirmCloseSidebarResolve: ((v: boolean) => void) | null = null
let showImportModal = $state(false)
let charactersList: Sockets.CharactersList.Response["charactersList"] =
$state([])
let search = $state("")
let characterId: number | undefined = $state()
let isCreating = $state(false)
let isSafeToCloseCharacterForm = $state(true)
let showDeleteModal = $state(false)
let characterToDelete: number | undefined = $state(undefined)
let showUnsavedChangesModal = $state(false)
let confirmCloseSidebarResolve: ((v: boolean) => void) | null = null
let showImportModal = $state(false)
let unsavedChanges = $derived.by(() => {
return !isCreating && !characterId ? false : !isSafeToCloseCharacterForm
})
let unsavedChanges = $derived.by(() => {
return !isCreating && !characterId ? false : !isSafeToCloseCharacterForm
})
$effect(() => {
console.log("Unsaved changes:", unsavedChanges)
})
$effect(() => {
console.log("Unsaved changes:", unsavedChanges)
})
socket.on("charactersList", (msg: Sockets.CharactersList.Response) => {
charactersList = msg.charactersList
})
socket.on("charactersList", (msg: Sockets.CharactersList.Response) => {
charactersList = msg.charactersList
})
// Filtered list
let filteredCharacters: Sockets.CharactersList.Response["charactersList"] = $derived.by(() => {
if (!search) return charactersList
return charactersList.filter(
(c: Sockets.CharactersList.Response["charactersList"][0]) =>
c.name!.toLowerCase().includes(search.toLowerCase()) ||
(c.description && c.description.toLowerCase().includes(search.toLowerCase()))
)
})
// Filtered list
let filteredCharacters: Sockets.CharactersList.Response["charactersList"] =
$derived.by(() => {
if (!search) return charactersList
return charactersList.filter(
(c: Sockets.CharactersList.Response["charactersList"][0]) =>
c.name!.toLowerCase().includes(search.toLowerCase()) ||
(c.description &&
c.description
.toLowerCase()
.includes(search.toLowerCase()))
)
})
function handleCreateClick() {
isCreating = true
}
function handleCreateClick() {
isCreating = true
}
function handleEditClick(id: number) {
characterId = id
}
function handleEditClick(id: number) {
characterId = id
}
function closeCharacterForm() {
isCreating = false
characterId = undefined
}
function closeCharacterForm() {
isCreating = false
characterId = undefined
}
function handleDeleteClick(id: number) {
characterToDelete = id
showDeleteModal = true
}
function handleDeleteClick(id: number) {
characterToDelete = id
showDeleteModal = true
}
function confirmDelete() {
if (characterToDelete !== undefined) {
socket.emit("deleteCharacter", { characterId: characterToDelete })
}
showDeleteModal = false
characterToDelete = undefined
// Optionally, close form if deleting from edit view
if (characterId === characterToDelete) closeCharacterForm()
}
function confirmDelete() {
if (characterToDelete !== undefined) {
socket.emit("deleteCharacter", { characterId: characterToDelete })
}
showDeleteModal = false
characterToDelete = undefined
// Optionally, close form if deleting from edit view
if (characterId === characterToDelete) closeCharacterForm()
}
function cancelDelete() {
showDeleteModal = false
characterToDelete = undefined
}
function cancelDelete() {
showDeleteModal = false
characterToDelete = undefined
}
async function handleOnClose() {
console.log("unsavedChanges", unsavedChanges)
if (unsavedChanges) {
showUnsavedChangesModal = true
return new Promise<boolean>((resolve) => {
confirmCloseSidebarResolve = resolve
})
} else {
return true
}
}
async function handleOnClose() {
console.log("unsavedChanges", unsavedChanges)
if (unsavedChanges) {
showUnsavedChangesModal = true
return new Promise<boolean>((resolve) => {
confirmCloseSidebarResolve = resolve
})
} else {
return true
}
}
function handleCloseModalDiscard() {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(true)
}
function handleCloseModalDiscard() {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(true)
}
function handleCloseModalCancel() {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(false)
}
function handleCloseModalCancel() {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(false)
}
function handleUnsavedChangesOnOpenChange(e: OpenChangeDetails) {
if (!e.open) {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(false)
}
}
function handleUnsavedChangesOnOpenChange(e: OpenChangeDetails) {
if (!e.open) {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(false)
}
}
function handleImportClick() {
showImportModal = true
}
function handleImportClick() {
showImportModal = true
}
async function handleFileImport(details: FileAcceptDetails) {
console.log("File import details:", details)
if (!details.files || details.files.length === 0) return
const file = details.files[0]
const reader = new FileReader()
reader.onload = function (e) {
const base64 = (e.target?.result as string)?.split(",")[1]
if (base64) {
socket.emit("characterCardImport", { file: base64 })
showImportModal = false
}
}
reader.readAsDataURL(file)
showImportModal = false
const req: Sockets.CharacterCardImport.Call = {
file
}
}
async function handleFileImport(details: FileAcceptDetails) {
console.log("File import details:", details)
if (!details.files || details.files.length === 0) return
const file = details.files[0]
const reader = new FileReader()
reader.onload = function (e) {
const base64 = (e.target?.result as string)?.split(",")[1]
if (base64) {
socket.emit("characterCardImport", { file: base64 })
showImportModal = false
}
}
reader.readAsDataURL(file)
showImportModal = false
const req: Sockets.CharacterCardImport.Call = {
file
}
}
function handleCharacterClick(character: Sockets.CharactersList.Response["charactersList"][0]) {
toaster.warning({
title: "Action not implemented"
})
}
function handleCharacterClick(
character: Sockets.CharactersList.Response["charactersList"][0]
) {
toaster.warning({
title: "Action not implemented"
})
}
onMount(() => {
socket.emit("charactersList", {})
onclose = handleOnClose
})
onMount(() => {
socket.emit("charactersList", {})
onclose = handleOnClose
})
</script>
<div class="text-foreground h-full p-4">
{#if isCreating}
<CharacterForm
bind:isSafeToClose={isSafeToCloseCharacterForm}
closeForm={closeCharacterForm}
/>
{:else if characterId}
<CharacterForm
bind:isSafeToClose={isSafeToCloseCharacterForm}
{characterId}
closeForm={closeCharacterForm}
/>
{:else}
<div class="mb-2 flex gap-2">
<button
class="btn btn-sm preset-filled-primary-500"
onclick={handleCreateClick}
title="Create New Character"
>
<Icons.Plus size={16} />
</button>
<button
class="btn btn-sm preset-filled-primary-500"
title="Import Character"
onclick={handleImportClick}
>
<Icons.Upload size={16} />
</button>
<button class="btn btn-sm preset-filled-primary-500" title="Export Character" disabled>
<Icons.Download size={16} />
</button>
</div>
<div class="mb-4 flex items-center gap-2">
<input
type="text"
placeholder="Search characters..."
class="input input-sm bg-background border-muted w-full rounded border"
bind:value={search}
/>
</div>
<div class="flex flex-col gap-2">
{#if filteredCharacters.length === 0}
<div class="text-muted-foreground py-8 text-center">No characters found.</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#each filteredCharacters as c}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="hover:bg-surface-800 flex cursor-pointer items-center gap-2 rounded-lg p-2 transition"
onclick={() => handleCharacterClick(c)}
>
<span class="text-muted-foreground w-[2.5em] text-xs">
#{c.id}
</span>
<Avatar src={c.avatar || ""} size="w-[4em] h-[4em]" name={c.name!}>
<Icons.User size={36} />
</Avatar>
<div class="min-w-0 flex-1">
<div class="truncate font-semibold">{c.name ?? "Unnamed"}</div>
{#if c.description}
<div class="text-muted-foreground truncate text-xs">
{c.description}
</div>
{/if}
</div>
<div class="flex gap-4">
<button
class="btn btn-xs text-primary-500 px-0"
onclick={(e) => {
e.stopPropagation();
handleEditClick(c.id!)
}}
title="Edit Character"
>
<Icons.Edit size={16} />
</button>
<button
class="btn btn-xs text-error-500 px-0"
onclick={(e) => {
e.stopPropagation()
handleDeleteClick(c.id!)
}}
title="Delete Character"
>
<Icons.Trash2 size={16} />
</button>
</div>
</div>
{/each}
{/if}
</div>
{/if}
{#if isCreating}
<CharacterForm
bind:isSafeToClose={isSafeToCloseCharacterForm}
closeForm={closeCharacterForm}
/>
{:else if characterId}
<CharacterForm
bind:isSafeToClose={isSafeToCloseCharacterForm}
{characterId}
closeForm={closeCharacterForm}
/>
{:else}
<div class="mb-2 flex gap-2">
<button
class="btn btn-sm preset-filled-primary-500"
onclick={handleCreateClick}
title="Create New Character"
>
<Icons.Plus size={16} />
</button>
<button
class="btn btn-sm preset-filled-primary-500"
title="Import Character"
onclick={handleImportClick}
>
<Icons.Upload size={16} />
</button>
<button
class="btn btn-sm preset-filled-primary-500"
title="Export Character"
disabled
>
<Icons.Download size={16} />
</button>
</div>
<div class="mb-4 flex items-center gap-2">
<input
type="text"
placeholder="Search characters..."
class="input input-sm bg-background border-muted w-full rounded border"
bind:value={search}
/>
</div>
<div class="flex flex-col gap-2">
{#if filteredCharacters.length === 0}
<div class="text-muted-foreground py-8 text-center">
No characters found.
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#each filteredCharacters as c}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="hover:bg-surface-800 flex cursor-pointer items-center gap-2 rounded-lg p-2 transition"
onclick={() => handleCharacterClick(c)}
>
<span class="text-muted-foreground w-[2.5em] text-xs">
#{c.id}
</span>
<Avatar
src={c.avatar || ""}
size="w-[4em] h-[4em]"
imageClasses="object-cover"
name={c.nickname || c.name!}
>
<Icons.User size={36} />
</Avatar>
<div class="min-w-0 flex-1">
<div class="truncate font-semibold">
{c.nickname || c.name}
</div>
{#if c.description}
<div
class="text-muted-foreground truncate text-xs"
>
{c.description}
</div>
{/if}
</div>
<div class="flex gap-4">
<button
class="btn btn-sm text-primary-500 px-0"
onclick={(e) => {
e.stopPropagation()
handleEditClick(c.id!)
}}
title="Edit Character"
>
<Icons.Edit size={16} />
</button>
<button
class="btn btn-sm text-error-500 px-0"
onclick={(e) => {
e.stopPropagation()
handleDeleteClick(c.id!)
}}
title="Delete Character"
>
<Icons.Trash2 size={16} />
</button>
</div>
</div>
{/each}
{/if}
</div>
{/if}
</div>
<Modal
open={showDeleteModal}
onOpenChange={(e) => (showDeleteModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-screen-sm"
backdropClasses="backdrop-blur-sm"
open={showDeleteModal}
onOpenChange={(e) => (showDeleteModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-screen-sm"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<div class="p-6">
<h2 class="mb-2 text-lg font-bold">Delete Character?</h2>
<p class="mb-4">
Are you sure you want to delete this character? This action cannot be undone.
</p>
<div class="flex justify-end gap-2">
<button class="btn preset-filled-surface-500" onclick={cancelDelete}>Cancel</button>
<button class="btn preset-filled-error-500" onclick={confirmDelete}>Delete</button>
</div>
</div>
{/snippet}
{#snippet content()}
<div class="p-6">
<h2 class="mb-2 text-lg font-bold">Delete Character?</h2>
<p class="mb-4">
Are you sure you want to delete this character? This action
cannot be undone.
</p>
<div class="flex justify-end gap-2">
<button
class="btn preset-filled-surface-500"
onclick={cancelDelete}
>
Cancel
</button>
<button
class="btn preset-filled-error-500"
onclick={confirmDelete}
>
Delete
</button>
</div>
</div>
{/snippet}
</Modal>
<Modal
open={showImportModal}
onOpenChange={(e) => (showImportModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-screen-sm"
backdropClasses="backdrop-blur-sm"
open={showImportModal}
onOpenChange={(e) => (showImportModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-screen-sm"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<div class="p-6">
<h2 class="mb-2 text-lg font-bold">Import Character</h2>
<p class="mb-4">
Import your character card or JSON file here. Make sure the file is in the correct
format.
</p>
<FileUpload
name="example"
accept="image/*"
maxFiles={1}
onFileAccept={handleFileImport}
onFileReject={console.error}
classes="w-full bg-surface-50-950"
/>
<div class="mt-4 flex gap-2">
<button
class="btn preset-filled-surface-500"
onclick={() => (showImportModal = false)}
>
Cancel
</button>
</div>
</div>
{/snippet}
{#snippet content()}
<div class="p-6">
<h2 class="mb-2 text-lg font-bold">Import Character</h2>
<p class="mb-4">
Import your character card or JSON file here. Make sure the file
is in the correct format.
</p>
<FileUpload
name="example"
accept=".png,.apng,.json,.charx,.JSON"
maxFiles={1}
onFileAccept={handleFileImport}
onFileReject={console.error}
classes="w-full bg-surface-50-950"
/>
<div class="mt-4 flex gap-2">
<button
class="btn preset-filled-surface-500"
onclick={() => (showImportModal = false)}
>
Cancel
</button>
</div>
</div>
{/snippet}
</Modal>
<CharacterUnsavedChangesModal
open={showUnsavedChangesModal}
onOpenChange={handleUnsavedChangesOnOpenChange}
onConfirm={handleCloseModalDiscard}
onCancel={handleCloseModalCancel}
/>
open={showUnsavedChangesModal}
onOpenChange={handleUnsavedChangesOnOpenChange}
onConfirm={handleCloseModalDiscard}
onCancel={handleCloseModalCancel}
/>

View file

@ -142,7 +142,8 @@
<Avatar
src={cc.character.avatar || ""}
size="w-[4em] h-[4em]"
name={cc.character.name}
name={cc.character.nickname || cc.character.name}
imageClasses="object-cover"
>
<Icons.User size={36} />
</Avatar>
@ -155,6 +156,7 @@
src={cp.persona.avatar || ""}
size="w-[4em] h-[4em]"
name={cp.persona.name}
imageClasses="object-cover"
>
<Icons.User size={36} />
</Avatar>
@ -183,7 +185,7 @@
</div>
<div class="flex gap-4 ml-auto">
<button
class="btn btn-xs text-primary-500 px-0"
class="btn btn-sm text-primary-500 px-0"
onclick={() => {
handleEditClick(chat.id!)
}}
@ -192,7 +194,7 @@
<Icons.Edit size={16} />
</button>
<button
class="btn btn-xs text-error-500 px-0"
class="btn btn-sm text-error-500 px-0"
onclick={(e) => {
e.stopPropagation()
handleDeleteClick(chat.id!)

View file

@ -1,218 +1,244 @@
<script lang="ts">
import skio from "sveltekit-io"
import { onMount } from "svelte"
import { Avatar, Modal } from "@skeletonlabs/skeleton-svelte"
import * as Icons from "@lucide/svelte"
import PersonaForm from "../personaForms/PersonaForm.svelte"
import PersonaUnsavedChangesModal from "../modals/PersonaUnsavedChangesModal.svelte"
import { toaster } from "$lib/client/utils/toaster"
import skio from "sveltekit-io"
import { onMount } from "svelte"
import { Avatar, Modal } from "@skeletonlabs/skeleton-svelte"
import * as Icons from "@lucide/svelte"
import PersonaForm from "../personaForms/PersonaForm.svelte"
import PersonaUnsavedChangesModal from "../modals/PersonaUnsavedChangesModal.svelte"
import { toaster } from "$lib/client/utils/toaster"
interface Props {
onclose?: () => Promise<boolean> | undefined
}
interface Props {
onclose?: () => Promise<boolean> | undefined
}
let { onclose = $bindable() }: Props = $props()
let { onclose = $bindable() }: Props = $props()
const socket = skio.get()
const socket = skio.get()
let personasList = $state([])
let search = $state("")
let personaId: number | undefined = $state()
let isCreating = $state(false)
let isSafeToClosePersonasForm = $state(true)
let showDeleteModal = $state(false)
let personaToDelete: number | undefined = $state(undefined)
let showUnsavedChangesModal = $state(false)
let confirmCloseSidebarResolve: ((v: boolean) => void) | null = null
let personasList = $state([])
let search = $state("")
let personaId: number | undefined = $state()
let isCreating = $state(false)
let isSafeToClosePersonasForm = $state(true)
let showDeleteModal = $state(false)
let personaToDelete: number | undefined = $state(undefined)
let showUnsavedChangesModal = $state(false)
let confirmCloseSidebarResolve: ((v: boolean) => void) | null = null
onMount(() => {
socket.emit("personasList", {})
onclose = handleOnClose
})
onMount(() => {
socket.emit("personasList", {})
onclose = handleOnClose
})
socket.on("personasList", (msg) => {
personasList = msg.personasList
})
socket.on("personasList", (msg) => {
personasList = msg.personasList
})
let filteredPersonas = $derived.by(() => {
if (!search) return personasList
return personasList.filter(
(p) =>
p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.description && p.description.toLowerCase().includes(search.toLowerCase()))
)
})
let filteredPersonas = $derived.by(() => {
if (!search) return personasList
return personasList.filter(
(p) =>
p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.description &&
p.description.toLowerCase().includes(search.toLowerCase()))
)
})
function handleCreateClick() {
isCreating = true
}
function handleCreateClick() {
isCreating = true
}
function handleEditClick(id: number) {
personaId = id
}
function handleEditClick(id: number) {
personaId = id
}
function closePersonasForm() {
isCreating = false
personaId = undefined
}
function closePersonasForm() {
isCreating = false
personaId = undefined
}
function handleDeleteClick(id: number) {
personaToDelete = id
showDeleteModal = true
}
function handleDeleteClick(id: number) {
personaToDelete = id
showDeleteModal = true
}
function confirmDelete() {
if (personaToDelete !== undefined) {
socket.emit("deletePersona", { id: personaToDelete })
}
showDeleteModal = false
personaToDelete = undefined
if (personaId === personaToDelete) closePersonasForm()
}
function confirmDelete() {
if (personaToDelete !== undefined) {
socket.emit("deletePersona", { id: personaToDelete })
}
showDeleteModal = false
personaToDelete = undefined
if (personaId === personaToDelete) closePersonasForm()
}
function cancelDelete() {
showDeleteModal = false
personaToDelete = undefined
}
function cancelDelete() {
showDeleteModal = false
personaToDelete = undefined
}
async function handleOnClose() {
if (!isSafeToClosePersonasForm) {
showUnsavedChangesModal = true
return new Promise<boolean>((resolve) => {
confirmCloseSidebarResolve = resolve
})
} else {
return true
}
}
async function handleOnClose() {
if (!isSafeToClosePersonasForm) {
showUnsavedChangesModal = true
return new Promise<boolean>((resolve) => {
confirmCloseSidebarResolve = resolve
})
} else {
return true
}
}
function handleCloseModalDiscard() {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(true)
}
function handleCloseModalDiscard() {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(true)
}
function handleCloseModalCancel() {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(false)
}
function handleCloseModalCancel() {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(false)
}
function handleUnsavedChangesOnOpenChange(e: { open: boolean }) {
if (!e.open) {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(false)
}
}
function handleUnsavedChangesOnOpenChange(e: { open: boolean }) {
if (!e.open) {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(false)
}
}
function handlePersonaClick(persona: Sockets.PersonasList.Response["personasList"][0]) {
toaster.warning({
title: "Action not implemented"
})
}
function handlePersonaClick(
persona: Sockets.PersonasList.Response["personasList"][0]
) {
toaster.warning({
title: "Action not implemented"
})
}
</script>
<div class="text-foreground h-full p-4">
{#if isCreating}
<PersonaForm bind:isSafeToClose={isSafeToClosePersonasForm} closeForm={closePersonasForm} />
{:else if personaId}
<PersonaForm
bind:isSafeToClose={isSafeToClosePersonasForm}
{personaId}
closeForm={closePersonasForm}
/>
{:else}
<div class="mb-2 flex gap-2">
<button
class="btn btn-sm preset-filled-primary-500"
onclick={handleCreateClick}
title="Create New Persona"
>
<Icons.Plus size={16} />
</button>
</div>
<div class="mb-4 flex items-center gap-2">
<input
type="text"
placeholder="Search personas..."
class="input input-sm bg-background border-muted w-full rounded border"
bind:value={search}
/>
</div>
<div class="flex flex-col gap-2">
{#if filteredPersonas.length === 0}
<div class="text-muted-foreground py-8 text-center">No personas found.</div>
{:else}
{#each filteredPersonas as p}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="hover:bg-surface-800 flex cursor-pointer items-center gap-2 rounded-lg p-2 transition"
onclick={() => handlePersonaClick(p)}
>
<span class="w-[2.5em] text-xs text-muted-foreground">
#{p.id}
</span>
<Avatar src={p.avatar} size="w-[4em] h-[4em]" name={p.name}>
<Icons.User size={36} />
</Avatar>
<div class="min-w-0 flex-1">
<div class="truncate">{p.name ?? "Unnamed"}</div>
{#if p.description}
<div class="text-muted-foreground truncate text-xs">
{p.description}
</div>
{/if}
</div>
<div class="flex gap-4">
<button
class="btn btn-xs text-primary-500 px-0"
onclick={(e) => {
e.stopPropagation();
handleEditClick(p.id)
}}
title="Edit Persona"
>
<Icons.Edit size={16} />
</button>
<button
class="btn btn-xs text-error-500 px-0"
onclick={(e) => {
e.stopPropagation()
handleDeleteClick(p.id)
}}
title="Delete Personar"
>
<Icons.Trash2 size={16} />
</button>
</div>
</div>
{/each}
{/if}
</div>
{/if}
{#if isCreating}
<PersonaForm
bind:isSafeToClose={isSafeToClosePersonasForm}
closeForm={closePersonasForm}
/>
{:else if personaId}
<PersonaForm
bind:isSafeToClose={isSafeToClosePersonasForm}
{personaId}
closeForm={closePersonasForm}
/>
{:else}
<div class="mb-2 flex gap-2">
<button
class="btn btn-sm preset-filled-primary-500"
onclick={handleCreateClick}
title="Create New Persona"
>
<Icons.Plus size={16} />
</button>
</div>
<div class="mb-4 flex items-center gap-2">
<input
type="text"
placeholder="Search personas..."
class="input input-sm bg-background border-muted w-full rounded border"
bind:value={search}
/>
</div>
<div class="flex flex-col gap-2">
{#if filteredPersonas.length === 0}
<div class="text-muted-foreground py-8 text-center">
No personas found.
</div>
{:else}
{#each filteredPersonas as p}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="hover:bg-surface-800 flex cursor-pointer items-center gap-2 rounded-lg p-2 transition"
onclick={() => handlePersonaClick(p)}
>
<span class="text-muted-foreground w-[2.5em] text-xs">
#{p.id}
</span>
<Avatar
src={p.avatar}
size="w-[4em] h-[4em]"
name={p.name}
imageClasses="object-cover"
>
<Icons.User size={36} />
</Avatar>
<div class="min-w-0 flex-1">
<div class="truncate">{p.name ?? "Unnamed"}</div>
{#if p.description}
<div
class="text-muted-foreground truncate text-xs"
>
{p.description}
</div>
{/if}
</div>
<div class="flex gap-4">
<button
class="btn btn-sm text-primary-500 px-0"
onclick={(e) => {
e.stopPropagation()
handleEditClick(p.id)
}}
title="Edit Persona"
>
<Icons.Edit size={16} />
</button>
<button
class="btn btn-sm text-error-500 px-0"
onclick={(e) => {
e.stopPropagation()
handleDeleteClick(p.id)
}}
title="Delete Personar"
>
<Icons.Trash2 size={16} />
</button>
</div>
</div>
{/each}
{/if}
</div>
{/if}
</div>
<Modal
open={showDeleteModal}
onOpenChange={(e) => (showDeleteModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-screen-sm"
backdropClasses="backdrop-blur-sm"
open={showDeleteModal}
onOpenChange={(e) => (showDeleteModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-screen-sm"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<div class="p-6">
<h2 class="mb-2 text-lg font-bold">Delete Persona?</h2>
<p class="mb-4">
Are you sure you want to delete this character? This action cannot be undone.
</p>
<div class="flex justify-end gap-2">
<button class="btn preset-filled-surface-500" onclick={cancelDelete}>Cancel</button>
<button class="btn preset-filled-error-500" onclick={confirmDelete}>Delete</button>
</div>
</div>
{/snippet}
{#snippet content()}
<div class="p-6">
<h2 class="mb-2 text-lg font-bold">Delete Persona?</h2>
<p class="mb-4">
Are you sure you want to delete this character? This action
cannot be undone.
</p>
<div class="flex justify-end gap-2">
<button
class="btn preset-filled-surface-500"
onclick={cancelDelete}
>
Cancel
</button>
<button
class="btn preset-filled-error-500"
onclick={confirmDelete}
>
Delete
</button>
</div>
</div>
{/snippet}
</Modal>
<PersonaUnsavedChangesModal
open={showUnsavedChangesModal}
onOpenChange={handleUnsavedChangesOnOpenChange}
onConfirm={handleCloseModalDiscard}
onCancel={handleCloseModalCancel}
open={showUnsavedChangesModal}
onOpenChange={handleUnsavedChangesOnOpenChange}
onConfirm={handleCloseModalDiscard}
onCancel={handleCloseModalCancel}
/>

View file

@ -8,377 +8,441 @@ import { TokenCounterOptions } from "$lib/shared/constants/TokenCounters"
import { TokenCounters } from "../utils/TokenCounterManager"
export class OllamaAdapter {
connection: SelectConnection
sampling: SelectSamplingConfig
contextConfig: SelectContextConfig
promptConfig: SelectPromptConfig
chat: SelectChat & {
chatCharacters?: SelectChatCharacter & { character: SelectCharacter }[]
chatPersonas?: SelectChatPersona & { persona: SelectPersona }[]
chatMessages: SelectChatMessage[]
}
connection: SelectConnection
sampling: SelectSamplingConfig
contextConfig: SelectContextConfig
promptConfig: SelectPromptConfig
chat: SelectChat & {
chatCharacters?: SelectChatCharacter & { character: SelectCharacter }[]
chatPersonas?: SelectChatPersona & { persona: SelectPersona }[]
chatMessages: SelectChatMessage[]
}
private _client?: Ollama;
private _tokenCounter?: TokenCounters;
private _client?: Ollama
private _tokenCounter?: TokenCounters
constructor({
connection,
sampling,
contextConfig,
promptConfig,
chat
}: {
connection: SelectConnection
sampling: SelectSamplingConfig
contextConfig: SelectContextConfig
promptConfig: SelectPromptConfig
chat: SelectChat & {
chatCharacters?: SelectChatCharacter & { character: SelectCharacter }[]
chatPersonas?: SelectChatPersona & { persona: SelectPersona }[]
chatMessages: SelectChatMessage[]
}
}) {
this.connection = connection
this.sampling = sampling
this.contextConfig = contextConfig
this.promptConfig = promptConfig
this.chat = chat
}
constructor({
connection,
sampling,
contextConfig,
promptConfig,
chat
}: {
connection: SelectConnection
sampling: SelectSamplingConfig
contextConfig: SelectContextConfig
promptConfig: SelectPromptConfig
chat: SelectChat & {
chatCharacters?: SelectChatCharacter &
{ character: SelectCharacter }[]
chatPersonas?: SelectChatPersona & { persona: SelectPersona }[]
chatMessages: SelectChatMessage[]
}
}) {
this.connection = connection
this.sampling = sampling
this.contextConfig = contextConfig
this.promptConfig = promptConfig
this.chat = chat
}
// --- Default Ollama connection config ---
static connectionDefaults = {
baseUrl: "http://localhost:11434/",
promptFormat: PromptFormats.VICUNA,
tokenCounter: TokenCounterOptions.ESTIMATE,
extraJson: {
stream: true,
think: false,
keepAlive: "300ms",
raw: true
}
}
// --- Default Ollama connection config ---
static connectionDefaults = {
baseUrl: "http://localhost:11434/",
promptFormat: PromptFormats.VICUNA,
tokenCounter: TokenCounterOptions.ESTIMATE,
extraJson: {
stream: true,
think: false,
keepAlive: "300ms",
raw: true
}
}
static defaultContextLimit = 2048
static contextThresholdPercent = 0.9 // we don't want to hit the limit, so we stop at 90% of the context size
static defaultContextLimit = 2048
static contextThresholdPercent = 0.9 // we don't want to hit the limit, so we stop at 90% of the context size
// --- Context builders ---
contextBuildCharacterDescription(character: any): string | undefined {
if (!character?.description) return undefined
return `**Assistant character {{char}}'s description:**\n\n${character.description}\n\n`
}
contextBuildCharacterPersonality(character: any): string | undefined {
if (!character?.personality) return undefined
return `**Assistant character {{char}}'s personality:**\n\n${character.personality}\n\n`
}
contextBuildCharacterScenario(character: any): string | undefined {
if (!character?.scenario) return undefined
return `**Assistant character {{char}}'s scenario:**\n\n${character.scenario}\n\n`
}
contextBuildCharacterWiBefore(): string | undefined {
return undefined
}
contextBuildCharacterWiAfter(): string | undefined {
return undefined
}
contextBuildPersonaDescription(persona: any): string | undefined {
if (!persona?.description) return undefined
return `**User character {{user}}'s description:**\n\n${persona.description}\n\n`
}
contextBuildSystemPrompt(): string | undefined {
if (!this.promptConfig.systemPrompt) return undefined
return `**Instructions:**\n\n${this.promptConfig.systemPrompt}\n\n`
}
// --- Context builders ---
contextBuildCharacterDescription(character: any): string | undefined {
if (!character?.description) return undefined
return `**Assistant character {{char}}'s description:**\n\n${character.description}\n\n`
}
contextBuildCharacterPersonality(character: any): string | undefined {
if (!character?.personality) return undefined
return `**Assistant character {{char}}'s personality:**\n\n${character.personality}\n\n`
}
contextBuildCharacterScenario(character: any): string | undefined {
if (!character?.scenario) return undefined
return `**Assistant character {{char}}'s scenario:**\n\n${character.scenario}\n\n`
}
contextBuildCharacterWiBefore(): string | undefined {
return undefined
}
contextBuildCharacterWiAfter(): string | undefined {
return undefined
}
contextBuildPersonaDescription(persona: any): string | undefined {
if (!persona?.description) return undefined
return `**User character {{user}}'s description:**\n\n${persona.description}\n\n`
}
contextBuildSystemPrompt(): string | undefined {
if (!this.promptConfig.systemPrompt) return undefined
return `**Instructions:**\n\n${this.promptConfig.systemPrompt}\n\n`
}
// --- SamplingConfig mapping ---
static ollamaKeyMap: Record<string, string> = {
temperature: "temperature",
topP: "top_p",
topK: "top_k",
repetitionPenalty: "repetition_penalty",
minP: "min_p",
tfs: "tfs",
typicalP: "typical_p",
mirostat: "mirostat",
mirostatTau: "mirostat_tau",
mirostatEta: "mirostat_eta",
penaltyAlpha: "penalty_alpha",
frequencyPenalty: "frequency_penalty",
presencePenalty: "presence_penalty",
responseTokens: "num_predict",
contextTokens: "num_ctx",
noRepeatNgramSize: "no_repeat_ngram_size",
numBeams: "num_beams",
lengthPenalty: "length_penalty",
minLength: "min_length",
encoderRepetitionPenalty: "encoder_repetition_penalty",
freqPen: "freq_pen",
presencePen: "presence_pen",
skew: "skew",
doSample: "do_sample",
earlyStopping: "early_stopping",
dynatemp: "dynatemp",
minTemp: "min_temp",
maxTemp: "max_temp",
dynatempExponent: "dynatemp_exponent",
smoothingFactor: "smoothing_factor",
smoothingCurve: "smoothing_curve",
dryAllowedLength: "dry_allowed_length",
dryMultiplier: "dry_multiplier",
dryBase: "dry_base",
dryPenaltyLastN: "dry_penalty_last_n",
maxTokensSecond: "max_tokens_second",
seed: "seed",
addBosToken: "add_bos_token",
banEosToken: "ban_eos_token",
skipSpecialTokens: "skip_special_tokens",
includeReasoning: "include_reasoning",
streaming: "streaming", // Not sent to Ollama, handled separately
mirostatMode: "mirostat_mode",
xtcThreshold: "xtc_threshold",
xtcProbability: "xtc_probability",
nsigma: "nsigma",
speculativeNgram: "speculative_ngram",
guidanceScale: "guidance_scale",
etaCutoff: "eta_cutoff",
epsilonCutoff: "epsilon_cutoff",
repPenRange: "rep_pen_range",
repPenDecay: "rep_pen_decay",
repPenSlope: "rep_pen_slope",
logitBias: "logit_bias",
bannedTokens: "banned_tokens"
}
// --- SamplingConfig mapping ---
static ollamaKeyMap: Record<string, string> = {
temperature: "temperature",
topP: "top_p",
topK: "top_k",
repetitionPenalty: "repetition_penalty",
minP: "min_p",
tfs: "tfs",
typicalP: "typical_p",
mirostat: "mirostat",
mirostatTau: "mirostat_tau",
mirostatEta: "mirostat_eta",
penaltyAlpha: "penalty_alpha",
frequencyPenalty: "frequency_penalty",
presencePenalty: "presence_penalty",
responseTokens: "num_predict",
contextTokens: "num_ctx",
noRepeatNgramSize: "no_repeat_ngram_size",
numBeams: "num_beams",
lengthPenalty: "length_penalty",
minLength: "min_length",
encoderRepetitionPenalty: "encoder_repetition_penalty",
freqPen: "freq_pen",
presencePen: "presence_pen",
skew: "skew",
doSample: "do_sample",
earlyStopping: "early_stopping",
dynatemp: "dynatemp",
minTemp: "min_temp",
maxTemp: "max_temp",
dynatempExponent: "dynatemp_exponent",
smoothingFactor: "smoothing_factor",
smoothingCurve: "smoothing_curve",
dryAllowedLength: "dry_allowed_length",
dryMultiplier: "dry_multiplier",
dryBase: "dry_base",
dryPenaltyLastN: "dry_penalty_last_n",
maxTokensSecond: "max_tokens_second",
seed: "seed",
addBosToken: "add_bos_token",
banEosToken: "ban_eos_token",
skipSpecialTokens: "skip_special_tokens",
includeReasoning: "include_reasoning",
streaming: "streaming", // Not sent to Ollama, handled separately
mirostatMode: "mirostat_mode",
xtcThreshold: "xtc_threshold",
xtcProbability: "xtc_probability",
nsigma: "nsigma",
speculativeNgram: "speculative_ngram",
guidanceScale: "guidance_scale",
etaCutoff: "eta_cutoff",
epsilonCutoff: "epsilon_cutoff",
repPenRange: "rep_pen_range",
repPenDecay: "rep_pen_decay",
repPenSlope: "rep_pen_slope",
logitBias: "logit_bias",
bannedTokens: "banned_tokens"
}
mapSamplingConfig(): Record<string, any> {
const result: Record<string, any> = {}
for (const [key, value] of Object.entries(this.sampling)) {
if (key.endsWith("Enabled")) continue
const enabledKey = key + "Enabled"
if ((this.sampling as any)[enabledKey] === false) continue
if ((this.constructor as typeof OllamaAdapter).ollamaKeyMap[key]) {
if (key === "streaming") continue
result[(this.constructor as typeof OllamaAdapter).ollamaKeyMap[key]] = value
}
}
return result
}
mapSamplingConfig(): Record<string, any> {
const result: Record<string, any> = {}
for (const [key, value] of Object.entries(this.sampling)) {
if (key.endsWith("Enabled")) continue
const enabledKey = key + "Enabled"
if ((this.sampling as any)[enabledKey] === false) continue
if ((this.constructor as typeof OllamaAdapter).ollamaKeyMap[key]) {
if (key === "streaming") continue
result[
(this.constructor as typeof OllamaAdapter).ollamaKeyMap[key]
] = value
}
}
return result
}
// --- API helpers ---
static async testConnection(connection: SelectConnection): Promise<{ ok: boolean; error?: string }> {
try {
const ollama = new Ollama({
host: connection.baseUrl
})
const res = await ollama.list()
if (res && Array.isArray(res.models)) {
return { ok: true }
} else {
console.log("Ollama testConnection response:", res)
return { ok: false, error: "Unexpected response format from Ollama API" }
}
} catch (e: any) {
console.error("Ollama testConnection error:", e)
return { ok: false, error: e.message || String(e) }
}
}
// --- API helpers ---
static async testConnection(
connection: SelectConnection
): Promise<{ ok: boolean; error?: string }> {
try {
const ollama = new Ollama({
host: connection.baseUrl
})
const res = await ollama.list()
if (res && Array.isArray(res.models)) {
return { ok: true }
} else {
console.log("Ollama testConnection response:", res)
return {
ok: false,
error: "Unexpected response format from Ollama API"
}
}
} catch (e: any) {
console.error("Ollama testConnection error:", e)
return { ok: false, error: e.message || String(e) }
}
}
static async listModels(connection: SelectConnection): Promise<{ models: any[]; error?: string }> {
try {
const ollama = new Ollama({
host: connection.baseUrl
})
const res = await ollama.list()
if (res && Array.isArray(res.models)) {
return { models: res.models }
} else {
console.log("Ollama listModels response:", res)
return { models: [], error: "Unexpected response format from Ollama API" }
}
} catch (e: any) {
console.error("Ollama listModels error:", e)
return { models: [], error: e.message || String(e) }
}
}
static async listModels(
connection: SelectConnection
): Promise<{ models: any[]; error?: string }> {
try {
const ollama = new Ollama({
host: connection.baseUrl
})
const res = await ollama.list()
if (res && Array.isArray(res.models)) {
return { models: res.models }
} else {
console.log("Ollama listModels response:", res)
return {
models: [],
error: "Unexpected response format from Ollama API"
}
}
} catch (e: any) {
console.error("Ollama listModels error:", e)
return { models: [], error: e.message || String(e) }
}
}
// --- Prompt construction ---
async compilePrompt(): Promise<[string, number, number, number, number]> {
const characterName = this.chat.chatCharacters?.[0]?.character?.name || "assistant"
const persona = this.chat.chatPersonas?.[0]?.persona
const personaName = persona?.name || "user"
const character = this.chat.chatCharacters?.[0]?.character
const systemCtxData: Record<string, string | undefined> = {
char: characterName,
character: characterName,
user: personaName,
persona: this.contextBuildPersonaDescription(persona),
personaDescription: this.contextBuildPersonaDescription(persona),
description: this.contextBuildCharacterDescription(character),
personality: this.contextBuildCharacterPersonality(character),
scenario: this.contextBuildCharacterScenario(character),
wiBefore: this.contextBuildCharacterWiBefore(),
wiAfter: this.contextBuildCharacterWiAfter(),
system: this.contextBuildSystemPrompt()
}
const systemTemplate = this.contextConfig.template || "{{system}}"
const renderedSystemBlock = Handlebars.compile(systemTemplate)(systemCtxData)
const systemBlock = PromptBlockFormatter.makeBlock({
format: this.connection.promptFormat || "chatml",
role: "system",
content: renderedSystemBlock
})
// --- Prompt construction ---
async compilePrompt(): Promise<[string, number, number, number, number]> {
const characterName =
this.chat.chatCharacters?.[0]?.character?.nickname ||
this.chat.chatCharacters?.[0]?.character?.name ||
"assistant"
const persona = this.chat.chatPersonas?.[0]?.persona
const personaName = persona?.name || "user"
const character = this.chat.chatCharacters?.[0]?.character
const systemCtxData: Record<string, string | undefined> = {
char: characterName,
character: characterName,
user: personaName,
persona: this.contextBuildPersonaDescription(persona),
personaDescription: this.contextBuildPersonaDescription(persona),
description: this.contextBuildCharacterDescription(character),
personality: this.contextBuildCharacterPersonality(character),
scenario: this.contextBuildCharacterScenario(character),
wiBefore: this.contextBuildCharacterWiBefore(),
wiAfter: this.contextBuildCharacterWiAfter(),
system: this.contextBuildSystemPrompt()
}
const systemTemplate = this.contextConfig.template || "{{system}}"
const renderedSystemBlock =
Handlebars.compile(systemTemplate)(systemCtxData)
const systemBlock = PromptBlockFormatter.makeBlock({
format: this.connection.promptFormat || "chatml",
role: "system",
content: renderedSystemBlock
})
// --- Context window logic ---
const messages = this.chat.chatMessages.filter((msg: SelectChatMessage) => !msg.isHidden)
const totalMessages = messages.length
const reversed = [...messages].reverse()
const promptBlocks: string[] = [systemBlock]
const messageBlocks: string[] = []
let tokenCounter: any
let tokenLimit: number
let contextThreshold: number
let totalTokens = 0
let alwaysInclude = 2 // Always include the 2 most recent messages
tokenCounter = this.getTokenCounter()
if (this.sampling.contextTokensEnabled && typeof this.sampling.contextTokens === 'number') {
tokenLimit = this.sampling.contextTokens
} else {
tokenLimit = (this.constructor as typeof OllamaAdapter).defaultContextLimit
}
contextThreshold = Math.floor(tokenLimit * (this.constructor as typeof OllamaAdapter).contextThresholdPercent)
let includedMessages = 0
for (let i = 0; i < reversed.length; i++) {
const msg = reversed[i]
const block = PromptBlockFormatter.makeBlock({
format: this.connection.promptFormat || "chatml",
role: msg.role! || "assistant",
content: `[{{${msg.role === "user" ? "user" : msg.role === "assistant" ? "char" : msg.role === "system" ? "system" : "system"}}}]:\n${msg.content}`
})
messageBlocks.unshift(block)
if (i < alwaysInclude) {
includedMessages++
continue
}
const currentPrompt = [systemBlock, ...messageBlocks].join("\n\n")
const tokens = tokenCounter.countTokens(currentPrompt)
if (tokens > tokenLimit) {
messageBlocks.shift()
break
}
if (tokens > contextThreshold) {
includedMessages++
break
}
includedMessages++
}
promptBlocks.push(...messageBlocks)
if (this.contextConfig.alwaysForceName) {
promptBlocks.push(
PromptBlockFormatter.makeBlock({
format: this.connection.promptFormat || "chatml",
role: "assistant",
content: "{{char}}:",
includeClose: false
})
)
}
const prompt = Handlebars.compile(promptBlocks.join("\n\n"))(systemCtxData)
totalTokens = tokenCounter.countTokens(prompt)
return [prompt.trim(), totalTokens, tokenLimit, includedMessages, totalMessages]
}
// --- Context window logic ---
const messages = this.chat.chatMessages.filter(
(msg: SelectChatMessage) => !msg.isHidden
)
const totalMessages = messages.length
const reversed = [...messages].reverse()
const promptBlocks: string[] = [systemBlock]
const messageBlocks: string[] = []
let tokenCounter: any
let tokenLimit: number
let contextThreshold: number
let totalTokens = 0
let alwaysInclude = 2 // Always include the 2 most recent messages
tokenCounter = this.getTokenCounter()
if (
this.sampling.contextTokensEnabled &&
typeof this.sampling.contextTokens === "number"
) {
tokenLimit = this.sampling.contextTokens
} else {
tokenLimit = (this.constructor as typeof OllamaAdapter)
.defaultContextLimit
}
contextThreshold = Math.floor(
tokenLimit *
(this.constructor as typeof OllamaAdapter)
.contextThresholdPercent
)
let includedMessages = 0
for (let i = 0; i < reversed.length; i++) {
const msg = reversed[i]
const block = PromptBlockFormatter.makeBlock({
format: this.connection.promptFormat || "chatml",
role: msg.role! || "assistant",
content: `[{{${msg.role === "user" ? "user" : msg.role === "assistant" ? "char" : msg.role === "system" ? "system" : "system"}}}]:\n${msg.content}`
})
messageBlocks.unshift(block)
if (i < alwaysInclude) {
includedMessages++
continue
}
const currentPrompt = [systemBlock, ...messageBlocks].join("\n\n")
const tokens = tokenCounter.countTokens(currentPrompt)
if (tokens > tokenLimit) {
messageBlocks.shift()
break
}
if (tokens > contextThreshold) {
includedMessages++
break
}
includedMessages++
}
promptBlocks.push(...messageBlocks)
if (this.contextConfig.alwaysForceName) {
promptBlocks.push(
PromptBlockFormatter.makeBlock({
format: this.connection.promptFormat || "chatml",
role: "assistant",
content: "{{char}}:",
includeClose: false
})
)
}
const prompt = Handlebars.compile(promptBlocks.join("\n\n"))(
systemCtxData
)
totalTokens = tokenCounter.countTokens(prompt)
return [
prompt.trim(),
totalTokens,
tokenLimit,
includedMessages,
totalMessages
]
}
// --- Ollama client instance ---
getClient() {
if (!this._client) {
const host = this.connection.baseUrl || OllamaAdapter.connectionDefaults.baseUrl;
this._client = new Ollama({ host });
}
return this._client;
}
// --- Ollama client instance ---
getClient() {
if (!this._client) {
const host =
this.connection.baseUrl ||
OllamaAdapter.connectionDefaults.baseUrl
this._client = new Ollama({ host })
}
return this._client
}
getTokenCounter() {
if (!this._tokenCounter) {
this._tokenCounter = new TokenCounters(this.connection.tokenCounter || TokenCounterOptions.ESTIMATE);
}
return this._tokenCounter;
}
getTokenCounter() {
if (!this._tokenCounter) {
this._tokenCounter = new TokenCounters(
this.connection.tokenCounter || TokenCounterOptions.ESTIMATE
)
}
return this._tokenCounter
}
static mapRole(role: string): string {
if (role === "system") return "system"
if (role === "assistant" || role === "bot") return "assistant"
return "user"
}
static mapRole(role: string): string {
if (role === "system") return "system"
if (role === "assistant" || role === "bot") return "assistant"
return "user"
}
async generate(): Promise<string | ((cb: (chunk: string) => void) => Promise<void>)> {
const model = this.connection.model ?? OllamaAdapter.connectionDefaults.baseUrl
const stream = this.connection!.extraJson?.stream || false
const think = this.connection!.extraJson?.think || false
const keep_alive = this.connection!.extraJson?.keepAlive || "300ms"
const raw = this.connection!.extraJson?.raw || false
if (typeof model !== "string") throw new Error("OllamaAdapter: model must be a string")
async generate(): Promise<
string | ((cb: (chunk: string) => void) => Promise<void>)
> {
const model =
this.connection.model ?? OllamaAdapter.connectionDefaults.baseUrl
const stream = this.connection!.extraJson?.stream || false
const think = this.connection!.extraJson?.think || false
const keep_alive = this.connection!.extraJson?.keepAlive || "300ms"
const raw = this.connection!.extraJson?.raw || false
if (typeof model !== "string")
throw new Error("OllamaAdapter: model must be a string")
// Prepare stop strings for Ollama
const stopStrings = StopStrings.get(this.connection.promptFormat || "chatml")
const characterName = this.chat.chatCharacters?.[0]?.character?.name || "assistant"
const personaName = this.chat.chatPersonas?.[0]?.persona?.name || "user"
const stopContext: Record<string, string> = { char: characterName, user: personaName }
const stop = stopStrings.map(str => Handlebars.compile(str)(stopContext))
// Prepare stop strings for Ollama
const stopStrings = StopStrings.get(
this.connection.promptFormat || "chatml"
)
const characterName =
this.chat.chatCharacters?.[0]?.character?.nickname ||
this.chat.chatCharacters?.[0]?.character?.nickname ||
this.chat.chatCharacters?.[0]?.character?.name ||
"assistant"
const personaName = this.chat.chatPersonas?.[0]?.persona?.name || "user"
const stopContext: Record<string, string> = {
char: characterName,
user: personaName
}
const stop = stopStrings.map((str) =>
Handlebars.compile(str)(stopContext)
)
// Await the prompt and token count
const [prompt, totalTokens, tokenLimit] = await this.compilePrompt()
// Await the prompt and token count
const [prompt, totalTokens, tokenLimit] = await this.compilePrompt()
const req = {
model,
prompt,
stream,
think,
raw,
keep_alive,
options: {
...this.mapSamplingConfig(),
stop
}
}
if (stream) {
return async (cb: (chunk: string) => void) => {
let content = ""
try {
const ollama = this.getClient()
const result = await ollama.generate({ ...req, stream: true })
for await (const part of result) {
if (part.response) {
content += part.response
cb(part.response)
}
}
// No need to apply stop strings here, Ollama will handle it
} catch (e: any) {
cb("FAILURE: " + (e.message || String(e)))
}
}
} else {
return (async () => {
let content = ""
try {
const ollama = this.getClient()
const result = await ollama.generate({ ...req, stream: false })
if (result && typeof result === "object" && "response" in result) {
content = result.response || ""
// No need to apply stop strings here, Ollama will handle it
return content
} else {
return "FAILURE: Unexpected Ollama result type"
}
} catch (e: any) {
return "FAILURE: " + (e.message || String(e))
}
})()
}
}
const req = {
model,
prompt,
stream,
think,
raw,
keep_alive,
options: {
...this.mapSamplingConfig(),
stop
}
}
if (stream) {
return async (cb: (chunk: string) => void) => {
let content = ""
try {
const ollama = this.getClient()
const result = await ollama.generate({
...req,
stream: true
})
for await (const part of result) {
if (part.response) {
content += part.response
cb(part.response)
}
}
// No need to apply stop strings here, Ollama will handle it
} catch (e: any) {
cb("FAILURE: " + (e.message || String(e)))
}
}
} else {
return (async () => {
let content = ""
try {
const ollama = this.getClient()
const result = await ollama.generate({
...req,
stream: false
})
if (
result &&
typeof result === "object" &&
"response" in result
) {
content = result.response || ""
// No need to apply stop strings here, Ollama will handle it
return content
} else {
return "FAILURE: Unexpected Ollama result type"
}
} catch (e: any) {
return "FAILURE: " + (e.message || String(e))
}
})()
}
}
// --- Abort in-flight Ollama request ---
abort() {
const client = this.getClient();
if (typeof client.abort === 'function') {
client.abort();
}
}
// --- Abort in-flight Ollama request ---
abort() {
const client = this.getClient()
if (typeof client.abort === "function") {
client.abort()
}
}
}

View file

@ -1,15 +1,31 @@
import { updated } from '$app/state';
import { relations } from 'drizzle-orm';
import { sqliteTable, integer, text, numeric, real, blob, SQLiteBoolean } from 'drizzle-orm/sqlite-core';
import { TokenCounterManager } from '../utils/TokenCounterManager';
import { updated } from "$app/state"
import { relations, sql } from "drizzle-orm"
import {
sqliteTable,
integer,
text,
numeric,
real,
blob,
SQLiteBoolean
} from "drizzle-orm/sqlite-core"
import { TokenCounterManager } from "../utils/TokenCounterManager"
export const users = sqliteTable('users', {
id: integer('id').primaryKey(),
username: text('username').notNull(),
activeConnectionId: integer('active_connection_id').references(() => connections.id, {onDelete: 'set null'}),
activeSamplingConfigId: integer('active_sampling_id').references(() => samplingConfigs.id, {onDelete: 'set null'}),
activeContextConfigId: integer('active_context_config_id').references(() => contextConfigs.id, {onDelete: 'set null'}),
activePromptConfigId: integer('active_prompt_config_id').references(() => promptConfigs.id, {onDelete: 'set null'}),
export const users = sqliteTable("users", {
id: integer("id").primaryKey(),
username: text("username").notNull(),
activeConnectionId: integer("active_connection_id").references(() => connections.id, {
onDelete: "set null"
}),
activeSamplingConfigId: integer("active_sampling_id").references(() => samplingConfigs.id, {
onDelete: "set null"
}),
activeContextConfigId: integer("active_context_config_id").references(() => contextConfigs.id, {
onDelete: "set null"
}),
activePromptConfigId: integer("active_prompt_config_id").references(() => promptConfigs.id, {
onDelete: "set null"
})
})
export const userRelations = relations(users, ({ many, one }) => ({
@ -31,91 +47,97 @@ export const userRelations = relations(users, ({ many, one }) => ({
fields: [users.activePromptConfigId],
references: [promptConfigs.id]
}),
personas: many(personas),
personas: many(personas)
}))
export const samplingConfigs = sqliteTable('sampling_configs', {
id: integer('id').primaryKey(),
name: text('name').notNull(), // Name for this sampling config (for selection)
isImmutable: integer('is_immutable', {mode: 'boolean'}).default(0), // Is this the built-in config? Then we don't want to allow mutation/deletion
export const samplingConfigs = sqliteTable("sampling_configs", {
id: integer("id").primaryKey(),
name: text("name").notNull(), // Name for this sampling config (for selection)
isImmutable: integer("is_immutable", { mode: "boolean" }).default(0), // Is this the built-in config? Then we don't want to allow mutation/deletion
// Tuned defaults for roleplay:
// More creative and less repetitive
temperature: real('temperature').default(0.7), // Higher = more creative
temperatureEnabled: integer('temperature_enabled', {mode: 'boolean'}).default(true),
temperature: real("temperature").default(0.7), // Higher = more creative
temperatureEnabled: integer("temperature_enabled", { mode: "boolean" }).default(true),
topP: real('top_p').default(0.92), // Lower than 1, encourages diversity but not too random
topPEnabled: integer('top_p_enabled', {mode: 'boolean'}).default(true),
topP: real("top_p").default(0.92), // Lower than 1, encourages diversity but not too random
topPEnabled: integer("top_p_enabled", { mode: "boolean" }).default(true),
topK: integer('top_k').default(80), // Allows more token options for creative replies
topKEnabled: integer('top_k_enabled', {mode: 'boolean'}).default(true),
topK: integer("top_k").default(80), // Allows more token options for creative replies
topKEnabled: integer("top_k_enabled", { mode: "boolean" }).default(true),
repetitionPenalty: real('repetition_penalty').default(1.15), // Slightly encourages less repetition but not too harsh
repetitionPenaltyEnabled: integer('repetition_penalty_enabled', {mode: 'boolean'}).default(true),
repetitionPenalty: real("repetition_penalty").default(1.15), // Slightly encourages less repetition but not too harsh
repetitionPenaltyEnabled: integer("repetition_penalty_enabled", { mode: "boolean" }).default(
true
),
frequencyPenalty: real('frequency_penalty').default(0.2), // Mild penalty for repetitive phrases
frequencyPenaltyEnabled: integer('frequency_penalty_enabled', {mode: 'boolean'}).default(true),
frequencyPenalty: real("frequency_penalty").default(0.2), // Mild penalty for repetitive phrases
frequencyPenaltyEnabled: integer("frequency_penalty_enabled", { mode: "boolean" }).default(
true
),
presencePenalty: real('presence_penalty').default(0.6), // Encourage new topics and freshness
presencePenaltyEnabled: integer('presence_penalty_enabled', {mode: 'boolean'}).default(true),
presencePenalty: real("presence_penalty").default(0.6), // Encourage new topics and freshness
presencePenaltyEnabled: integer("presence_penalty_enabled", { mode: "boolean" }).default(true),
responseTokens: integer('response_tokens').default(512), // Allow longer, richer replies
responseTokensEnabled: integer('response_tokens_enabled', {mode: 'boolean'}).default(true),
responseTokensUnlocked: integer('response_tokens_unlocked', {mode: 'boolean'}).default(false), // Dynamic length allowed
responseTokens: integer("response_tokens").default(512), // Allow longer, richer replies
responseTokensEnabled: integer("response_tokens_enabled", { mode: "boolean" }).default(true),
responseTokensUnlocked: integer("response_tokens_unlocked", { mode: "boolean" }).default(false), // Dynamic length allowed
contextTokens: integer('context_tokens').default(4096), // Keep more conversation in memory/context
contextTokensEnabled: integer('context_tokens_enabled', {mode: 'boolean'}).default(true),
contextTokensUnlocked: integer('context_tokens_unlocked', {mode: 'boolean'}).default(false), // Allow for context window expansion
contextTokens: integer("context_tokens").default(4096), // Keep more conversation in memory/context
contextTokensEnabled: integer("context_tokens_enabled", { mode: "boolean" }).default(true),
contextTokensUnlocked: integer("context_tokens_unlocked", { mode: "boolean" }).default(false), // Allow for context window expansion
seed: integer('seed').default(-1), // -1 for random, can be used for deterministic sampling
seedEnabled: integer('seed_enabled', {mode: 'boolean'}).default(false),
seed: integer("seed").default(-1), // -1 for random, can be used for deterministic sampling
seedEnabled: integer("seed_enabled", { mode: "boolean" }).default(false)
})
export const samplingRelations = relations(samplingConfigs, () => ({}))
export const connections = sqliteTable('connections', {
id: integer('id').primaryKey(),
name: text('name').notNull(), // Connection name (e.g., ollama, llama, chatgpt)
type: text('type').notNull(), // Connection type/category (e.g., ollama, chatgpt, etc)
baseUrl: text('base_url'), // Base URL or endpoint for API
model: text('model'), // Model name or identifier
export const connections = sqliteTable("connections", {
id: integer("id").primaryKey(),
name: text("name").notNull(), // Connection name (e.g., ollama, llama, chatgpt)
type: text("type").notNull(), // Connection type/category (e.g., ollama, chatgpt, etc)
baseUrl: text("base_url"), // Base URL or endpoint for API
model: text("model"), // Model name or identifier
// Ollama-specific options
extraJson: text('extra_json', { mode: 'json' }).$type<Record<string, any>>(), // Additional JSON options for the connections, api keys, etc.
tokenCounter: text('token_counter').notNull().default("estimate"),
promptFormat: text('prompt_format').default('vicuna')
extraJson: text("extra_json", { mode: "json" }).$type<Record<string, any>>(), // Additional JSON options for the connections, api keys, etc.
tokenCounter: text("token_counter").notNull().default("estimate"),
promptFormat: text("prompt_format").default("vicuna")
})
export const connectionsRelations = relations(connections, () => ({}))
export const contextConfigs = sqliteTable('context_configs', {
id: integer('id').primaryKey(),
isImmutable: integer('is_immutable', {mode: "boolean"}).default(true),
name: text('name').notNull(),
template: text('template'), // Sillytavern storyString
alwaysForceName: integer('always_force_name', {mode: 'boolean'}).default(true), // Always force name2
export const contextConfigs = sqliteTable("context_configs", {
id: integer("id").primaryKey(),
isImmutable: integer("is_immutable", { mode: "boolean" }).default(true),
name: text("name").notNull(),
template: text("template"), // Sillytavern storyString
alwaysForceName: integer("always_force_name", { mode: "boolean" }).default(true) // Always force name2
})
export const contextConfigsRelations = relations(contextConfigs, () => ({}))
export const promptConfigs = sqliteTable('prompt_configs', {
id: integer('id').primaryKey(),
isImmutable: integer('is_immutable', {mode: "boolean"}).default(true),
name: text('name').notNull(),
systemPrompt: text('system_prompt'), // Maps to sillytavern sysPrompt.content
export const promptConfigs = sqliteTable("prompt_configs", {
id: integer("id").primaryKey(),
isImmutable: integer("is_immutable", { mode: "boolean" }).default(true),
name: text("name").notNull(),
systemPrompt: text("system_prompt") // Maps to sillytavern sysPrompt.content
})
export const promptConfigsRelations = relations(promptConfigs, () => ({}))
export const lorebooks = sqliteTable('lorebooks', {
id: integer('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, {onDelete: 'cascade'}), // FK to users.id
name: text('name').notNull(), // Lorebook name
description: text('description'), // Lorebook description
tags: text('tags'), // JSON array of tags
entries: text('entries'), // JSON array of lorebook entries (for compatibility with SillyTavern)
metadata: text('metadata'), // JSON object for any extra SillyTavern/world/lorebook fields
createdAt: text('created_at'), // ISO date string
updatedAt: text('updated_at'), // ISO date string
export const lorebooks = sqliteTable("lorebooks", {
id: integer("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }), // FK to users.id
name: text("name").notNull(), // Lorebook name
description: text("description"), // Lorebook description
tags: text("tags"), // JSON array of tags
entries: text("entries"), // JSON array of lorebook entries (for compatibility with SillyTavern)
metadata: text("metadata"), // JSON object for any extra SillyTavern/world/lorebook fields
createdAt: text("created_at"), // ISO date string
updatedAt: text("updated_at") // ISO date string
})
export const lorebooksRelations = relations(lorebooks, ({ many, one }) => ({
@ -126,40 +148,42 @@ export const lorebooksRelations = relations(lorebooks, ({ many, one }) => ({
})
}))
export const lorebookEntries = sqliteTable('lorebook_entries', {
id: integer('id').primaryKey(),
lorebookId: integer('lorebook_id').notNull().references(() => lorebooks.id, {onDelete: 'cascade'}), // FK to lorebooks.id
key: text('key'), // JSON array of keys
keySecondary: text('key_secondary'), // JSON array of secondary keys
comment: text('comment'),
content: text('content'),
constant: integer('constant', {mode: "boolean"}).default(false), // Is this entry a constant value?
vectorized: integer('vectorized'),
selective: integer('selective'),
selectiveLogic: integer('selective_logic'),
addMemo: integer('add_memo'),
order: integer('order'),
position: integer('position'),
disable: integer('disable', {mode: "boolean"}).default(false), // Is this entry disabled?
excludeRecursion: integer('exclude_recursion'),
preventRecursion: integer('prevent_recursion'),
delayUntilRecursion: integer('delay_until_recursion'),
probability: integer('probability'),
useProbability: integer('use_probability'),
depth: integer('depth'),
group: text('group'),
groupOverride: integer('group_override'),
groupWeight: integer('group_weight'),
scanDepth: integer('scan_depth'),
caseSensitive: integer('case_sensitive'),
matchWholeWords: integer('match_whole_words'),
useGroupScoring: integer('use_group_scoring'),
automationId: text('automation_id'),
role: text('role'),
sticky: integer('sticky'),
cooldown: integer('cooldown'),
delay: integer('delay'),
displayIndex: integer('display_index'),
export const lorebookEntries = sqliteTable("lorebook_entries", {
id: integer("id").primaryKey(),
lorebookId: integer("lorebook_id")
.notNull()
.references(() => lorebooks.id, { onDelete: "cascade" }), // FK to lorebooks.id
key: text("key"), // JSON array of keys
keySecondary: text("key_secondary"), // JSON array of secondary keys
comment: text("comment"),
content: text("content"),
constant: integer("constant", { mode: "boolean" }).default(false), // Is this entry a constant value?
vectorized: integer("vectorized"),
selective: integer("selective"),
selectiveLogic: integer("selective_logic"),
addMemo: integer("add_memo"),
order: integer("order"),
position: integer("position"),
disable: integer("disable", { mode: "boolean" }).default(false), // Is this entry disabled?
excludeRecursion: integer("exclude_recursion"),
preventRecursion: integer("prevent_recursion"),
delayUntilRecursion: integer("delay_until_recursion"),
probability: integer("probability"),
useProbability: integer("use_probability"),
depth: integer("depth"),
group: text("group"),
groupOverride: integer("group_override"),
groupWeight: integer("group_weight"),
scanDepth: integer("scan_depth"),
caseSensitive: integer("case_sensitive"),
matchWholeWords: integer("match_whole_words"),
useGroupScoring: integer("use_group_scoring"),
automationId: text("automation_id"),
role: text("role"),
sticky: integer("sticky"),
cooldown: integer("cooldown"),
delay: integer("delay"),
displayIndex: integer("display_index")
})
export const lorebookEntriesRelations = relations(lorebookEntries, ({ one }) => ({
@ -169,19 +193,23 @@ export const lorebookEntriesRelations = relations(lorebookEntries, ({ one }) =>
})
}))
export const tags = sqliteTable('tags', {
id: integer('id').primaryKey(),
name: text('name').notNull(), // Tag name (unique)
description: text('description'),
export const tags = sqliteTable("tags", {
id: integer("id").primaryKey(),
name: text("name").notNull(), // Tag name (unique)
description: text("description")
})
export const tagsRelations = relations(tags, ({ many }) => ({
characterTags: many(characterTags)
}))
export const characterTags = sqliteTable('character_tags', {
characterId: integer('character_id').notNull().references(() => characters.id, {onDelete: 'cascade'}), // FK to characters.id
tagId: integer('tag_id').notNull().references(() => tags.id, {onDelete: 'cascade'}), // FK to tags.id
export const characterTags = sqliteTable("character_tags", {
characterId: integer("character_id")
.notNull()
.references(() => characters.id, { onDelete: "cascade" }), // FK to characters.id
tagId: integer("tag_id")
.notNull()
.references(() => tags.id, { onDelete: "cascade" }) // FK to tags.id
})
export const characterTagsRelations = relations(characterTags, ({ one }) => ({
@ -195,21 +223,46 @@ export const characterTagsRelations = relations(characterTags, ({ one }) => ({
})
}))
export const characters = sqliteTable('characters', {
id: integer('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, {onDelete: 'cascade'}), // FK to users.id
name: text('name').notNull(),
description: text('description'),
personality: text('personality'), // Persona field
scenario: text('scenario'),
firstMessage: text('first_message'),
exampleDialogues: text('example_dialogues'), // JSON/text
metadata: text('metadata'), // JSON/text for extra fields
avatar: text('avatar'), // Path or URL to avatar image
createdAt: text('created_at'),
updatedAt: text('updated_at'),
lorebookId: integer('lorebook_id').references(() => lorebooks.id, {onDelete: 'set null'}), // Optional FK to lorebooks.id
isFavorite: integer('is_favorite', {mode: "boolean"}).default(false), // 1 if favorite, 0 otherwise
export const characters = sqliteTable("characters", {
id: integer("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }), // FK to users.id
name: text("name").notNull(),
nickname: text("nickname"), // Optional nickname
characterVersion: text("character_version").default("1.0"), // Version of the character schema
description: text("description"),
personality: text("personality"), // Persona field
scenario: text("scenario"),
firstMessage: text("first_message"),
alternateGreetings: text("alternate_greetings", { mode: "json" })
.default("[]")
.$type<string[]>(), // JSON array of alternate greetings
exampleDialogues: text("example_dialogues"), // JSON/text
metadata: text("metadata"), // JSON/text for extra fields
avatar: text("avatar"), // Path or URL to avatar image
creatorNotes: text("creator_notes"), // Notes from the character creator
creatorNotesMultilingual: text("creator_notes_multilingual", { mode: "json" })
.default("{}")
.$type<Record<string, string>>(), // Multilingual creator notes as JSON object
groupOnlyGreetings: text("group_only_greetings", { mode: "json" })
.default("[]")
.$type<String[]>(), // JSON array of greetings for group chats
postHistoryInstructions: text("post_history_instructions"), // Instructions for post-history processing
source: text("source", { mode: "json" }).default("[]").$type<string[]>(), // JSON array of sources (e.g., URLs, books)
assets: text("assets", { mode: "json" }).default("[]").$type<
Array<{
type: string
uri: string
name: string
ext: string
}>
>(), // JSON array of asset paths or URLs
createdAt: text("created_at").default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at").$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
lorebookId: integer("lorebook_id").references(() => lorebooks.id, { onDelete: "set null" }), // Optional FK to lorebooks.id
extensions: text("extensions", { mode: "json" }).default("[]").$type<Record<string, any>>(),
isFavorite: integer("is_favorite", { mode: "boolean" }).default(false) // 1 if favorite, 0 otherwise
})
export const charactersRelations = relations(characters, ({ many, one }) => ({
@ -218,41 +271,45 @@ export const charactersRelations = relations(characters, ({ many, one }) => ({
fields: [characters.userId],
references: [users.id]
}),
lorebook: one(lorebooks, {
fields: [characters.lorebookId],
references: [lorebooks.id]
}),
lorebook: one(lorebooks, {
fields: [characters.lorebookId],
references: [lorebooks.id]
})
}))
export const personas = sqliteTable('personas', {
id: integer('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, {onDelete: 'cascade'}), // FK to users.id
isDefault: integer('is_default', {mode: "boolean"}).default(false), // Is this the default persona for the user?
avatar: text('avatar'), // e.g. 'user-default.png', '1747379438925-Ryvn.png'
name: text('name').notNull(), // e.g. 'Warren', 'Master Desir'
description: text('description'), // Persona description (long text)
position: integer('position').default(0),
connections: text('connections'), // JSON array of connection IDs or objects
createdAt: text('created_at'),
updatedAt: text('updated_at'),
export const personas = sqliteTable("personas", {
id: integer("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }), // FK to users.id
isDefault: integer("is_default", { mode: "boolean" }).default(false), // Is this the default persona for the user?
avatar: text("avatar"), // e.g. 'user-default.png', '1747379438925-Ryvn.png'
name: text("name").notNull(), // e.g. 'Warren', 'Master Desir'
description: text("description"), // Persona description (long text)
position: integer("position").default(0),
connections: text("connections"), // JSON array of connection IDs or objects
createdAt: text("created_at"),
updatedAt: text("updated_at")
})
export const personasRelations = relations(personas, ({ one, many }) => ({
user: one(users, {
fields: [personas.userId],
references: [users.id]
}),
})
}))
// Chats (group or 1:1)
export const chats = sqliteTable('chats', {
id: integer('id').primaryKey(),
name: text('name'), // Optional chat/group name
isGroup: integer('is_group').default(0), // 1 for group chat, 0 for 1:1
userId: integer('user_id').notNull().references(() => users.id, {onDelete: 'cascade'}),
createdAt: text('created_at'),
updatedAt: text('updated_at'),
metadata: text('metadata'), // JSON for extra settings
export const chats = sqliteTable("chats", {
id: integer("id").primaryKey(),
name: text("name"), // Optional chat/group name
isGroup: integer("is_group").default(0), // 1 for group chat, 0 for 1:1
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: text("created_at"),
updatedAt: text("updated_at"),
metadata: text("metadata") // JSON for extra settings
})
export const chatsRelations = relations(chats, ({ one, many }) => ({
@ -262,25 +319,29 @@ export const chatsRelations = relations(chats, ({ one, many }) => ({
}),
chatMessages: many(chatMessages),
chatPersonas: many(chatPersonas),
chatCharacters: many(chatCharacters),
chatCharacters: many(chatCharacters)
}))
// Chat messages
export const chatMessages = sqliteTable('chat_messages', {
id: integer('id').primaryKey(),
chatId: integer('chat_id').notNull().references(() => chats.id, {onDelete: 'cascade'}),
userId: integer('user_id').notNull().references(() => users.id, {onDelete: 'cascade'}), // nullable for system/character messages
characterId: integer('character_id').references(() => characters.id, {onDelete: 'set null'}), // nullable
personaId: integer('persona_id').references(() => personas.id, {onDelete: 'set null'}), // nullable
role: text('role'), // 'user', 'character', 'system', etc
content: text('content').notNull(),
createdAt: text('created_at'),
updatedAt: text('updated_at'),
isEdited: integer('is_edited').default(0), // 1 if edited, 0 otherwise
metadata: text('metadata'), // JSON for extra info
isGenerating: integer('is_generating', {mode: "boolean"}).default(false), // 1 if processing, 0 otherwise
adapterId: text('adapter_id'), // UUID for in-flight adapter instance, nullable
isHidden: integer('is_hidden', {mode: "boolean"}).default(false), // Whether this message is processed or not
export const chatMessages = sqliteTable("chat_messages", {
id: integer("id").primaryKey(),
chatId: integer("chat_id")
.notNull()
.references(() => chats.id, { onDelete: "cascade" }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }), // nullable for system/character messages
characterId: integer("character_id").references(() => characters.id, { onDelete: "set null" }), // nullable
personaId: integer("persona_id").references(() => personas.id, { onDelete: "set null" }), // nullable
role: text("role"), // 'user', 'character', 'system', etc
content: text("content").notNull(),
createdAt: text("created_at"),
updatedAt: text("updated_at"),
isEdited: integer("is_edited").default(0), // 1 if edited, 0 otherwise
metadata: text("metadata"), // JSON for extra info
isGenerating: integer("is_generating", { mode: "boolean" }).default(false), // 1 if processing, 0 otherwise
adapterId: text("adapter_id"), // UUID for in-flight adapter instance, nullable
isHidden: integer("is_hidden", { mode: "boolean" }).default(false) // Whether this message is processed or not
})
export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({
@ -299,21 +360,25 @@ export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({
persona: one(personas, {
fields: [chatMessages.personaId],
references: [personas.id]
}),
})
}))
export const GroupReplyStrategies = {
MANUAL: 'manual', // User manually selects persona for each reply
ORDERED: 'ordered', // Replies follow the order of personas in the chat
NATURAL: 'natural', // Replies are assigned based on natural conversation flow
MANUAL: "manual", // User manually selects persona for each reply
ORDERED: "ordered", // Replies follow the order of personas in the chat
NATURAL: "natural" // Replies are assigned based on natural conversation flow
}
// Many-to-many: chats <-> personas
export const chatPersonas = sqliteTable('chat_personas', {
chatId: integer('chat_id').notNull().references(() => chats.id, {onDelete: 'cascade'}),
personaId: integer('persona_id').notNull().references(() => personas.id, {onDelete: 'cascade'}),
position: integer('position').default(0), // Position in the chat
group_reply_strategy: text('group_reply_strategy').default(GroupReplyStrategies.ORDERED), // How to handle group replies
export const chatPersonas = sqliteTable("chat_personas", {
chatId: integer("chat_id")
.notNull()
.references(() => chats.id, { onDelete: "cascade" }),
personaId: integer("persona_id")
.notNull()
.references(() => personas.id, { onDelete: "cascade" }),
position: integer("position").default(0), // Position in the chat
group_reply_strategy: text("group_reply_strategy").default(GroupReplyStrategies.ORDERED) // How to handle group replies
})
export const chatPersonasRelations = relations(chatPersonas, ({ one }) => ({
@ -324,15 +389,19 @@ export const chatPersonasRelations = relations(chatPersonas, ({ one }) => ({
persona: one(personas, {
fields: [chatPersonas.personaId],
references: [personas.id]
}),
})
}))
// Many-to-many: chats <-> characters
export const chatCharacters = sqliteTable('chat_characters', {
chatId: integer('chat_id').notNull().references(() => chats.id, {onDelete: 'cascade'}),
characterId: integer('character_id').notNull().references(() => characters.id, {onDelete: 'cascade'}),
position: integer('position').default(0), // Position in the chat
isActive: integer('is_active', {mode: "boolean"}).default(false), // 1 if active in chat, 0 if not
export const chatCharacters = sqliteTable("chat_characters", {
chatId: integer("chat_id")
.notNull()
.references(() => chats.id, { onDelete: "cascade" }),
characterId: integer("character_id")
.notNull()
.references(() => characters.id, { onDelete: "cascade" }),
position: integer("position").default(0), // Position in the chat
isActive: integer("is_active", { mode: "boolean" }).default(false) // 1 if active in chat, 0 if not
})
export const chatCharactersRelations = relations(chatCharacters, ({ one }) => ({
@ -343,7 +412,5 @@ export const chatCharactersRelations = relations(chatCharacters, ({ one }) => ({
character: one(characters, {
fields: [chatCharacters.characterId],
references: [characters.id]
}),
})
}))

View file

@ -3,171 +3,224 @@ import { and, eq } from "drizzle-orm"
import * as schema from "$lib/server/db/schema"
import * as fsPromises from "fs/promises"
import { getCharacterDataDir, handleCharacterAvatarUpload } from "../utils"
import extractChunks from 'png-chunks-extract';
import {decode as decodeText} from 'png-chunk-text';
import { CharacterCard } from "@lenml/char-card-reader"
import fs from "fs"
import { fileTypeFromBuffer } from "file-type"
export async function charactersList(
socket: any,
message: Sockets.CharactersList.Call,
emitToUser: (event: string, data: any) => void
socket: any,
message: Sockets.CharactersList.Call,
emitToUser: (event: string, data: any) => void
) {
const charactersList = await db.query.characters.findMany({
columns: {
id: true,
name: true,
avatar: true,
isFavorite: true
},
where: (c, { eq }) => eq(c.userId, 1) // TODO: Replace with actual user id
})
const res: Sockets.CharactersList.Response = { charactersList }
emitToUser("charactersList", res)
const charactersList = await db.query.characters.findMany({
columns: {
id: true,
name: true,
nickname: true,
avatar: true,
isFavorite: true
},
where: (c, { eq }) => eq(c.userId, 1) // TODO: Replace with actual user id
})
const res: Sockets.CharactersList.Response = { charactersList }
emitToUser("charactersList", res)
}
export async function character(
socket: any,
message: Sockets.Character.Call,
emitToUser: (event: string, data: any) => void
socket: any,
message: Sockets.Character.Call,
emitToUser: (event: string, data: any) => void
) {
const character = await db.query.characters.findFirst({
where: (c, { eq }) => eq(c.id, message.id)
})
if (character) {
const res: Sockets.Character.Response = { character }
emitToUser("character", res)
}
const character = await db.query.characters.findFirst({
where: (c, { eq }) => eq(c.id, message.id)
})
if (character) {
const res: Sockets.Character.Response = { character }
emitToUser("character", res)
}
}
export async function createCharacter(
socket: any,
message: Sockets.CreateCharacter.Call,
emitToUser: (event: string, data: any) => void
socket: any,
message: Sockets.CreateCharacter.Call,
emitToUser: (event: string, data: any) => void
) {
try {
const data = message.character
delete data.avatar // Remove avatar from character data to avoid conflicts
const [character] = await db
.insert(schema.characters)
.values({ ...message.character, userId: 1 })
.returning()
try {
const data = message.character
delete data.avatar // Remove avatar from character data to avoid conflicts
const [character] = await db
.insert(schema.characters)
.values({ ...message.character, userId: 1 })
.returning()
if (message.avatarFile) {
await handleCharacterAvatarUpload({
character,
avatarFile: message.avatarFile
})
}
if (message.avatarFile) {
await handleCharacterAvatarUpload({
character,
avatarFile: message.avatarFile
})
}
await charactersList(socket, {}, emitToUser)
await charactersList(socket, {}, emitToUser)
const res: Sockets.CreateCharacter.Response = { character }
emitToUser("createCharacter", res)
} catch (e: any) {
console.error("Error creating character:", e)
emitToUser("error", { error: e.message || "Failed to create character." })
return
}
const res: Sockets.CreateCharacter.Response = { character }
emitToUser("createCharacter", res)
} catch (e: any) {
console.error("Error creating character:", e)
emitToUser("error", {
error: e.message || "Failed to create character."
})
return
}
}
export async function updateCharacter(
socket: any,
message: Sockets.UpdateCharacter.Call,
emitToUser: (event: string, data: any) => void
socket: any,
message: Sockets.UpdateCharacter.Call,
emitToUser: (event: string, data: any) => void
) {
const data = message.character
const id = data.id
const userId = 1 // Replace with actual userId
const data = message.character
const id = data.id
const userId = 1 // Replace with actual userId
// Remove userId and id if present and optional
if ('userId' in data) (data as any).userId = undefined
if ('id' in data) (data as any).id = undefined
delete data.avatar // Remove avatar from character data to avoid conflicts
const [updated] = await db
.update(schema.characters)
.set(data)
.where(and(eq(schema.characters.id, id), eq(schema.characters.userId, userId)))
.returning()
// Remove userId and id if present and optional
if ("userId" in data) (data as any).userId = undefined
if ("id" in data) (data as any).id = undefined
delete data.avatar // Remove avatar from character data to avoid conflicts
const [updated] = await db
.update(schema.characters)
.set(data)
.where(
and(
eq(schema.characters.id, id),
eq(schema.characters.userId, userId)
)
)
.returning()
if (message.avatarFile) {
await handleCharacterAvatarUpload({
character: updated,
avatarFile: message.avatarFile
})
}
if (message.avatarFile) {
await handleCharacterAvatarUpload({
character: updated,
avatarFile: message.avatarFile
})
}
const res: Sockets.UpdateCharacter.Response = { character: updated }
await charactersList(socket, {}, emitToUser)
emitToUser("updateCharacter", res)
const res: Sockets.UpdateCharacter.Response = { character: updated }
await charactersList(socket, {}, emitToUser)
emitToUser("updateCharacter", res)
}
export async function deleteCharacter(
socket: any,
message: Sockets.DeleteCharacter.Call,
emitToUser: (event: string, data: any) => void
socket: any,
message: Sockets.DeleteCharacter.Call,
emitToUser: (event: string, data: any) => void
) {
const userId = 1 // Replace with actual userId
await db
.delete(schema.characters)
.where(
and(eq(schema.characters.id, message.characterId), eq(schema.characters.userId, userId))
)
await charactersList(socket, {}, emitToUser)
// Delete the character data directory if it exists
const avatarDir = getCharacterDataDir({
characterId: message.characterId,
userId
})
try {
await fsPromises.rmdir(avatarDir, { recursive: true })
} catch (err) {
console.error("Error deleting character data directory:", err)
}
// Emit the delete event
const res: Sockets.DeleteCharacter.Response = { id: message.characterId }
await charactersList(socket, {}, emitToUser)
emitToUser("deleteCharacter", res)
const userId = 1 // Replace with actual userId
await db
.delete(schema.characters)
.where(
and(
eq(schema.characters.id, message.characterId),
eq(schema.characters.userId, userId)
)
)
await charactersList(socket, {}, emitToUser)
// Delete the character data directory if it exists
const avatarDir = getCharacterDataDir({
characterId: message.characterId,
userId
})
try {
await fsPromises.rmdir(avatarDir, { recursive: true })
} catch (err) {
console.error("Error deleting character data directory:", err)
}
// Emit the delete event
const res: Sockets.DeleteCharacter.Response = { id: message.characterId }
await charactersList(socket, {}, emitToUser)
emitToUser("deleteCharacter", res)
}
export async function characterCardImport(
socket: any,
message: { file?: string },
emitToUser: (event: string, data: any) => void
socket: any,
message: Sockets.CharacterCardImport.Call,
emitToUser: (event: string, data: any) => void
) {
const userId = 1
let charaData: CharaImportMetadata
let base64 = message.file!
if (base64.startsWith("data:")) base64 = base64.split(",")[1]
const buffer = Buffer.from(base64, "base64")
const userId = 1
let base64 = message.file!
if (base64.startsWith("data:")) base64 = base64.split(",")[1]
const buffer = Buffer.from(base64, "base64")
const card = await CharacterCard.from_file(buffer)
const chunks = extractChunks(buffer)
const v3Data = card.toSpecV3().data
const creationDate =
v3Data.creation_date && !isNaN(Number(v3Data.creation_date))
? new Date(Number(v3Data.creation_date)).toISOString()
: new Date().toISOString()
const data: InsertCharacter = {
userId,
name: v3Data.name || "Imported Character",
description: v3Data.description || "",
personality: v3Data.personality || "",
scenario: v3Data.scenario || "",
firstMessage: v3Data.first_mes || "",
exampleDialogues: v3Data.mes_example || "",
nickname: v3Data.nickname || "",
alternateGreetings: v3Data.alternate_greetings || [],
creatorNotes: v3Data.creator_notes || "",
creatorNotesMultilingual: v3Data.creator_notes_multilingual || {},
groupOnlyGreetings: v3Data.group_only_greetings || [],
postHistoryInstructions: v3Data.post_history_instructions || "",
source: v3Data.source || [],
assets: v3Data.assets || [],
createdAt: creationDate,
extensions: v3Data.extensions || []
}
for (const chunk of chunks) {
if (chunk.name === "tEXt") {
const { keyword, text } = decodeText(chunk.data)
if (keyword.toLocaleLowerCase() === "chara") {
charaData = JSON.parse(
Buffer.from(text, "base64").toString("utf8")
) as CharaImportMetadata
}
}
}
const [character] = await db
.insert(schema.characters)
.values(data)
.returning()
const data: InsertCharacter = {
userId,
name: charaData!.data.name || "Imported Character",
description: charaData!.data.description || "",
personality: charaData!.data.personality || "",
scenario: charaData!.data.scenario || "",
firstMessage: charaData!.data.first_mes || "",
exampleDialogues: charaData!.data.mes_example || "",
}
// Extract file extension and check if it's a supported image type
let ext = ""
if (typeof message.file === "string") {
// If data URL, try to extract extension from MIME type
const dataUrlMatch = message.file.match(/^data:image\/(\w+)/i)
if (dataUrlMatch) {
ext = dataUrlMatch[1].toLowerCase()
} else {
// Otherwise, try to extract from filename
const fileNameMatch = message.file.match(/\.([a-zA-Z0-9]+)$/)
if (fileNameMatch) {
ext = fileNameMatch[1].toLowerCase()
}
}
}
const [character] = await db.insert(schema.characters).values(data).returning()
await handleCharacterAvatarUpload({
character,
avatarFile: buffer
})
const res: Sockets.CreateCharacter.Response = { character }
emitToUser("createCharacter", res)
await charactersList(socket, {}, emitToUser)
}
async function detectMimeType(base64: string) {
const buffer = Buffer.from(base64, "base64")
const result = await fileTypeFromBuffer(buffer)
return result ? result.mime : null
}
const mimeType = await detectMimeType(base64)
console.log("Extracted mime type:", )
const supportedMimeTypes = [
"image/png",
"image/apng",
]
if (supportedMimeTypes.includes(mimeType || "")) {
await handleCharacterAvatarUpload({
character,
avatarFile: buffer
})
}
// TODO: Import tags
// TODO: Import lorebook
const res: Sockets.CharacterCardImport.Response = { character }
emitToUser("createCharacter", res)
await charactersList(socket, {}, emitToUser)
}

View file

@ -250,14 +250,15 @@
<Avatar
src={character?.avatar ?? ""}
size="w-[4em] h-[4em]"
name={character?.name ?? "Unknown"}
name={character?.nickname || character?.name || "Unknown"}
background="preset-filled-primary-500"
imageClasses="object-cover"
>
<Icons.User size={36} />
</Avatar>
</span>
<span class="funnel-display text-[1.1em] font-bold"
>{character?.name || "Unknown"}</span
>{character?.nickname || character?.name || "Unknown"}</span
>
</div>
{#if editChatMessage && editChatMessage.id === msg.id}
@ -377,6 +378,7 @@
size="w-[4em] h-[4em]"
name={persona?.name ?? "Unknown"}
background="preset-filled-primary-500"
imageClasses="object-cover"
>
<Icons.User size={36} />
</Avatar>

View file

@ -13,7 +13,6 @@ export const GET: RequestHandler = async ({ params }) => {
const relPath = Array.isArray(reqPath) ? reqPath.join('/') : reqPath
const appData = envPaths('SerenePub', { suffix: "" }).data
const filePath = path.join(appData, relPath)
console.log(`Serving avatar from: ${filePath}`)
try {
const data = await fs.readFile(filePath)
// Guess content type from extension
@ -25,7 +24,7 @@ export const GET: RequestHandler = async ({ params }) => {
else if (ext === '.gif') type = 'image/gif'
// SvelteKit Response expects Uint8Array, not Buffer
return new Response(new Uint8Array(data), {
headers: { 'Content-Type': type, 'Cache-Control': 'public, max-age=31536000' }
headers: { 'Content-Type': type, 'Cache-Control': 'public, max-age=0' }
})
} catch (e) {
return new Response('Not found', { status: 404 })