mirror of
https://github.com/doolijb/serene-pub.git
synced 2026-04-28 03:20:07 +00:00
Bring character schema up to v3 standards, add support v3 import support
This commit is contained in:
parent
f1534dad54
commit
ffd2621fc8
17 changed files with 3606 additions and 1433 deletions
|
|
@ -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
BIN
bun.lockb
Binary file not shown.
35
drizzle/0004_fantastic_sumo.sql
Normal file
35
drizzle/0004_fantastic_sumo.sql
Normal 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;
|
||||
1575
drizzle/meta/0004_snapshot.json
Normal file
1575
drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -29,6 +29,13 @@
|
|||
"when": 1749526176881,
|
||||
"tag": "0003_magical_pride",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1749630355164,
|
||||
"tag": "0004_fantastic_sumo",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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!)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}),
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue