mirror of
https://github.com/doolijb/serene-pub.git
synced 2026-07-09 15:58:23 +00:00
Upgrade forms, add character creator modal
This commit is contained in:
parent
737a1638a5
commit
b5a826cc4f
22 changed files with 4037 additions and 2241 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1,41 +1,41 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1751273223316,
|
||||
"tag": "0000_stiff_puma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1751364094857,
|
||||
"tag": "0001_unique_stone_men",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1751366812681,
|
||||
"tag": "0002_awesome_thena",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1752304229013,
|
||||
"tag": "0003_gigantic_nova",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1753248760264,
|
||||
"tag": "0004_absurd_human_robot",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1751273223316,
|
||||
"tag": "0000_stiff_puma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1751364094857,
|
||||
"tag": "0001_unique_stone_men",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1751366812681,
|
||||
"tag": "0002_awesome_thena",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1752304229013,
|
||||
"tag": "0003_gigantic_nova",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1753248760264,
|
||||
"tag": "0004_absurd_human_robot",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { z } from "zod"
|
||||
import CharacterUnsavedChangesModal from "../modals/CharacterUnsavedChangesModal.svelte"
|
||||
import Avatar from "../Avatar.svelte"
|
||||
import { toaster } from "$lib/client/utils/toaster"
|
||||
|
||||
interface EditCharacterData {
|
||||
id?: number
|
||||
|
|
@ -292,15 +293,23 @@
|
|||
document.addEventListener("keydown", handleKeydown)
|
||||
|
||||
socket.on("createCharacter", (res: any) => {
|
||||
if (!res.error) {
|
||||
if (res.character) {
|
||||
validationErrors = {} // Clear any validation errors on success
|
||||
toaster.success({
|
||||
title: "Character Created",
|
||||
description: `Character "${res.character.name}" created successfully.`
|
||||
})
|
||||
closeForm()
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("updateCharacter", (res: any) => {
|
||||
if (!res.error) {
|
||||
if (res.character) {
|
||||
validationErrors = {} // Clear any validation errors on success
|
||||
toaster.success({
|
||||
title: "Character Updated",
|
||||
description: `Character "${res.character.name}" updated successfully.`
|
||||
})
|
||||
closeForm()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,6 +10,16 @@
|
|||
import { Switch } from "@skeletonlabs/skeleton-svelte"
|
||||
import { toaster } from "$lib/client/utils/toaster"
|
||||
import { GroupReplyStrategies } from "$lib/shared/constants/GroupReplyStrategies"
|
||||
import { z } from "zod"
|
||||
|
||||
// Zod validation schema
|
||||
const chatSchema = z.object({
|
||||
name: z.string().min(1, "Chat name is required").trim(),
|
||||
scenario: z.string().optional(),
|
||||
groupReplyStrategy: z.string().optional()
|
||||
})
|
||||
|
||||
type ValidationErrors = Record<string, string>
|
||||
|
||||
interface Props {
|
||||
editChatId?: number | null // If provided, edit mode; else create mode
|
||||
|
|
@ -91,6 +101,7 @@
|
|||
let removeType: "character" | "persona" = $state("character")
|
||||
let removeName = $state("")
|
||||
let removeId: number | null = $state(null)
|
||||
let validationErrors: ValidationErrors = $state({})
|
||||
|
||||
$effect(() => {
|
||||
const _name = name.trim()
|
||||
|
|
@ -147,6 +158,7 @@
|
|||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!validateForm()) return
|
||||
if (
|
||||
!data?.chat.name.trim() ||
|
||||
selectedCharacters.length === 0 ||
|
||||
|
|
@ -168,7 +180,6 @@
|
|||
socket.emit("createChat", createChat)
|
||||
}
|
||||
isCreating = false
|
||||
showEditChatForm = false
|
||||
}
|
||||
|
||||
function confirmRemoveCharacter(id: number, name: string) {
|
||||
|
|
@ -199,6 +210,28 @@
|
|||
removeName = ""
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
const result = chatSchema.safeParse({
|
||||
name: name,
|
||||
scenario: scenario,
|
||||
groupReplyStrategy: groupReplyStrategy
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
validationErrors = {}
|
||||
return true
|
||||
} else {
|
||||
const errors: ValidationErrors = {}
|
||||
result.error.errors.forEach((error) => {
|
||||
if (error.path.length > 0) {
|
||||
errors[error.path[0] as string] = error.message
|
||||
}
|
||||
})
|
||||
validationErrors = errors
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCloseForm() {
|
||||
// TODO handle unsaved changes if any
|
||||
showEditChatForm = false
|
||||
|
|
@ -237,6 +270,20 @@
|
|||
}
|
||||
}
|
||||
)
|
||||
socket.on("createChat", (res: any) => {
|
||||
toaster.success({
|
||||
title: "Chat Created",
|
||||
description: `Chat "${res.chat.name || "Unnamed Chat"}" created successfully.`
|
||||
})
|
||||
showEditChatForm = false
|
||||
})
|
||||
socket.on("updateChat", (res: any) => {
|
||||
toaster.success({
|
||||
title: "Chat Updated",
|
||||
description: `Chat "${res.chat.name || "Unnamed Chat"}" updated successfully.`
|
||||
})
|
||||
showEditChatForm = false
|
||||
})
|
||||
socket.emit("characterList", {})
|
||||
socket.emit("personaList", {})
|
||||
socket.emit("lorebookList", {})
|
||||
|
|
@ -248,6 +295,8 @@
|
|||
socket.off("personaList")
|
||||
socket.off("lorebookList")
|
||||
socket.off("toggleChatCharacterActive")
|
||||
socket.off("createChat")
|
||||
socket.off("updateChat")
|
||||
})
|
||||
|
||||
function toggleCharacterActive(
|
||||
|
|
@ -283,12 +332,25 @@
|
|||
<label class="font-semibold" for="chatName">Chat Name*</label>
|
||||
<input
|
||||
id="chatName"
|
||||
class="input input-lg w-full"
|
||||
class="input input-lg w-full {validationErrors.name
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
type="text"
|
||||
placeholder="Enter chat name"
|
||||
bind:value={name}
|
||||
required
|
||||
oninput={() => {
|
||||
if (validationErrors.name) {
|
||||
const { name, ...rest } = validationErrors
|
||||
validationErrors = rest
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if validationErrors.name}
|
||||
<p class="mt-1 text-sm text-red-500" role="alert">
|
||||
{validationErrors.name}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<span class="mb-2 font-semibold">Characters*</span>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,15 @@
|
|||
|
||||
import * as skio from "sveltekit-io"
|
||||
import { toaster } from "$lib/client/utils/toaster"
|
||||
import { z } from "zod"
|
||||
|
||||
// Zod validation schema
|
||||
const lorebookSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").trim(),
|
||||
description: z.string().optional()
|
||||
})
|
||||
|
||||
type ValidationErrors = Record<string, string>
|
||||
|
||||
interface Props {
|
||||
lorebookId: number // ID of the lorebook to edit
|
||||
|
|
@ -18,6 +27,7 @@
|
|||
$state()
|
||||
let originalLorebook: Sockets.Lorebook.Response["lorebook"] | undefined =
|
||||
$state()
|
||||
let validationErrors: ValidationErrors = $state({})
|
||||
|
||||
$effect(() => {
|
||||
hasUnsavedChanges =
|
||||
|
|
@ -25,11 +35,35 @@
|
|||
})
|
||||
|
||||
function handleSave() {
|
||||
if (!validateForm()) return
|
||||
const updateReq: Sockets.UpdateLorebook.Call = {
|
||||
lorebook: editLorebook!
|
||||
}
|
||||
socket.emit("updateLorebook", updateReq)
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
if (!editLorebook) return false
|
||||
|
||||
const result = lorebookSchema.safeParse({
|
||||
name: editLorebook.name,
|
||||
description: editLorebook.description
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
validationErrors = {}
|
||||
return true
|
||||
} else {
|
||||
const errors: ValidationErrors = {}
|
||||
result.error.errors.forEach((error) => {
|
||||
if (error.path.length > 0) {
|
||||
errors[error.path[0] as string] = error.message
|
||||
}
|
||||
})
|
||||
validationErrors = errors
|
||||
return false
|
||||
}
|
||||
}
|
||||
function handleCancel() {
|
||||
editLorebook = { ...originalLorebook! }
|
||||
}
|
||||
|
|
@ -78,12 +112,25 @@
|
|||
<label class="font-semibold" for="lorebookName">Name*</label>
|
||||
<input
|
||||
id="lorebookName"
|
||||
class="input input-lg w-full"
|
||||
class="input input-lg w-full {validationErrors.name
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
type="text"
|
||||
placeholder="Enter lorebook name"
|
||||
bind:value={editLorebook.name}
|
||||
required
|
||||
oninput={() => {
|
||||
if (validationErrors.name) {
|
||||
const { name, ...rest } = validationErrors
|
||||
validationErrors = rest
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if validationErrors.name}
|
||||
<p class="mt-1 text-sm text-red-500" role="alert">
|
||||
{validationErrors.name}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label class="font-semibold" for="lorebookDescription">
|
||||
|
|
|
|||
855
src/lib/client/components/modals/CharacterCreator.svelte
Normal file
855
src/lib/client/components/modals/CharacterCreator.svelte
Normal file
|
|
@ -0,0 +1,855 @@
|
|||
<script lang="ts">
|
||||
import { Modal } from "@skeletonlabs/skeleton-svelte"
|
||||
import * as Icons from "@lucide/svelte"
|
||||
import * as skio from "sveltekit-io"
|
||||
import { onDestroy, onMount } from "svelte"
|
||||
import { z } from "zod"
|
||||
import { toaster } from "$lib/client/utils/toaster"
|
||||
import Avatar from "../Avatar.svelte"
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange?: (e: { open: boolean }) => void
|
||||
}
|
||||
|
||||
let { open = $bindable(), onOpenChange }: Props = $props()
|
||||
|
||||
const socket = skio.get()
|
||||
|
||||
// Character data interface
|
||||
interface CharacterData {
|
||||
name: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
description: string
|
||||
personality: string
|
||||
firstMessage: string
|
||||
_avatarFile?: File | undefined
|
||||
_avatar?: string
|
||||
}
|
||||
|
||||
// Zod validation schema (same as CharacterForm but only required fields)
|
||||
const characterSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").trim(),
|
||||
nickname: z.string().optional(),
|
||||
description: z.string().min(1, "Description is required").trim(),
|
||||
personality: z.string().optional(),
|
||||
firstMessage: z.string().optional()
|
||||
})
|
||||
|
||||
type ValidationErrors = Record<string, string>
|
||||
|
||||
// State
|
||||
let currentStep = $state(0)
|
||||
let characterData: CharacterData = $state({
|
||||
name: "",
|
||||
nickname: "",
|
||||
avatar: "",
|
||||
description: "",
|
||||
personality: "",
|
||||
firstMessage: "",
|
||||
_avatarFile: undefined,
|
||||
_avatar: ""
|
||||
})
|
||||
let validationErrors: ValidationErrors = $state({})
|
||||
let showUnsavedChangesModal = $state(false)
|
||||
|
||||
// Step definitions
|
||||
const steps = [
|
||||
{ title: "Name", canSkip: false },
|
||||
{ title: "Avatar", canSkip: true },
|
||||
{ title: "Description", canSkip: false },
|
||||
{ title: "Personality", canSkip: true },
|
||||
{ title: "First Message", canSkip: true }
|
||||
]
|
||||
|
||||
// Validation functions
|
||||
function validateCurrentStep(): boolean {
|
||||
const step = steps[currentStep]
|
||||
|
||||
// Only validate required steps
|
||||
if (!step.canSkip) {
|
||||
if (currentStep === 0) {
|
||||
// Step 1: Name is required
|
||||
if (!characterData.name.trim()) {
|
||||
validationErrors = { name: "Name is required" }
|
||||
return false
|
||||
}
|
||||
} else if (currentStep === 2) {
|
||||
// Step 3: Description is required
|
||||
if (!characterData.description.trim()) {
|
||||
validationErrors = {
|
||||
description: "Description is required"
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validationErrors = {}
|
||||
return true
|
||||
}
|
||||
|
||||
function validateFinalForm(): boolean {
|
||||
const result = characterSchema.safeParse(characterData)
|
||||
|
||||
if (result.success) {
|
||||
validationErrors = {}
|
||||
return true
|
||||
} else {
|
||||
const errors: ValidationErrors = {}
|
||||
result.error.errors.forEach((error) => {
|
||||
if (error.path.length > 0) {
|
||||
errors[error.path[0] as string] = error.message
|
||||
}
|
||||
})
|
||||
validationErrors = errors
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Avatar handling
|
||||
function handleAvatarChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement | null
|
||||
if (!input || !input.files || input.files.length === 0) return
|
||||
const file = input.files[0]
|
||||
if (!file) return
|
||||
|
||||
// Set preview
|
||||
const previewReader = new FileReader()
|
||||
previewReader.onload = (ev2) => {
|
||||
characterData._avatar = ev2.target?.result as string
|
||||
}
|
||||
previewReader.readAsDataURL(file)
|
||||
|
||||
// Store file for later upload
|
||||
characterData._avatarFile = file
|
||||
}
|
||||
|
||||
// Navigation functions
|
||||
function handleNext() {
|
||||
// Validate current step if it's required
|
||||
if (!steps[currentStep].canSkip && !validateCurrentStep()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentStep < steps.length - 1) {
|
||||
currentStep++
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrevious() {
|
||||
if (currentStep > 0) {
|
||||
currentStep--
|
||||
}
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!validateFinalForm()) {
|
||||
// Find the first step with validation errors and go to it
|
||||
if (validationErrors.name) {
|
||||
currentStep = 0
|
||||
} else if (validationErrors.description) {
|
||||
currentStep = 2
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare character data for creation
|
||||
const newCharacter = {
|
||||
...characterData,
|
||||
alternateGreetings: [],
|
||||
exampleDialogues: [],
|
||||
creatorNotes: "",
|
||||
creatorNotesMultilingual: {},
|
||||
groupOnlyGreetings: [],
|
||||
postHistoryInstructions: "",
|
||||
isFavorite: false,
|
||||
lorebookId: null,
|
||||
scenario: ""
|
||||
}
|
||||
|
||||
const avatarFile = newCharacter._avatarFile
|
||||
delete newCharacter._avatarFile
|
||||
delete newCharacter._avatar
|
||||
|
||||
socket.emit("createCharacter", {
|
||||
character: newCharacter,
|
||||
avatarFile
|
||||
})
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
// Reset form data
|
||||
characterData = {
|
||||
name: "",
|
||||
nickname: "",
|
||||
avatar: "",
|
||||
description: "",
|
||||
personality: "",
|
||||
firstMessage: "",
|
||||
_avatarFile: undefined,
|
||||
_avatar: ""
|
||||
}
|
||||
validationErrors = {}
|
||||
currentStep = 0
|
||||
open = false
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (hasUnsavedData) {
|
||||
showUnsavedChangesModal = true
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
|
||||
function handleUnsavedChangesConfirm() {
|
||||
showUnsavedChangesModal = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
function handleUnsavedChangesCancel() {
|
||||
showUnsavedChangesModal = false
|
||||
}
|
||||
|
||||
function handleUnsavedChangesOnOpenChange(e: { open: boolean }) {
|
||||
if (!e.open) {
|
||||
showUnsavedChangesModal = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearValidationError(field: string) {
|
||||
if (validationErrors[field]) {
|
||||
const { [field]: removed, ...rest } = validationErrors
|
||||
validationErrors = rest
|
||||
}
|
||||
}
|
||||
|
||||
// Computed properties
|
||||
let isLastStep = $derived(currentStep === steps.length - 1)
|
||||
let isFirstStep = $derived(currentStep === 0)
|
||||
let canProceedToNext = $derived(() => {
|
||||
// Always allow proceeding on optional steps
|
||||
if (steps[currentStep].canSkip) return true
|
||||
|
||||
// For required steps, validate the current step
|
||||
return validateCurrentStep()
|
||||
})
|
||||
|
||||
// Check if any fields are populated (has unsaved data)
|
||||
let hasUnsavedData = $derived(
|
||||
characterData.name.trim() !== "" ||
|
||||
characterData.nickname.trim() !== "" ||
|
||||
characterData.description.trim() !== "" ||
|
||||
characterData.personality.trim() !== "" ||
|
||||
characterData.firstMessage.trim() !== "" ||
|
||||
!!characterData._avatarFile
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
socket.on("createCharacter", (res: any) => {
|
||||
if (res.character) {
|
||||
toaster.success({
|
||||
title: "Character Created",
|
||||
description: `Character "${res.character.name}" created successfully.`
|
||||
})
|
||||
resetForm() // This will close the modal and reset data
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
socket.off("createCharacter")
|
||||
})
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
{open}
|
||||
onOpenChange={(e) => {
|
||||
if (!e.open && hasUnsavedData) {
|
||||
// If trying to close and has unsaved data, show confirmation
|
||||
showUnsavedChangesModal = true
|
||||
return
|
||||
}
|
||||
// Otherwise allow normal close behavior
|
||||
onOpenChange?.(e)
|
||||
}}
|
||||
contentBase="card bg-surface-100-900 p-6 space-y-6 shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||
backdropClasses="backdrop-blur-sm"
|
||||
>
|
||||
{#snippet content()}
|
||||
<header class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="h2">Create Character</h2>
|
||||
<p class="text-sm opacity-60">
|
||||
Step {currentStep + 1} of {steps.length}: {steps[
|
||||
currentStep
|
||||
].title}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm preset-tonal-surface"
|
||||
onclick={handleCancel}
|
||||
aria-label="Close character creator"
|
||||
>
|
||||
<Icons.X size={16} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Progress indicator -->
|
||||
<div class="flex gap-2">
|
||||
{#each steps as _, index}
|
||||
<div
|
||||
class="h-2 flex-1 rounded-full {index <= currentStep
|
||||
? 'bg-primary-500'
|
||||
: 'bg-surface-400'}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Step content -->
|
||||
<article class="min-h-[400px]">
|
||||
{#if currentStep === 0}
|
||||
<!-- Step 1: Name & Nickname -->
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2 text-center">
|
||||
<h3 class="h3">Let's start with the basics</h3>
|
||||
<p class="text-sm opacity-75">
|
||||
Give your character a name and optionally a
|
||||
nickname. The name is required and will be used
|
||||
throughout the application.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Name Field -->
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
class="flex gap-1 font-semibold"
|
||||
for="stepName"
|
||||
>
|
||||
Name*
|
||||
<span
|
||||
class="flex items-center opacity-50 transition-opacity duration-200 hover:opacity-100"
|
||||
title="This field will be visible in prompts"
|
||||
aria-label="This field will be visible in prompts"
|
||||
>
|
||||
<Icons.ScanEye
|
||||
size={16}
|
||||
class="relative top-[1px] inline"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="stepName"
|
||||
type="text"
|
||||
bind:value={characterData.name}
|
||||
class="input {validationErrors.name
|
||||
? 'border-red-500 focus:border-red-500'
|
||||
: ''}"
|
||||
placeholder="Enter character name..."
|
||||
aria-required="true"
|
||||
aria-invalid={validationErrors.name
|
||||
? "true"
|
||||
: "false"}
|
||||
aria-describedby={validationErrors.name
|
||||
? "name-error"
|
||||
: undefined}
|
||||
oninput={() => clearValidationError("name")}
|
||||
/>
|
||||
{#if validationErrors.name}
|
||||
<p
|
||||
class="mt-1 text-sm text-red-500"
|
||||
id="name-error"
|
||||
role="alert"
|
||||
>
|
||||
{validationErrors.name}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-xs opacity-60">
|
||||
The character's full or primary name (e.g.,
|
||||
"Elizabeth Bennet", "Sherlock Holmes")
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Nickname Field -->
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
class="flex gap-1 font-semibold"
|
||||
for="stepNickname"
|
||||
>
|
||||
Nickname (Optional)
|
||||
<span
|
||||
class="flex items-center opacity-50 transition-opacity duration-200 hover:opacity-100"
|
||||
title="This field will be visible in prompts"
|
||||
aria-label="This field will be visible in prompts"
|
||||
>
|
||||
<Icons.ScanEye
|
||||
size={16}
|
||||
class="relative top-[1px] inline"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="stepNickname"
|
||||
type="text"
|
||||
bind:value={characterData.nickname}
|
||||
class="input"
|
||||
placeholder="Enter nickname (optional)..."
|
||||
aria-label="Character nickname"
|
||||
/>
|
||||
<p class="text-xs opacity-60">
|
||||
A shorter, informal name or title (e.g.,
|
||||
"Lizzy", "Detective Holmes"). If provided, the
|
||||
nickname will be used in conversations and
|
||||
prompts instead of the full name.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Example -->
|
||||
<div class="bg-primary-500/10 rounded-lg p-4">
|
||||
<h4
|
||||
class="mb-2 flex items-center gap-2 text-sm font-semibold"
|
||||
>
|
||||
<Icons.Sparkles
|
||||
size={16}
|
||||
class="text-primary-500"
|
||||
/>
|
||||
Example
|
||||
</h4>
|
||||
<div class="space-y-1 text-sm opacity-75">
|
||||
<p>
|
||||
<strong>Name:</strong>
|
||||
"Dr. John Watson"
|
||||
</p>
|
||||
<p>
|
||||
<strong>Nickname:</strong>
|
||||
"Watson"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if currentStep === 1}
|
||||
<!-- Step 2: Avatar -->
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2 text-center">
|
||||
<h3 class="h3">Add an avatar</h3>
|
||||
<p class="text-sm opacity-75">
|
||||
Upload an image to represent your character. This
|
||||
step is optional but helps personalize your
|
||||
character.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-6">
|
||||
<!-- Avatar Preview -->
|
||||
<div class="flex-shrink-0">
|
||||
<Avatar
|
||||
src={characterData._avatar ||
|
||||
characterData.avatar}
|
||||
char={characterData}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Upload Area -->
|
||||
<div class="flex-1 space-y-3">
|
||||
<div
|
||||
class="flex w-full items-center justify-center"
|
||||
>
|
||||
<label
|
||||
for="avatar-upload"
|
||||
class="flex w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-6 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-gray-500 dark:hover:bg-gray-800"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center"
|
||||
>
|
||||
<Icons.Upload
|
||||
class="mb-3 h-8 w-8 text-gray-500 dark:text-gray-400"
|
||||
/>
|
||||
<p
|
||||
class="mb-2 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span class="font-semibold">
|
||||
Click to upload
|
||||
</span>
|
||||
or drag and drop
|
||||
</p>
|
||||
<p
|
||||
class="text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
PNG, JPG or GIF
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
id="avatar-upload"
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept="image/*"
|
||||
onchange={handleAvatarChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if characterData._avatarFile}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-tonal-error w-full"
|
||||
onclick={() => {
|
||||
characterData._avatarFile = undefined
|
||||
characterData._avatar = ""
|
||||
}}
|
||||
>
|
||||
<Icons.Trash2 size={16} />
|
||||
Remove Image
|
||||
</button>
|
||||
{/if}
|
||||
<p class="text-xs opacity-60">
|
||||
Supported formats: JPG, PNG, GIF. The image will
|
||||
be resized automatically to fit the interface.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Example -->
|
||||
<div class="bg-primary-500/10 rounded-lg p-4">
|
||||
<h4
|
||||
class="mb-2 flex items-center gap-2 text-sm font-semibold"
|
||||
>
|
||||
<Icons.Sparkles
|
||||
size={16}
|
||||
class="text-primary-500"
|
||||
/>
|
||||
Tip
|
||||
</h4>
|
||||
<p class="text-sm opacity-75">
|
||||
A good avatar helps bring your character to life and
|
||||
makes conversations more engaging. You can always
|
||||
change it later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if currentStep === 2}
|
||||
<!-- Step 3: Description -->
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2 text-center">
|
||||
<h3 class="h3">Describe your character</h3>
|
||||
<p class="text-sm opacity-75">
|
||||
Write a description that captures your character's
|
||||
appearance, background, and key traits. This is
|
||||
essential for the AI to understand your character.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
class="flex gap-1 font-semibold"
|
||||
for="stepDescription"
|
||||
>
|
||||
Description*
|
||||
<span
|
||||
class="flex items-center opacity-50 transition-opacity duration-200 hover:opacity-100"
|
||||
title="This field will be visible in prompts"
|
||||
aria-label="This field will be visible in prompts"
|
||||
>
|
||||
<Icons.ScanEye
|
||||
size={16}
|
||||
class="relative top-[1px] inline"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="stepDescription"
|
||||
rows="8"
|
||||
bind:value={characterData.description}
|
||||
class="input {validationErrors.description
|
||||
? 'border-red-500 focus:border-red-500'
|
||||
: ''}"
|
||||
placeholder="Describe your character..."
|
||||
aria-required="true"
|
||||
aria-invalid={validationErrors.description
|
||||
? "true"
|
||||
: "false"}
|
||||
aria-describedby={validationErrors.description
|
||||
? "description-error"
|
||||
: undefined}
|
||||
oninput={() => clearValidationError("description")}
|
||||
></textarea>
|
||||
{#if validationErrors.description}
|
||||
<p
|
||||
class="mt-1 text-sm text-red-500"
|
||||
id="description-error"
|
||||
role="alert"
|
||||
>
|
||||
{validationErrors.description}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="space-y-2 text-xs opacity-60">
|
||||
<p>
|
||||
<strong>Include:</strong>
|
||||
Physical appearance, age, background, occupation,
|
||||
or role
|
||||
</p>
|
||||
<p>
|
||||
<strong>Avoid:</strong>
|
||||
Personality traits (save for the next step), specific
|
||||
scenarios, or conversations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Example -->
|
||||
<div class="bg-primary-500/10 rounded-lg p-4">
|
||||
<h4
|
||||
class="mb-2 flex items-center gap-2 text-sm font-semibold"
|
||||
>
|
||||
<Icons.Sparkles
|
||||
size={16}
|
||||
class="text-primary-500"
|
||||
/>
|
||||
Example
|
||||
</h4>
|
||||
<p class="text-sm opacity-75">
|
||||
"Dr. John Watson is a former army doctor in his late
|
||||
30s with short blonde hair and kind blue eyes. He's
|
||||
practical, loyal, and brave, often serving as the
|
||||
moral compass to his brilliant but eccentric
|
||||
flatmate. Having served in Afghanistan, he brings
|
||||
medical expertise and military discipline to their
|
||||
adventures."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if currentStep === 3}
|
||||
<!-- Step 4: Personality -->
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2 text-center">
|
||||
<h3 class="h3">Define their personality</h3>
|
||||
<p class="text-sm opacity-75">
|
||||
Describe how your character thinks, feels, and
|
||||
behaves. This step is optional but helps create more
|
||||
authentic interactions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
class="flex gap-1 font-semibold"
|
||||
for="stepPersonality"
|
||||
>
|
||||
Personality (Optional)
|
||||
<span
|
||||
class="flex items-center opacity-50 transition-opacity duration-200 hover:opacity-100"
|
||||
title="This field will be visible in prompts"
|
||||
aria-label="This field will be visible in prompts"
|
||||
>
|
||||
<Icons.ScanEye
|
||||
size={16}
|
||||
class="relative top-[1px] inline"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="stepPersonality"
|
||||
rows="6"
|
||||
bind:value={characterData.personality}
|
||||
class="input"
|
||||
placeholder="Describe their personality traits and quirks..."
|
||||
aria-label="Character personality"
|
||||
></textarea>
|
||||
<div class="space-y-2 text-xs opacity-60">
|
||||
<p>
|
||||
<strong>Include:</strong>
|
||||
Personality traits, values, quirks, speaking style,
|
||||
emotional tendencies
|
||||
</p>
|
||||
<p>
|
||||
<strong>Examples:</strong>
|
||||
"Optimistic and curious", "Sarcastic but caring",
|
||||
"Methodical and analytical"
|
||||
</p>
|
||||
<p>
|
||||
This helps the AI understand how your character
|
||||
should behave and respond in conversations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Example -->
|
||||
<div class="bg-primary-500/10 rounded-lg p-4">
|
||||
<h4
|
||||
class="mb-2 flex items-center gap-2 text-sm font-semibold"
|
||||
>
|
||||
<Icons.Sparkles
|
||||
size={16}
|
||||
class="text-primary-500"
|
||||
/>
|
||||
Example
|
||||
</h4>
|
||||
<p class="text-sm opacity-75">
|
||||
"Watson is patient and methodical, with a dry sense
|
||||
of humor. He's fiercely loyal to his friends and has
|
||||
a strong moral compass. While not as brilliant as
|
||||
Holmes, he's practical and grounded, often providing
|
||||
the emotional intelligence that Holmes lacks. He
|
||||
tends to be modest about his own abilities."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if currentStep === 4}
|
||||
<!-- Step 5: First Message -->
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2 text-center">
|
||||
<h3 class="h3">Set the opening scene</h3>
|
||||
<p class="text-sm opacity-75">
|
||||
Write how your character introduces themselves or
|
||||
starts a conversation. This is optional but gives a
|
||||
great first impression.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-500/10 rounded-lg p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<Icons.MessageCircle
|
||||
size={20}
|
||||
class="text-info-500 mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
<div class="space-y-2 text-sm">
|
||||
<p>
|
||||
<strong>Tips:</strong>
|
||||
Write in character, set a scene or mood, include
|
||||
an action or dialogue
|
||||
</p>
|
||||
<p>
|
||||
<strong>Consider:</strong>
|
||||
Where are they? What are they doing? How do they
|
||||
greet someone?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="font-semibold" for="stepFirstMessage">
|
||||
First Message (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="stepFirstMessage"
|
||||
rows="6"
|
||||
bind:value={characterData.firstMessage}
|
||||
class="input"
|
||||
placeholder="Write their opening message..."
|
||||
aria-label="Character first message"
|
||||
></textarea>
|
||||
<p class="text-xs opacity-60">
|
||||
This will be the first thing users see when they
|
||||
start a conversation with your character.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Example -->
|
||||
<div class="bg-primary-500/10 rounded-lg p-4">
|
||||
<h4
|
||||
class="mb-2 flex items-center gap-2 text-sm font-semibold"
|
||||
>
|
||||
<Icons.Sparkles
|
||||
size={16}
|
||||
class="text-primary-500"
|
||||
/>
|
||||
Example
|
||||
</h4>
|
||||
<p class="text-sm opacity-75">
|
||||
"*Dr. Watson looks up from his medical journal,
|
||||
adjusting his reading glasses with a warm smile* Ah,
|
||||
good to see you! I was just reviewing some
|
||||
fascinating case notes. Please, have a seat and tell
|
||||
me - what brings you to Baker Street today?"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<!-- Navigation -->
|
||||
<footer class="flex justify-between gap-4">
|
||||
<button
|
||||
class="btn preset-filled-surface-500"
|
||||
onclick={handlePrevious}
|
||||
disabled={isFirstStep}
|
||||
>
|
||||
<Icons.ChevronLeft size={16} />
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<div class="flex gap-2">
|
||||
{#if steps[currentStep].canSkip && !isLastStep}
|
||||
<button
|
||||
class="btn preset-tonal-surface"
|
||||
onclick={handleNext}
|
||||
>
|
||||
Skip
|
||||
<Icons.ChevronRight size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isLastStep}
|
||||
<button
|
||||
class="btn preset-filled-success-500"
|
||||
onclick={handleSave}
|
||||
>
|
||||
<Icons.Save size={16} />
|
||||
Create Character
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn preset-filled-primary-500"
|
||||
onclick={handleNext}
|
||||
disabled={!canProceedToNext}
|
||||
>
|
||||
Next
|
||||
<Icons.ChevronRight size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</footer>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<!-- Unsaved Changes Confirmation Modal -->
|
||||
<Modal
|
||||
open={showUnsavedChangesModal}
|
||||
onOpenChange={handleUnsavedChangesOnOpenChange}
|
||||
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
|
||||
backdropClasses="backdrop-blur-sm"
|
||||
>
|
||||
{#snippet content()}
|
||||
<header class="flex justify-between">
|
||||
<h2 class="h2">Confirm</h2>
|
||||
</header>
|
||||
<article>
|
||||
<p class="opacity-60">
|
||||
You have unsaved changes to your character. Are you sure you
|
||||
want to discard them?
|
||||
</p>
|
||||
</article>
|
||||
<footer class="flex justify-end gap-4">
|
||||
<button
|
||||
class="btn preset-filled-surface-500"
|
||||
onclick={handleUnsavedChangesCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn preset-filled-error-500"
|
||||
onclick={handleUnsavedChangesConfirm}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</footer>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { Modal } from "@skeletonlabs/skeleton-svelte"
|
||||
import { z } from "zod"
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
|
|
@ -19,12 +20,40 @@
|
|||
description
|
||||
}: Props = $props()
|
||||
|
||||
// Zod validation schema
|
||||
const nameSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").trim()
|
||||
})
|
||||
|
||||
type ValidationErrors = Record<string, string>
|
||||
|
||||
let name = $state("")
|
||||
let inputRef: HTMLInputElement | null = null
|
||||
let validationErrors: ValidationErrors = $state({})
|
||||
$effect(() => {
|
||||
if (open && inputRef) inputRef.focus()
|
||||
})
|
||||
let isValid = $derived(!!name.trim())
|
||||
let isValid = $derived(
|
||||
!!name.trim() && Object.keys(validationErrors).length === 0
|
||||
)
|
||||
|
||||
function validateForm(): boolean {
|
||||
const result = nameSchema.safeParse({ name })
|
||||
|
||||
if (result.success) {
|
||||
validationErrors = {}
|
||||
return true
|
||||
} else {
|
||||
const errors: ValidationErrors = {}
|
||||
result.error.errors.forEach((error) => {
|
||||
if (error.path.length > 0) {
|
||||
errors[error.path[0] as string] = error.message
|
||||
}
|
||||
})
|
||||
validationErrors = errors
|
||||
return false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
|
|
@ -44,15 +73,30 @@
|
|||
<input
|
||||
bind:this={inputRef}
|
||||
bind:value={name}
|
||||
class="input w-full"
|
||||
class="input w-full {validationErrors.name
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
type="text"
|
||||
placeholder="Enter a name..."
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter" && isValid) {
|
||||
onConfirm(name)
|
||||
if (validateForm()) {
|
||||
onConfirm(name)
|
||||
}
|
||||
}
|
||||
}}
|
||||
oninput={() => {
|
||||
if (validationErrors.name) {
|
||||
const { name, ...rest } = validationErrors
|
||||
validationErrors = rest
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if validationErrors.name}
|
||||
<p class="mt-1 text-sm text-red-500" role="alert">
|
||||
{validationErrors.name}
|
||||
</p>
|
||||
{/if}
|
||||
</article>
|
||||
<footer class="flex justify-end gap-4">
|
||||
<button class="btn preset-filled-surface-500" onclick={onCancel}>
|
||||
|
|
@ -60,7 +104,11 @@
|
|||
</button>
|
||||
<button
|
||||
class="btn preset-filled-primary-500"
|
||||
onclick={() => onConfirm(name)}
|
||||
onclick={() => {
|
||||
if (validateForm()) {
|
||||
onConfirm(name)
|
||||
}
|
||||
}}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Confirm
|
||||
|
|
|
|||
|
|
@ -52,24 +52,24 @@
|
|||
</h3>
|
||||
<p class="text-primary-700-300 mb-3 text-sm">
|
||||
To download <strong>{modelName}</strong>
|
||||
, you'll need to select a specific version from the
|
||||
Ollama library.
|
||||
, you'll need to select a specific version from the Ollama
|
||||
library.
|
||||
</p>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<p class="text-primary-700-300">
|
||||
<strong>Step 1:</strong>
|
||||
Click the button below to open the model's page
|
||||
on Ollama.com
|
||||
Click the button below to open the model's page on
|
||||
Ollama.com
|
||||
</p>
|
||||
<p class="text-primary-700-300">
|
||||
<strong>Step 2:</strong>
|
||||
Browse the available versions and copy the full
|
||||
name of the one you want
|
||||
Browse the available versions and copy the full name
|
||||
of the one you want
|
||||
</p>
|
||||
<p class="text-primary-700-300">
|
||||
<strong>Step 3:</strong>
|
||||
Come back here and paste the name in the next screen
|
||||
Come back here and paste the name in the next screen
|
||||
</p>
|
||||
<p class="text-primary-700-300">
|
||||
<em>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import { z } from "zod"
|
||||
import PersonaUnsavedChangesModal from "../modals/PersonaUnsavedChangesModal.svelte"
|
||||
import Avatar from "../Avatar.svelte"
|
||||
import { toaster } from "$lib/client/utils/toaster"
|
||||
|
||||
interface EditPersonaData {
|
||||
id?: number
|
||||
|
|
@ -218,15 +219,23 @@
|
|||
document.addEventListener("keydown", handleKeydown)
|
||||
|
||||
socket.on("createPersona", (res: Sockets.CreatePersona.Response) => {
|
||||
if (!res.error) {
|
||||
if (res.persona) {
|
||||
validationErrors = {} // Clear any validation errors on success
|
||||
toaster.success({
|
||||
title: "Persona Created",
|
||||
description: `Persona "${res.persona.name}" created successfully.`
|
||||
})
|
||||
closeForm()
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("updatePersona", (res: Sockets.UpdatePersona.Response) => {
|
||||
if (!res.error) {
|
||||
if (res.persona) {
|
||||
validationErrors = {} // Clear any validation errors on success
|
||||
toaster.success({
|
||||
title: "Persona Updated",
|
||||
description: `Persona "${res.persona.name}" updated successfully.`
|
||||
})
|
||||
closeForm()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { Avatar, FileUpload, Modal } from "@skeletonlabs/skeleton-svelte"
|
||||
import * as Icons from "@lucide/svelte"
|
||||
import CharacterForm from "../characterForms/CharacterForm.svelte"
|
||||
import CharacterCreator from "../modals/CharacterCreator.svelte"
|
||||
import CharacterUnsavedChangesModal from "../modals/CharacterUnsavedChangesModal.svelte"
|
||||
import { toaster } from "$lib/client/utils/toaster"
|
||||
import type { SpecV3 } from "@lenml/char-card-reader"
|
||||
|
|
@ -23,7 +24,8 @@
|
|||
)
|
||||
let search = $state("")
|
||||
let characterId: number | undefined = $state()
|
||||
let isCreating = $state(false)
|
||||
// let isCreating = $state(false)
|
||||
let showCharacterCreator = $state(false)
|
||||
let isSafeToCloseCharacterForm = $state(true)
|
||||
let showDeleteModal = $state(false)
|
||||
let characterToDelete: number | undefined = $state(undefined)
|
||||
|
|
@ -36,7 +38,7 @@
|
|||
let showLorebookImportConfirmationModal = $state(false)
|
||||
|
||||
let unsavedChanges = $derived.by(() => {
|
||||
return !isCreating && !characterId ? false : !isSafeToCloseCharacterForm
|
||||
return !characterId ? false : !isSafeToCloseCharacterForm
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
|
|
@ -77,7 +79,7 @@
|
|||
})
|
||||
|
||||
function handleCreateClick() {
|
||||
isCreating = true
|
||||
showCharacterCreator = true
|
||||
}
|
||||
|
||||
function handleEditClick(id: number) {
|
||||
|
|
@ -85,7 +87,7 @@
|
|||
}
|
||||
|
||||
function closeCharacterForm() {
|
||||
isCreating = false
|
||||
// isCreating = false
|
||||
characterId = undefined
|
||||
}
|
||||
|
||||
|
|
@ -221,12 +223,15 @@
|
|||
</script>
|
||||
|
||||
<div class="text-foreground h-full p-4">
|
||||
<!-- Commented out old character creation form - keeping for potential future use
|
||||
{#if isCreating}
|
||||
<CharacterForm
|
||||
bind:isSafeToClose={isSafeToCloseCharacterForm}
|
||||
closeForm={closeCharacterForm}
|
||||
/>
|
||||
{:else if characterId}
|
||||
-->
|
||||
{#if characterId}
|
||||
{#key characterId}
|
||||
<CharacterForm
|
||||
bind:isSafeToClose={isSafeToCloseCharacterForm}
|
||||
|
|
@ -463,3 +468,9 @@
|
|||
onCancel={handleCloseModalCancel}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Character Creator Modal -->
|
||||
<CharacterCreator
|
||||
bind:open={showCharacterCreator}
|
||||
onOpenChange={(e) => (showCharacterCreator = e.open)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { getContext, onDestroy, onMount } from "svelte"
|
||||
import * as Icons from "@lucide/svelte"
|
||||
import { Modal } from "@skeletonlabs/skeleton-svelte"
|
||||
import { z } from "zod"
|
||||
import OllamaForm from "$lib/client/connectionForms/OllamaForm.svelte"
|
||||
// import ChatGPTForm from "$lib/client/connectionForms/ChatGPTForm.svelte"
|
||||
import OpenAIForm from "$lib/client/connectionForms/OpenAIForm.svelte"
|
||||
|
|
@ -222,6 +223,18 @@
|
|||
}
|
||||
]
|
||||
|
||||
// Zod validation schemas
|
||||
const connectionSchema = z.object({
|
||||
name: z.string().min(1, "Connection name is required").trim()
|
||||
})
|
||||
|
||||
const newConnectionSchema = z.object({
|
||||
name: z.string().min(1, "Connection name is required").trim(),
|
||||
type: z.string().min(1, "Connection type is required")
|
||||
})
|
||||
|
||||
type ValidationErrors = Record<string, string>
|
||||
|
||||
// --- State ---
|
||||
let connectionsList: SelectConnection[] = $state([])
|
||||
let connection: Sockets.Connection.Response["connection"] | undefined =
|
||||
|
|
@ -243,6 +256,8 @@
|
|||
let newConnectionType = $state(CONNECTION_TYPES[0].value)
|
||||
let newConnectionOAIChatPreset: number | undefined = $state()
|
||||
let showDeleteModal = $state(false)
|
||||
let validationErrors: ValidationErrors = $state({})
|
||||
let newConnectionValidationErrors: ValidationErrors = $state({})
|
||||
|
||||
function handleSelectChange(e: Event) {
|
||||
socket.emit("setUserActiveConnection", {
|
||||
|
|
@ -255,10 +270,7 @@
|
|||
showNewConnectionModal = true
|
||||
}
|
||||
function handleNewConnectionConfirm() {
|
||||
if (!newConnectionName.trim()) {
|
||||
toaster.error({ title: "Connection name is required" })
|
||||
return
|
||||
}
|
||||
if (!validateNewConnection()) return
|
||||
if (newConnectionType === CONNECTION_TYPE.OPENAI_CHAT) {
|
||||
const preset = OAIChatPresets.find(
|
||||
(p) => p.value === newConnectionOAIChatPreset
|
||||
|
|
@ -284,7 +296,51 @@
|
|||
function handleNewConnectionCancel() {
|
||||
showNewConnectionModal = false
|
||||
}
|
||||
function validateConnection(): boolean {
|
||||
if (!connection) return false
|
||||
|
||||
const result = connectionSchema.safeParse({
|
||||
name: connection.name
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
validationErrors = {}
|
||||
return true
|
||||
} else {
|
||||
const errors: ValidationErrors = {}
|
||||
result.error.errors.forEach((error) => {
|
||||
if (error.path.length > 0) {
|
||||
errors[error.path[0] as string] = error.message
|
||||
}
|
||||
})
|
||||
validationErrors = errors
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function validateNewConnection(): boolean {
|
||||
const result = newConnectionSchema.safeParse({
|
||||
name: newConnectionName,
|
||||
type: newConnectionType
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
newConnectionValidationErrors = {}
|
||||
return true
|
||||
} else {
|
||||
const errors: ValidationErrors = {}
|
||||
result.error.errors.forEach((error) => {
|
||||
if (error.path.length > 0) {
|
||||
errors[error.path[0] as string] = error.message
|
||||
}
|
||||
})
|
||||
newConnectionValidationErrors = errors
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdate() {
|
||||
if (!validateConnection()) return
|
||||
socket.emit("updateConnection", { connection })
|
||||
}
|
||||
function handleReset() {
|
||||
|
|
@ -448,8 +504,21 @@
|
|||
id="name"
|
||||
type="text"
|
||||
bind:value={connection.name}
|
||||
class="input"
|
||||
class="input {validationErrors.name
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
oninput={() => {
|
||||
if (validationErrors.name) {
|
||||
const { name, ...rest } = validationErrors
|
||||
validationErrors = rest
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if validationErrors.name}
|
||||
<p class="mt-1 text-sm text-red-500" role="alert">
|
||||
{validationErrors.name}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if connection.type === CONNECTION_TYPE.OLLAMA}
|
||||
<OllamaForm bind:connection />
|
||||
|
|
@ -536,7 +605,9 @@
|
|||
<input
|
||||
id="newConnName"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
class="input w-full {newConnectionValidationErrors.name
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
bind:value={newConnectionName}
|
||||
placeholder="Enter a name..."
|
||||
onkeydown={(e) => {
|
||||
|
|
@ -544,7 +615,19 @@
|
|||
handleNewConnectionConfirm()
|
||||
}
|
||||
}}
|
||||
oninput={() => {
|
||||
if (newConnectionValidationErrors.name) {
|
||||
const { name, ...rest } =
|
||||
newConnectionValidationErrors
|
||||
newConnectionValidationErrors = rest
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if newConnectionValidationErrors.name}
|
||||
<p class="mt-1 text-sm text-red-500" role="alert">
|
||||
{newConnectionValidationErrors.name}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label class="font-semibold" for="newConnType">Type</label>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import ContextConfigUnsavedChangesModal from "../modals/ContextConfigUnsavedChangesModal.svelte"
|
||||
import NewNameModal from "../modals/NewNameModal.svelte"
|
||||
import { toaster } from "$lib/client/utils/toaster"
|
||||
import { z } from "zod"
|
||||
|
||||
interface Props {
|
||||
onclose?: () => Promise<boolean> | undefined
|
||||
|
|
@ -33,7 +34,36 @@
|
|||
let confirmCloseSidebarResolve: ((v: boolean) => void) | null = null
|
||||
let showAdvanced = $state(false)
|
||||
|
||||
// Zod validation schema
|
||||
const contextConfigSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").trim()
|
||||
})
|
||||
|
||||
type ValidationErrors = Record<string, string>
|
||||
let validationErrors: ValidationErrors = $state({})
|
||||
|
||||
function validateForm(): boolean {
|
||||
const result = contextConfigSchema.safeParse({
|
||||
name: contextConfig.name
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
validationErrors = {}
|
||||
return true
|
||||
} else {
|
||||
const errors: ValidationErrors = {}
|
||||
result.error.errors.forEach((error) => {
|
||||
if (error.path.length > 0) {
|
||||
errors[error.path[0] as string] = error.message
|
||||
}
|
||||
})
|
||||
validationErrors = errors
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!validateForm()) return
|
||||
socket.emit("updateContextConfig", {
|
||||
contextConfig
|
||||
})
|
||||
|
|
@ -221,9 +251,22 @@
|
|||
id="contextName"
|
||||
type="text"
|
||||
bind:value={contextConfig.name}
|
||||
class="input w-full"
|
||||
class="input w-full {validationErrors.name
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
disabled={contextConfig.isImmutable}
|
||||
oninput={() => {
|
||||
if (validationErrors.name) {
|
||||
const { name, ...rest } = validationErrors
|
||||
validationErrors = rest
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if validationErrors.name}
|
||||
<p class="mt-1 text-sm text-red-500" role="alert">
|
||||
{validationErrors.name}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import PromptConfigUnsavedChangesModal from "../modals/PromptConfigUnsavedChangesModal.svelte"
|
||||
import NewNameModal from "../modals/NewNameModal.svelte"
|
||||
import { toaster } from "$lib/client/utils/toaster"
|
||||
import { z } from "zod"
|
||||
|
||||
interface Props {
|
||||
onclose?: () => Promise<boolean> | undefined
|
||||
|
|
@ -33,7 +34,36 @@
|
|||
let showUnsavedChangesModal = $state(false)
|
||||
let confirmCloseSidebarResolve: ((v: boolean) => void) | null = null
|
||||
|
||||
// Zod validation schema
|
||||
const promptConfigSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").trim()
|
||||
})
|
||||
|
||||
type ValidationErrors = Record<string, string>
|
||||
let validationErrors: ValidationErrors = $state({})
|
||||
|
||||
function validateForm(): boolean {
|
||||
const result = promptConfigSchema.safeParse({
|
||||
name: promptConfig.name
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
validationErrors = {}
|
||||
return true
|
||||
} else {
|
||||
const errors: ValidationErrors = {}
|
||||
result.error.errors.forEach((error) => {
|
||||
if (error.path.length > 0) {
|
||||
errors[error.path[0] as string] = error.message
|
||||
}
|
||||
})
|
||||
validationErrors = errors
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!validateForm()) return
|
||||
socket.emit("updatePromptConfig", {
|
||||
promptConfig: { ...promptConfig, id: promptConfig.id }
|
||||
})
|
||||
|
|
@ -213,9 +243,22 @@
|
|||
id="promptName"
|
||||
type="text"
|
||||
bind:value={promptConfig.name}
|
||||
class="input w-full"
|
||||
class="input w-full {validationErrors.name
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
disabled={promptConfig.isImmutable}
|
||||
oninput={() => {
|
||||
if (validationErrors.name) {
|
||||
const { name, ...rest } = validationErrors
|
||||
validationErrors = rest
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if validationErrors.name}
|
||||
<p class="mt-1 text-sm text-red-500" role="alert">
|
||||
{validationErrors.name}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="font-semibold" for="systemPrompt">
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
import * as skio from "sveltekit-io"
|
||||
import { getContext, onDestroy, onMount, tick } from "svelte"
|
||||
import * as Icons from "@lucide/svelte"
|
||||
import { Modal } from "@skeletonlabs/skeleton-svelte"
|
||||
import SamplingConfigUnsavedChangesModal from "../modals/PromptConfigUnsavedChangesModal.svelte"
|
||||
import NewNameModal from "../modals/NewNameModal.svelte"
|
||||
import { toaster } from "$lib/client/utils/toaster"
|
||||
import { z } from "zod"
|
||||
|
||||
interface Props {
|
||||
onclose?: () => Promise<boolean> | undefined
|
||||
|
|
@ -28,9 +30,18 @@
|
|||
let showSelectSamplingConfig = $state(false)
|
||||
let showUnsavedChangesModal = $state(false)
|
||||
let showNewNameModal = $state(false)
|
||||
let showDeleteModal = $state(false)
|
||||
let confirmCloseSidebarResolve: ((v: boolean) => void) | null = null
|
||||
let editingField: string | null = $state(null)
|
||||
|
||||
// Zod validation schema
|
||||
const samplingConfigSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").trim()
|
||||
})
|
||||
|
||||
type ValidationErrors = Record<string, string>
|
||||
let validationErrors: ValidationErrors = $state({})
|
||||
|
||||
type FieldType = "number" | "boolean" | "string"
|
||||
|
||||
const fieldMeta: Record<
|
||||
|
|
@ -148,11 +159,37 @@
|
|||
showNewNameModal = false
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
if (!sampling) return false
|
||||
|
||||
const result = samplingConfigSchema.safeParse({
|
||||
name: sampling.name
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
validationErrors = {}
|
||||
return true
|
||||
} else {
|
||||
const errors: ValidationErrors = {}
|
||||
result.error.errors.forEach((error) => {
|
||||
if (error.path.length > 0) {
|
||||
errors[error.path[0] as string] = error.message
|
||||
}
|
||||
})
|
||||
validationErrors = errors
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdate() {
|
||||
if (sampling!.isImmutable) {
|
||||
alert("Cannot save immutable sampling.")
|
||||
toaster.error({
|
||||
title: "Cannot Save",
|
||||
description: "Cannot save immutable sampling configuration."
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!validateForm()) return
|
||||
socket.emit("updateSamplingConfig", { sampling })
|
||||
}
|
||||
|
||||
|
|
@ -162,18 +199,24 @@
|
|||
|
||||
function handleDelete() {
|
||||
if (sampling!.isImmutable) {
|
||||
alert("Cannot delete immutable sampling.")
|
||||
toaster.error({
|
||||
title: "Cannot Delete",
|
||||
description: "Cannot delete immutable sampling configuration."
|
||||
})
|
||||
return
|
||||
}
|
||||
if (
|
||||
confirm(
|
||||
"Are you sure you want to delete these sampling? This cannot be undone."
|
||||
)
|
||||
) {
|
||||
socket.emit("deleteSamplingConfig", {
|
||||
id: userCtx.user.activeSamplingConfigId
|
||||
})
|
||||
}
|
||||
showDeleteModal = true
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
socket.emit("deleteSamplingConfig", {
|
||||
id: userCtx.user.activeSamplingConfigId
|
||||
})
|
||||
showDeleteModal = false
|
||||
}
|
||||
|
||||
function cancelDelete() {
|
||||
showDeleteModal = false
|
||||
}
|
||||
|
||||
function handleSelectSamplingConfig() {
|
||||
|
|
@ -372,9 +415,22 @@
|
|||
id="samplingName"
|
||||
type="text"
|
||||
bind:value={sampling.name}
|
||||
class="input"
|
||||
class="input {validationErrors.name
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
disabled={!!sampling && sampling.isImmutable}
|
||||
oninput={() => {
|
||||
if (validationErrors.name) {
|
||||
const { name, ...rest } = validationErrors
|
||||
validationErrors = rest
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if validationErrors.name}
|
||||
<p class="mt-1 text-sm text-red-500" role="alert">
|
||||
{validationErrors.name}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#each Object.entries(fieldMeta) as [key, meta]}
|
||||
{#if isFieldVisible(key)}
|
||||
|
|
@ -514,3 +570,33 @@
|
|||
title="New Sampling Config"
|
||||
description="Your current settings will be copied."
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={showDeleteModal}
|
||||
onOpenChange={(e) => (showDeleteModal = e.open)}
|
||||
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm"
|
||||
backdropClasses="backdrop-blur-sm"
|
||||
>
|
||||
{#snippet content()}
|
||||
<header class="flex justify-between">
|
||||
<h2 class="h2">Delete Sampling Configuration</h2>
|
||||
</header>
|
||||
<article>
|
||||
<p class="opacity-60">
|
||||
Are you sure you want to delete the sampling configuration "{sampling?.name}"?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</article>
|
||||
<footer class="flex justify-end gap-4">
|
||||
<button
|
||||
class="btn preset-filled-surface-500"
|
||||
onclick={cancelDelete}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn preset-filled-error-500" onclick={confirmDelete}>
|
||||
Delete
|
||||
</button>
|
||||
</footer>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -34,38 +34,156 @@
|
|||
let relatedPersonas: SelectPersona[] = $state([])
|
||||
let relatedChats: SelectChat[] = $state([])
|
||||
|
||||
// Zod validation schema
|
||||
const tagSchema = z.object({
|
||||
name: z.string().min(1, "Tag name is required").trim()
|
||||
})
|
||||
|
||||
type ValidationErrors = Record<string, string>
|
||||
let validationErrors: ValidationErrors = $state({})
|
||||
let editValidationErrors: ValidationErrors = $state({})
|
||||
|
||||
// Filtered tags for display
|
||||
let filteredTags: SelectTag[] = $derived.by(() => {
|
||||
if (!search) return tagsList
|
||||
return tagsList.filter(tag =>
|
||||
tag.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(tag.description && tag.description.toLowerCase().includes(search.toLowerCase()))
|
||||
return tagsList.filter(
|
||||
(tag) =>
|
||||
tag.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(tag.description &&
|
||||
tag.description
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase()))
|
||||
)
|
||||
})
|
||||
|
||||
// Color preset options
|
||||
const colorPresetOptions = [
|
||||
{ value: "preset-filled-primary-500", label: "Primary Filled", type: "filled", color: "primary" },
|
||||
{ value: "preset-tonal-primary", label: "Primary Tonal", type: "tonal", color: "primary" },
|
||||
{ value: "preset-outlined-primary-500", label: "Primary Outlined", type: "outlined", color: "primary" },
|
||||
{ value: "preset-filled-secondary-500", label: "Secondary Filled", type: "filled", color: "secondary" },
|
||||
{ value: "preset-tonal-secondary", label: "Secondary Tonal", type: "tonal", color: "secondary" },
|
||||
{ value: "preset-outlined-secondary-500", label: "Secondary Outlined", type: "outlined", color: "secondary" },
|
||||
{ value: "preset-filled-tertiary-500", label: "Tertiary Filled", type: "filled", color: "tertiary" },
|
||||
{ value: "preset-tonal-tertiary", label: "Tertiary Tonal", type: "tonal", color: "tertiary" },
|
||||
{ value: "preset-outlined-tertiary-500", label: "Tertiary Outlined", type: "outlined", color: "tertiary" },
|
||||
{ value: "preset-filled-success-500", label: "Success Filled", type: "filled", color: "success" },
|
||||
{ value: "preset-tonal-success", label: "Success Tonal", type: "tonal", color: "success" },
|
||||
{ value: "preset-outlined-success-500", label: "Success Outlined", type: "outlined", color: "success" },
|
||||
{ value: "preset-filled-warning-500", label: "Warning Filled", type: "filled", color: "warning" },
|
||||
{ value: "preset-tonal-warning", label: "Warning Tonal", type: "tonal", color: "warning" },
|
||||
{ value: "preset-outlined-warning-500", label: "Warning Outlined", type: "outlined", color: "warning" },
|
||||
{ value: "preset-filled-error-500", label: "Error Filled", type: "filled", color: "error" },
|
||||
{ value: "preset-tonal-error", label: "Error Tonal", type: "tonal", color: "error" },
|
||||
{ value: "preset-outlined-error-500", label: "Error Outlined", type: "outlined", color: "error" },
|
||||
{ value: "preset-filled-surface-500", label: "Surface Filled", type: "filled", color: "surface" },
|
||||
{ value: "preset-tonal-surface", label: "Surface Tonal", type: "tonal", color: "surface" },
|
||||
{ value: "preset-outlined-surface-500", label: "Surface Outlined", type: "outlined", color: "surface" }
|
||||
{
|
||||
value: "preset-filled-primary-500",
|
||||
label: "Primary Filled",
|
||||
type: "filled",
|
||||
color: "primary"
|
||||
},
|
||||
{
|
||||
value: "preset-tonal-primary",
|
||||
label: "Primary Tonal",
|
||||
type: "tonal",
|
||||
color: "primary"
|
||||
},
|
||||
{
|
||||
value: "preset-outlined-primary-500",
|
||||
label: "Primary Outlined",
|
||||
type: "outlined",
|
||||
color: "primary"
|
||||
},
|
||||
{
|
||||
value: "preset-filled-secondary-500",
|
||||
label: "Secondary Filled",
|
||||
type: "filled",
|
||||
color: "secondary"
|
||||
},
|
||||
{
|
||||
value: "preset-tonal-secondary",
|
||||
label: "Secondary Tonal",
|
||||
type: "tonal",
|
||||
color: "secondary"
|
||||
},
|
||||
{
|
||||
value: "preset-outlined-secondary-500",
|
||||
label: "Secondary Outlined",
|
||||
type: "outlined",
|
||||
color: "secondary"
|
||||
},
|
||||
{
|
||||
value: "preset-filled-tertiary-500",
|
||||
label: "Tertiary Filled",
|
||||
type: "filled",
|
||||
color: "tertiary"
|
||||
},
|
||||
{
|
||||
value: "preset-tonal-tertiary",
|
||||
label: "Tertiary Tonal",
|
||||
type: "tonal",
|
||||
color: "tertiary"
|
||||
},
|
||||
{
|
||||
value: "preset-outlined-tertiary-500",
|
||||
label: "Tertiary Outlined",
|
||||
type: "outlined",
|
||||
color: "tertiary"
|
||||
},
|
||||
{
|
||||
value: "preset-filled-success-500",
|
||||
label: "Success Filled",
|
||||
type: "filled",
|
||||
color: "success"
|
||||
},
|
||||
{
|
||||
value: "preset-tonal-success",
|
||||
label: "Success Tonal",
|
||||
type: "tonal",
|
||||
color: "success"
|
||||
},
|
||||
{
|
||||
value: "preset-outlined-success-500",
|
||||
label: "Success Outlined",
|
||||
type: "outlined",
|
||||
color: "success"
|
||||
},
|
||||
{
|
||||
value: "preset-filled-warning-500",
|
||||
label: "Warning Filled",
|
||||
type: "filled",
|
||||
color: "warning"
|
||||
},
|
||||
{
|
||||
value: "preset-tonal-warning",
|
||||
label: "Warning Tonal",
|
||||
type: "tonal",
|
||||
color: "warning"
|
||||
},
|
||||
{
|
||||
value: "preset-outlined-warning-500",
|
||||
label: "Warning Outlined",
|
||||
type: "outlined",
|
||||
color: "warning"
|
||||
},
|
||||
{
|
||||
value: "preset-filled-error-500",
|
||||
label: "Error Filled",
|
||||
type: "filled",
|
||||
color: "error"
|
||||
},
|
||||
{
|
||||
value: "preset-tonal-error",
|
||||
label: "Error Tonal",
|
||||
type: "tonal",
|
||||
color: "error"
|
||||
},
|
||||
{
|
||||
value: "preset-outlined-error-500",
|
||||
label: "Error Outlined",
|
||||
type: "outlined",
|
||||
color: "error"
|
||||
},
|
||||
{
|
||||
value: "preset-filled-surface-500",
|
||||
label: "Surface Filled",
|
||||
type: "filled",
|
||||
color: "surface"
|
||||
},
|
||||
{
|
||||
value: "preset-tonal-surface",
|
||||
label: "Surface Tonal",
|
||||
type: "tonal",
|
||||
color: "surface"
|
||||
},
|
||||
{
|
||||
value: "preset-outlined-surface-500",
|
||||
label: "Surface Outlined",
|
||||
type: "outlined",
|
||||
color: "surface"
|
||||
}
|
||||
]
|
||||
|
||||
function handleCreateClick() {
|
||||
|
|
@ -80,7 +198,8 @@
|
|||
isEditing = true
|
||||
editTagName = selectedTag.name
|
||||
editTagDescription = selectedTag.description || ""
|
||||
editTagColorPreset = selectedTag.colorPreset || "preset-filled-primary-500"
|
||||
editTagColorPreset =
|
||||
selectedTag.colorPreset || "preset-filled-primary-500"
|
||||
}
|
||||
|
||||
function handleDeleteClick() {
|
||||
|
|
@ -95,29 +214,69 @@
|
|||
socket.emit("tagRelatedData", { tagId: tag.id })
|
||||
}
|
||||
|
||||
function validateNewTag(): boolean {
|
||||
const result = tagSchema.safeParse({
|
||||
name: newTagName
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
validationErrors = {}
|
||||
return true
|
||||
} else {
|
||||
const errors: ValidationErrors = {}
|
||||
result.error.errors.forEach((error) => {
|
||||
if (error.path.length > 0) {
|
||||
errors[error.path[0] as string] = error.message
|
||||
}
|
||||
})
|
||||
validationErrors = errors
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function validateEditTag(): boolean {
|
||||
const result = tagSchema.safeParse({
|
||||
name: editTagName
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
editValidationErrors = {}
|
||||
return true
|
||||
} else {
|
||||
const errors: ValidationErrors = {}
|
||||
result.error.errors.forEach((error) => {
|
||||
if (error.path.length > 0) {
|
||||
errors[error.path[0] as string] = error.message
|
||||
}
|
||||
})
|
||||
editValidationErrors = errors
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function createTag() {
|
||||
if (!newTagName.trim()) return
|
||||
|
||||
if (!validateNewTag()) return
|
||||
|
||||
const tag: InsertTag = {
|
||||
name: newTagName.trim(),
|
||||
description: newTagDescription.trim() || null,
|
||||
colorPreset: newTagColorPreset
|
||||
}
|
||||
|
||||
|
||||
socket.emit("createTag", { tag })
|
||||
isCreating = false
|
||||
}
|
||||
|
||||
function updateTag() {
|
||||
if (!selectedTag || !editTagName.trim()) return
|
||||
|
||||
if (!selectedTag || !validateEditTag()) return
|
||||
|
||||
const tag: SelectTag = {
|
||||
...selectedTag,
|
||||
name: editTagName.trim(),
|
||||
description: editTagDescription.trim() || null,
|
||||
colorPreset: editTagColorPreset
|
||||
}
|
||||
|
||||
|
||||
socket.emit("updateTag", { tag })
|
||||
isEditing = false
|
||||
}
|
||||
|
|
@ -201,7 +360,7 @@
|
|||
})
|
||||
|
||||
socket.emit("tagsList", {})
|
||||
|
||||
|
||||
onclose = async () => {
|
||||
return true
|
||||
}
|
||||
|
|
@ -209,7 +368,7 @@
|
|||
|
||||
onDestroy(() => {
|
||||
socket.off("tagsList")
|
||||
socket.off("createTag")
|
||||
socket.off("createTag")
|
||||
socket.off("updateTag")
|
||||
socket.off("deleteTag")
|
||||
socket.off("tagRelatedData")
|
||||
|
|
@ -223,15 +382,21 @@
|
|||
<div class="mb-4">
|
||||
<button
|
||||
class="btn btn-sm preset-filled-surface-500 mb-3"
|
||||
onclick={() => { selectedTag = null }}
|
||||
onclick={() => {
|
||||
selectedTag = null
|
||||
}}
|
||||
>
|
||||
<Icons.ArrowLeft size={16} />
|
||||
Back to Tags
|
||||
</button>
|
||||
|
||||
<div class="border border-primary-500 rounded-lg p-4 mb-4 bg-surface-50-950">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-xl font-bold text-primary-500">{selectedTag.name}</h2>
|
||||
<div
|
||||
class="border-primary-500 bg-surface-50-950 mb-4 rounded-lg border p-4"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h2 class="text-primary-500 text-xl font-bold">
|
||||
{selectedTag.name}
|
||||
</h2>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn btn-sm text-primary-500 p-2"
|
||||
|
|
@ -250,26 +415,34 @@
|
|||
</div>
|
||||
</div>
|
||||
{#if selectedTag.description}
|
||||
<p class="text-muted-foreground text-sm">{selectedTag.description}</p>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{selectedTag.description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Related sections -->
|
||||
{#if relatedCharacters.length > 0}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<h3
|
||||
class="mb-3 flex items-center gap-2 text-lg font-semibold"
|
||||
>
|
||||
<Icons.User size={18} />
|
||||
Characters ({relatedCharacters.length})
|
||||
</h3>
|
||||
<div class="grid gap-2">
|
||||
{#each relatedCharacters as character}
|
||||
<button
|
||||
class="p-3 rounded-lg bg-surface-100-900 hover:bg-surface-200-800 transition-colors text-left"
|
||||
class="bg-surface-100-900 hover:bg-surface-200-800 rounded-lg p-3 text-left transition-colors"
|
||||
onclick={() => handleCharacterClick(character)}
|
||||
>
|
||||
<div class="font-medium">{character.nickname || character.name}</div>
|
||||
<div class="font-medium">
|
||||
{character.nickname || character.name}
|
||||
</div>
|
||||
{#if character.description}
|
||||
<div class="text-sm text-muted-foreground line-clamp-2 mt-1">
|
||||
<div
|
||||
class="text-muted-foreground mt-1 line-clamp-2 text-sm"
|
||||
>
|
||||
{character.description}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -281,19 +454,23 @@
|
|||
|
||||
{#if relatedPersonas.length > 0}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<h3
|
||||
class="mb-3 flex items-center gap-2 text-lg font-semibold"
|
||||
>
|
||||
<Icons.UserCog size={18} />
|
||||
Personas ({relatedPersonas.length})
|
||||
</h3>
|
||||
<div class="grid gap-2">
|
||||
{#each relatedPersonas as persona}
|
||||
<button
|
||||
class="p-3 rounded-lg bg-surface-100-900 hover:bg-surface-200-800 transition-colors text-left"
|
||||
class="bg-surface-100-900 hover:bg-surface-200-800 rounded-lg p-3 text-left transition-colors"
|
||||
onclick={() => handlePersonaClick(persona)}
|
||||
>
|
||||
<div class="font-medium">{persona.name}</div>
|
||||
{#if persona.description}
|
||||
<div class="text-sm text-muted-foreground line-clamp-2 mt-1">
|
||||
<div
|
||||
class="text-muted-foreground mt-1 line-clamp-2 text-sm"
|
||||
>
|
||||
{persona.description}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -305,19 +482,25 @@
|
|||
|
||||
{#if relatedChats.length > 0}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<h3
|
||||
class="mb-3 flex items-center gap-2 text-lg font-semibold"
|
||||
>
|
||||
<Icons.MessageSquare size={18} />
|
||||
Chats ({relatedChats.length})
|
||||
</h3>
|
||||
<div class="grid gap-2">
|
||||
{#each relatedChats as chat}
|
||||
<button
|
||||
class="p-3 rounded-lg bg-surface-100-900 hover:bg-surface-200-800 transition-colors text-left"
|
||||
class="bg-surface-100-900 hover:bg-surface-200-800 rounded-lg p-3 text-left transition-colors"
|
||||
onclick={() => handleChatClick(chat)}
|
||||
>
|
||||
<div class="font-medium">{chat.name || 'Unnamed Chat'}</div>
|
||||
<div class="font-medium">
|
||||
{chat.name || "Unnamed Chat"}
|
||||
</div>
|
||||
{#if chat.scenario}
|
||||
<div class="text-sm text-muted-foreground line-clamp-2 mt-1">
|
||||
<div
|
||||
class="text-muted-foreground mt-1 line-clamp-2 text-sm"
|
||||
>
|
||||
{chat.scenario}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -327,12 +510,15 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if isCreating}
|
||||
<!-- Create tag form -->
|
||||
<div>
|
||||
<h1 class="mb-4 text-lg font-bold">Create New Tag</h1>
|
||||
<div class="mt-4 mb-4 flex gap-2" role="group" aria-label="Form actions">
|
||||
<div
|
||||
class="mt-4 mb-4 flex gap-2"
|
||||
role="group"
|
||||
aria-label="Form actions"
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm preset-filled-surface-500 w-full"
|
||||
onclick={cancelCreate}
|
||||
|
|
@ -342,24 +528,54 @@
|
|||
<button
|
||||
class="btn btn-sm preset-filled-primary-500 w-full"
|
||||
onclick={createTag}
|
||||
disabled={!newTagName.trim()}
|
||||
disabled={Object.keys(validationErrors).length > 0 ||
|
||||
!newTagName.trim()}
|
||||
>
|
||||
Create Tag
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block font-semibold mb-1" for="tagName">Name</label>
|
||||
<label class="mb-1 block font-semibold" for="tagName">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="tagName"
|
||||
name="tagName"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
class="input w-full {validationErrors.name
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
bind:value={newTagName}
|
||||
placeholder="Enter tag name"
|
||||
aria-invalid={validationErrors.name ? "true" : "false"}
|
||||
aria-describedby={validationErrors.name
|
||||
? "name-error"
|
||||
: undefined}
|
||||
oninput={() => {
|
||||
if (validationErrors.name) {
|
||||
const { name, ...rest } = validationErrors
|
||||
validationErrors = rest
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if validationErrors.name}
|
||||
<p
|
||||
id="name-error"
|
||||
class="mt-1 text-sm text-red-500"
|
||||
role="alert"
|
||||
>
|
||||
{validationErrors.name}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block font-semibold mb-1" for="tagDescription">Description (Optional)</label>
|
||||
<label
|
||||
class="mb-1 block font-semibold"
|
||||
for="tagDescription"
|
||||
>
|
||||
Description (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
name="tagDescription"
|
||||
class="input w-full"
|
||||
|
|
@ -369,7 +585,9 @@
|
|||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block font-semibold mb-1" for="colorPreset">Color Preset</label>
|
||||
<label class="mb-1 block font-semibold" for="colorPreset">
|
||||
Color Preset
|
||||
</label>
|
||||
<select
|
||||
name="colorPreset"
|
||||
class="input w-full"
|
||||
|
|
@ -380,20 +598,28 @@
|
|||
{/each}
|
||||
</select>
|
||||
<div class="mt-2">
|
||||
<span class="text-sm text-muted-foreground">Preview:</span>
|
||||
<button type="button" class="chip {newTagColorPreset} ml-2">
|
||||
{newTagName.trim() || 'Tag Preview'}
|
||||
<span class="text-muted-foreground text-sm">
|
||||
Preview:
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="chip {newTagColorPreset} ml-2"
|
||||
>
|
||||
{newTagName.trim() || "Tag Preview"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if isEditing}
|
||||
<!-- Edit tag form -->
|
||||
<div>
|
||||
<h1 class="mb-4 text-lg font-bold">Edit Tag</h1>
|
||||
<div class="mt-4 mb-4 flex gap-2" role="group" aria-label="Form actions">
|
||||
<div
|
||||
class="mt-4 mb-4 flex gap-2"
|
||||
role="group"
|
||||
aria-label="Form actions"
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm preset-filled-surface-500 w-full"
|
||||
onclick={cancelEdit}
|
||||
|
|
@ -403,24 +629,56 @@
|
|||
<button
|
||||
class="btn btn-sm preset-filled-primary-500 w-full"
|
||||
onclick={updateTag}
|
||||
disabled={!editTagName.trim()}
|
||||
disabled={Object.keys(editValidationErrors).length > 0 ||
|
||||
!editTagName.trim()}
|
||||
>
|
||||
Update Tag
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block font-semibold mb-1" for="editTagName">Name</label>
|
||||
<label class="mb-1 block font-semibold" for="editTagName">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="editTagName"
|
||||
name="editTagName"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
class="input w-full {editValidationErrors.name
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
bind:value={editTagName}
|
||||
placeholder="Enter tag name"
|
||||
aria-invalid={editValidationErrors.name
|
||||
? "true"
|
||||
: "false"}
|
||||
aria-describedby={editValidationErrors.name
|
||||
? "edit-name-error"
|
||||
: undefined}
|
||||
oninput={() => {
|
||||
if (editValidationErrors.name) {
|
||||
const { name, ...rest } = editValidationErrors
|
||||
editValidationErrors = rest
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if editValidationErrors.name}
|
||||
<p
|
||||
id="edit-name-error"
|
||||
class="mt-1 text-sm text-red-500"
|
||||
role="alert"
|
||||
>
|
||||
{editValidationErrors.name}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block font-semibold mb-1" for="editTagDescription">Description (Optional)</label>
|
||||
<label
|
||||
class="mb-1 block font-semibold"
|
||||
for="editTagDescription"
|
||||
>
|
||||
Description (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
name="editTagDescription"
|
||||
class="input w-full"
|
||||
|
|
@ -430,7 +688,12 @@
|
|||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block font-semibold mb-1" for="editColorPreset">Color Preset</label>
|
||||
<label
|
||||
class="mb-1 block font-semibold"
|
||||
for="editColorPreset"
|
||||
>
|
||||
Color Preset
|
||||
</label>
|
||||
<select
|
||||
name="editColorPreset"
|
||||
class="input w-full"
|
||||
|
|
@ -441,15 +704,19 @@
|
|||
{/each}
|
||||
</select>
|
||||
<div class="mt-2">
|
||||
<span class="text-sm text-muted-foreground">Preview:</span>
|
||||
<button type="button" class="chip {editTagColorPreset} ml-2">
|
||||
{editTagName.trim() || 'Tag Preview'}
|
||||
<span class="text-muted-foreground text-sm">
|
||||
Preview:
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="chip {editTagColorPreset} ml-2"
|
||||
>
|
||||
{editTagName.trim() || "Tag Preview"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- Main tags list view -->
|
||||
<div class="mb-2 flex gap-2">
|
||||
|
|
@ -472,7 +739,7 @@
|
|||
</div>
|
||||
|
||||
{#if filteredTags.length === 0}
|
||||
<div class="text-muted-foreground py-8 text-center w-100 relative">
|
||||
<div class="text-muted-foreground relative w-100 py-8 text-center">
|
||||
No tags found.
|
||||
</div>
|
||||
{:else}
|
||||
|
|
@ -481,7 +748,8 @@
|
|||
{#each filteredTags as tag}
|
||||
<button
|
||||
type="button"
|
||||
class="chip {tag.colorPreset || 'preset-filled-primary-500'} transition-all duration-200"
|
||||
class="chip {tag.colorPreset ||
|
||||
'preset-filled-primary-500'} transition-all duration-200"
|
||||
onclick={() => handleTagClick(tag)}
|
||||
title={tag.description || tag.name}
|
||||
>
|
||||
|
|
@ -505,8 +773,9 @@
|
|||
<div class="p-6">
|
||||
<h2 class="mb-2 text-lg font-bold">Delete Tag?</h2>
|
||||
<p class="mb-4">
|
||||
Are you sure you want to delete the tag "{tagToDelete?.name}"? This action
|
||||
cannot be undone and will remove the tag from all associated items.
|
||||
Are you sure you want to delete the tag "{tagToDelete?.name}"?
|
||||
This action cannot be undone and will remove the tag from
|
||||
all associated items.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
<script lang="ts">
|
||||
import * as skio from "sveltekit-io"
|
||||
import { onDestroy } from "svelte"
|
||||
import { z } from "zod"
|
||||
|
||||
// Zod validation schema
|
||||
const chatGPTConnectionSchema = z.object({
|
||||
baseUrl: z
|
||||
.string()
|
||||
.url("Invalid URL format")
|
||||
.min(1, "Base URL is required"),
|
||||
model: z.string().min(1, "Model is required"),
|
||||
apiKey: z.string().min(1, "API key is required")
|
||||
})
|
||||
|
||||
type ValidationErrors = Record<string, string>
|
||||
|
||||
interface Props {
|
||||
connection: SelectConnection
|
||||
|
|
@ -9,11 +22,37 @@
|
|||
let { connection = $bindable() } = $props()
|
||||
|
||||
let testResult: { ok: boolean; error?: string } | null = $state(null)
|
||||
let validationErrors: ValidationErrors = $state({})
|
||||
const socket = skio.get()
|
||||
function handleTestConnection() {
|
||||
if (!validateConnection()) return
|
||||
testResult = null
|
||||
socket.emit("testConnection", { connection })
|
||||
}
|
||||
|
||||
function validateConnection(): boolean {
|
||||
const data = {
|
||||
baseUrl: connection.baseUrl || "",
|
||||
model: connection.model || "",
|
||||
apiKey: connection.apiKey || ""
|
||||
}
|
||||
|
||||
const result = chatGPTConnectionSchema.safeParse(data)
|
||||
|
||||
if (result.success) {
|
||||
validationErrors = {}
|
||||
return true
|
||||
} else {
|
||||
const errors: ValidationErrors = {}
|
||||
result.error.errors.forEach((error) => {
|
||||
if (error.path.length > 0) {
|
||||
errors[error.path[0] as string] = error.message
|
||||
}
|
||||
})
|
||||
validationErrors = errors
|
||||
return false
|
||||
}
|
||||
}
|
||||
$effect(() => {
|
||||
function handleTest(msg) {
|
||||
testResult = msg
|
||||
|
|
@ -36,8 +75,27 @@
|
|||
id="baseUrl"
|
||||
type="text"
|
||||
bind:value={connection.baseUrl}
|
||||
class="input"
|
||||
class="input {validationErrors.baseUrl ? 'border-red-500' : ''}"
|
||||
aria-invalid={validationErrors.baseUrl ? "true" : "false"}
|
||||
aria-describedby={validationErrors.baseUrl
|
||||
? "baseUrl-error"
|
||||
: undefined}
|
||||
oninput={() => {
|
||||
if (validationErrors.baseUrl) {
|
||||
const { baseUrl, ...rest } = validationErrors
|
||||
validationErrors = rest
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if validationErrors.baseUrl}
|
||||
<p
|
||||
id="baseUrl-error"
|
||||
class="mt-1 text-sm text-red-500"
|
||||
role="alert"
|
||||
>
|
||||
{validationErrors.baseUrl}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-col gap-1">
|
||||
<label class="font-semibold" for="model">Model</label>
|
||||
|
|
@ -45,8 +103,23 @@
|
|||
id="model"
|
||||
type="text"
|
||||
bind:value={connection.model}
|
||||
class="input"
|
||||
class="input {validationErrors.model ? 'border-red-500' : ''}"
|
||||
aria-invalid={validationErrors.model ? "true" : "false"}
|
||||
aria-describedby={validationErrors.model
|
||||
? "model-error"
|
||||
: undefined}
|
||||
oninput={() => {
|
||||
if (validationErrors.model) {
|
||||
const { model, ...rest } = validationErrors
|
||||
validationErrors = rest
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if validationErrors.model}
|
||||
<p id="model-error" class="mt-1 text-sm text-red-500" role="alert">
|
||||
{validationErrors.model}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-col gap-1">
|
||||
<label class="font-semibold" for="enabled">Enabled</label>
|
||||
|
|
@ -61,15 +134,31 @@
|
|||
<label class="font-semibold" for="chatgptApiKey">ChatGPT API Key</label>
|
||||
<input
|
||||
id="chatgptApiKey"
|
||||
type="text"
|
||||
type="password"
|
||||
bind:value={connection.apiKey}
|
||||
class="input"
|
||||
class="input {validationErrors.apiKey ? 'border-red-500' : ''}"
|
||||
aria-invalid={validationErrors.apiKey ? "true" : "false"}
|
||||
aria-describedby={validationErrors.apiKey
|
||||
? "apiKey-error"
|
||||
: undefined}
|
||||
oninput={() => {
|
||||
if (validationErrors.apiKey) {
|
||||
const { apiKey, ...rest } = validationErrors
|
||||
validationErrors = rest
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if validationErrors.apiKey}
|
||||
<p id="apiKey-error" class="mt-1 text-sm text-red-500" role="alert">
|
||||
{validationErrors.apiKey}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn preset-filled-primary-500 mt-2 w-full"
|
||||
onclick={handleTestConnection}
|
||||
disabled={Object.keys(validationErrors).length > 0}
|
||||
>
|
||||
Test Connection
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,18 @@
|
|||
import { TokenCounterOptions } from "$lib/shared/constants/TokenCounters"
|
||||
import { onMount, onDestroy } from "svelte"
|
||||
import * as skio from "sveltekit-io"
|
||||
import { z } from "zod"
|
||||
|
||||
// Zod validation schema
|
||||
const lmStudioConnectionSchema = z.object({
|
||||
model: z.string().min(1, "Model is required"),
|
||||
baseUrl: z
|
||||
.string()
|
||||
.url("Invalid URL format")
|
||||
.min(1, "Base URL is required")
|
||||
})
|
||||
|
||||
type ValidationErrors = Record<string, string>
|
||||
|
||||
interface Props {
|
||||
connection: SelectConnection
|
||||
|
|
@ -17,6 +29,7 @@
|
|||
error?: string | null
|
||||
models?: any[]
|
||||
} | null = $state(null)
|
||||
let validationErrors: ValidationErrors = $state({})
|
||||
|
||||
// Initialize extraFields from connection.extraJson, but don't make it reactive to connection changes
|
||||
let extraFields = $state({
|
||||
|
|
@ -34,12 +47,36 @@
|
|||
}
|
||||
|
||||
function handleTestConnection() {
|
||||
if (!validateConnection()) return
|
||||
testResult = null
|
||||
socket.emit("testConnection", {
|
||||
connection
|
||||
} as Sockets.TestConnection.Call)
|
||||
}
|
||||
|
||||
function validateConnection(): boolean {
|
||||
const data = {
|
||||
model: connection.model || "",
|
||||
baseUrl: connection.baseUrl || ""
|
||||
}
|
||||
|
||||
const result = lmStudioConnectionSchema.safeParse(data)
|
||||
|
||||
if (result.success) {
|
||||
validationErrors = {}
|
||||
return true
|
||||
} else {
|
||||
const errors: ValidationErrors = {}
|
||||
result.error.errors.forEach((error) => {
|
||||
if (error.path.length > 0) {
|
||||
errors[error.path[0] as string] = error.message
|
||||
}
|
||||
})
|
||||
validationErrors = errors
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
socket.on("refreshModels", (msg: Sockets.RefreshModels.Response) => {
|
||||
if (msg.models) availableLMStudioModels = msg.models
|
||||
if (!connection.model && msg.models.length > 0) {
|
||||
|
|
@ -69,13 +106,30 @@
|
|||
<select
|
||||
id="model"
|
||||
bind:value={connection.model}
|
||||
class="select bg-background border-muted w-full rounded border"
|
||||
class="select bg-background border-muted w-full rounded border {validationErrors.model
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
aria-invalid={validationErrors.model ? "true" : "false"}
|
||||
aria-describedby={validationErrors.model
|
||||
? "model-error"
|
||||
: undefined}
|
||||
oninput={() => {
|
||||
if (validationErrors.model) {
|
||||
const { model, ...rest } = validationErrors
|
||||
validationErrors = rest
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">-- Select Model --</option>
|
||||
{#each availableLMStudioModels as m}
|
||||
<option value={m.model}>{m.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if validationErrors.model}
|
||||
<p id="model-error" class="mt-1 text-sm text-red-500" role="alert">
|
||||
{validationErrors.model}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -88,6 +142,7 @@
|
|||
type="button"
|
||||
class="btn preset-tonal-success btn-sm w-full"
|
||||
onclick={handleTestConnection}
|
||||
disabled={Object.keys(validationErrors).length > 0}
|
||||
>
|
||||
{#if testResult?.ok === true}
|
||||
Test: Okay!
|
||||
|
|
@ -172,8 +227,29 @@
|
|||
bind:value={connection.baseUrl}
|
||||
placeholder="ws://localhost:1234"
|
||||
required
|
||||
class="input"
|
||||
class="input {validationErrors.baseUrl
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
aria-invalid={validationErrors.baseUrl ? "true" : "false"}
|
||||
aria-describedby={validationErrors.baseUrl
|
||||
? "baseUrl-error"
|
||||
: undefined}
|
||||
oninput={() => {
|
||||
if (validationErrors.baseUrl) {
|
||||
const { baseUrl, ...rest } = validationErrors
|
||||
validationErrors = rest
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if validationErrors.baseUrl}
|
||||
<p
|
||||
id="baseUrl-error"
|
||||
class="mt-1 text-sm text-red-500"
|
||||
role="alert"
|
||||
>
|
||||
{validationErrors.baseUrl}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Use Chat toggle -->
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { TokenCounterOptions } from "$lib/shared/constants/TokenCounters"
|
||||
import { onMount, onDestroy } from "svelte"
|
||||
import * as skio from "sveltekit-io"
|
||||
import { z } from "zod"
|
||||
|
||||
interface ExtraFieldData {
|
||||
stream: boolean
|
||||
|
|
@ -12,6 +13,16 @@
|
|||
stream?: boolean
|
||||
}
|
||||
|
||||
// Zod validation schema
|
||||
const llamaCppConnectionSchema = z.object({
|
||||
baseUrl: z
|
||||
.string()
|
||||
.url("Invalid URL format")
|
||||
.min(1, "Base URL is required")
|
||||
})
|
||||
|
||||
type ValidationErrors = Record<string, string>
|
||||
|
||||
interface Props {
|
||||
connection: SelectConnection
|
||||
}
|
||||
|
|
@ -24,6 +35,7 @@
|
|||
}
|
||||
|
||||
let llamaCppFields: ExtraFieldData | undefined = $state()
|
||||
let validationErrors: ValidationErrors = $state({})
|
||||
|
||||
socket.on("testConnection", (msg: Sockets.TestConnection.Response) => {
|
||||
testResult = msg
|
||||
|
|
@ -33,12 +45,35 @@
|
|||
$state(null)
|
||||
|
||||
function handleTestConnection() {
|
||||
if (!validateConnection()) return
|
||||
testResult = null
|
||||
socket.emit("testConnection", {
|
||||
connection
|
||||
} as Sockets.TestConnection.Call)
|
||||
}
|
||||
|
||||
function validateConnection(): boolean {
|
||||
const data = {
|
||||
baseUrl: connection.baseUrl || ""
|
||||
}
|
||||
|
||||
const result = llamaCppConnectionSchema.safeParse(data)
|
||||
|
||||
if (result.success) {
|
||||
validationErrors = {}
|
||||
return true
|
||||
} else {
|
||||
const errors: ValidationErrors = {}
|
||||
result.error.errors.forEach((error) => {
|
||||
if (error.path.length > 0) {
|
||||
errors[error.path[0] as string] = error.message
|
||||
}
|
||||
})
|
||||
validationErrors = errors
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
let isValid = $derived.by(() => {
|
||||
return (
|
||||
connection &&
|
||||
|
|
@ -87,6 +122,7 @@
|
|||
type="button"
|
||||
class="btn preset-tonal-success btn-sm w-full"
|
||||
onclick={handleTestConnection}
|
||||
disabled={Object.keys(validationErrors).length > 0}
|
||||
>
|
||||
{#if testResult?.ok === true}
|
||||
Test: Okay!
|
||||
|
|
@ -133,8 +169,27 @@
|
|||
bind:value={connection.baseUrl}
|
||||
placeholder="http://localhost:8080/"
|
||||
required
|
||||
class="input"
|
||||
class="input {validationErrors.baseUrl ? 'border-red-500' : ''}"
|
||||
aria-invalid={validationErrors.baseUrl ? "true" : "false"}
|
||||
aria-describedby={validationErrors.baseUrl
|
||||
? "baseUrl-error"
|
||||
: undefined}
|
||||
oninput={() => {
|
||||
if (validationErrors.baseUrl) {
|
||||
const { baseUrl, ...rest } = validationErrors
|
||||
validationErrors = rest
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if validationErrors.baseUrl}
|
||||
<p
|
||||
id="baseUrl-error"
|
||||
class="mt-1 text-sm text-red-500"
|
||||
role="alert"
|
||||
>
|
||||
{validationErrors.baseUrl}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if llamaCppFields}
|
||||
<div class="mt-2 flex flex-col gap-1">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { Switch } from "@skeletonlabs/skeleton-svelte"
|
||||
import { onMount, onDestroy } from "svelte"
|
||||
import * as skio from "sveltekit-io"
|
||||
import { z } from "zod"
|
||||
|
||||
interface ExtraFieldData {
|
||||
stream: boolean
|
||||
|
|
@ -22,6 +23,17 @@
|
|||
useChat?: boolean
|
||||
}
|
||||
|
||||
// Zod validation schema
|
||||
const ollamaConnectionSchema = z.object({
|
||||
model: z.string().min(1, "Model is required"),
|
||||
baseUrl: z
|
||||
.string()
|
||||
.url("Invalid URL format")
|
||||
.min(1, "Base URL is required")
|
||||
})
|
||||
|
||||
type ValidationErrors = Record<string, string>
|
||||
|
||||
interface Props {
|
||||
connection: SelectConnection
|
||||
}
|
||||
|
|
@ -40,6 +52,7 @@
|
|||
let availableOllamaModels: Sockets.RefreshModels.Response["models"] =
|
||||
$state([])
|
||||
let ollamaFields: ExtraFieldData | undefined = $state()
|
||||
let validationErrors: ValidationErrors = $state({})
|
||||
|
||||
socket.on("refreshModels", (msg: Sockets.RefreshModels.Response) => {
|
||||
if (msg.models) availableOllamaModels = msg.models
|
||||
|
|
@ -59,12 +72,36 @@
|
|||
$state(null)
|
||||
|
||||
function handleTestConnection() {
|
||||
if (!validateConnection()) return
|
||||
testResult = null
|
||||
socket.emit("testConnection", {
|
||||
connection
|
||||
} as Sockets.TestConnection.Call)
|
||||
}
|
||||
|
||||
function validateConnection(): boolean {
|
||||
const data = {
|
||||
model: connection.model || "",
|
||||
baseUrl: connection.baseUrl || ""
|
||||
}
|
||||
|
||||
const result = ollamaConnectionSchema.safeParse(data)
|
||||
|
||||
if (result.success) {
|
||||
validationErrors = {}
|
||||
return true
|
||||
} else {
|
||||
const errors: ValidationErrors = {}
|
||||
result.error.errors.forEach((error) => {
|
||||
if (error.path.length > 0) {
|
||||
errors[error.path[0] as string] = error.message
|
||||
}
|
||||
})
|
||||
validationErrors = errors
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// let isValid = $derived.by(() => {
|
||||
// return (
|
||||
// connection &&
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { Switch } from "@skeletonlabs/skeleton-svelte"
|
||||
import { onMount, onDestroy } from "svelte"
|
||||
import * as skio from "sveltekit-io"
|
||||
import { z } from "zod"
|
||||
|
||||
interface ExtraFieldData {
|
||||
stream: boolean
|
||||
|
|
@ -17,6 +18,18 @@
|
|||
prerenderPrompt: boolean
|
||||
}
|
||||
|
||||
// Zod validation schema
|
||||
const openAIConnectionSchema = z.object({
|
||||
model: z.string().min(1, "Model is required"),
|
||||
baseUrl: z
|
||||
.string()
|
||||
.url("Invalid URL format")
|
||||
.min(1, "Base URL is required"),
|
||||
apiKey: z.string().min(1, "API key is required")
|
||||
})
|
||||
|
||||
type ValidationErrors = Record<string, string>
|
||||
|
||||
interface Props {
|
||||
connection: SelectConnection
|
||||
}
|
||||
|
|
@ -33,6 +46,7 @@
|
|||
let availableOpenAIModels: Sockets.RefreshModels.Response["models"] =
|
||||
$state([])
|
||||
let openAIFields: ExtraFieldData | undefined = $state()
|
||||
let validationErrors: ValidationErrors = $state({})
|
||||
|
||||
socket.on("refreshModels", (msg: Sockets.RefreshModels.Response) => {
|
||||
if (msg.models) availableOpenAIModels = msg.models
|
||||
|
|
@ -52,12 +66,37 @@
|
|||
$state(null)
|
||||
|
||||
function handleTestConnection() {
|
||||
if (!validateConnection()) return
|
||||
testResult = null
|
||||
socket.emit("testConnection", {
|
||||
connection
|
||||
} as Sockets.TestConnection.Call)
|
||||
}
|
||||
|
||||
function validateConnection(): boolean {
|
||||
const data = {
|
||||
model: connection.model || "",
|
||||
baseUrl: connection.baseUrl || "",
|
||||
apiKey: openAIFields?.apiKey || ""
|
||||
}
|
||||
|
||||
const result = openAIConnectionSchema.safeParse(data)
|
||||
|
||||
if (result.success) {
|
||||
validationErrors = {}
|
||||
return true
|
||||
} else {
|
||||
const errors: ValidationErrors = {}
|
||||
result.error.errors.forEach((error) => {
|
||||
if (error.path.length > 0) {
|
||||
errors[error.path[0] as string] = error.message
|
||||
}
|
||||
})
|
||||
validationErrors = errors
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// let isValid = $derived.by(() => {
|
||||
// return connection && connection.type === "openai" && connection.baseUrl && connection.model
|
||||
// })
|
||||
|
|
@ -112,13 +151,30 @@
|
|||
<select
|
||||
id="model"
|
||||
bind:value={connection.model}
|
||||
class="select bg-background border-muted w-full rounded border"
|
||||
class="select bg-background border-muted w-full rounded border {validationErrors.model
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
aria-invalid={validationErrors.model ? "true" : "false"}
|
||||
aria-describedby={validationErrors.model
|
||||
? "model-error"
|
||||
: undefined}
|
||||
oninput={() => {
|
||||
if (validationErrors.model) {
|
||||
const { model, ...rest } = validationErrors
|
||||
validationErrors = rest
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">-- Select Model --</option>
|
||||
{#each availableOpenAIModels as m}
|
||||
<option value={m.id}>{m.id}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if validationErrors.model}
|
||||
<p id="model-error" class="mt-1 text-sm text-red-500" role="alert">
|
||||
{validationErrors.model}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button
|
||||
|
|
@ -132,6 +188,7 @@
|
|||
type="button"
|
||||
class="btn preset-tonal-success btn-sm w-full"
|
||||
onclick={handleTestConnection}
|
||||
disabled={Object.keys(validationErrors).length > 0}
|
||||
>
|
||||
{#if testResult?.ok === true}
|
||||
Test: Okay!
|
||||
|
|
@ -178,8 +235,27 @@
|
|||
bind:value={connection.baseUrl}
|
||||
placeholder="https://api.openai.com/v1/"
|
||||
required
|
||||
class="input"
|
||||
class="input {validationErrors.baseUrl ? 'border-red-500' : ''}"
|
||||
aria-invalid={validationErrors.baseUrl ? "true" : "false"}
|
||||
aria-describedby={validationErrors.baseUrl
|
||||
? "baseUrl-error"
|
||||
: undefined}
|
||||
oninput={() => {
|
||||
if (validationErrors.baseUrl) {
|
||||
const { baseUrl, ...rest } = validationErrors
|
||||
validationErrors = rest
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if validationErrors.baseUrl}
|
||||
<p
|
||||
id="baseUrl-error"
|
||||
class="mt-1 text-sm text-red-500"
|
||||
role="alert"
|
||||
>
|
||||
{validationErrors.baseUrl}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if openAIFields}
|
||||
<div class="mt-2 flex flex-col gap-1">
|
||||
|
|
@ -189,8 +265,27 @@
|
|||
type="password"
|
||||
bind:value={openAIFields.apiKey}
|
||||
placeholder="sk-..."
|
||||
class="input"
|
||||
class="input {validationErrors.apiKey ? 'border-red-500' : ''}"
|
||||
aria-invalid={validationErrors.apiKey ? "true" : "false"}
|
||||
aria-describedby={validationErrors.apiKey
|
||||
? "apiKey-error"
|
||||
: undefined}
|
||||
oninput={() => {
|
||||
if (validationErrors.apiKey) {
|
||||
const { apiKey, ...rest } = validationErrors
|
||||
validationErrors = rest
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if validationErrors.apiKey}
|
||||
<p
|
||||
id="apiKey-error"
|
||||
class="mt-1 text-sm text-red-500"
|
||||
role="alert"
|
||||
>
|
||||
{validationErrors.apiKey}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<details class="mt-4">
|
||||
<summary class="cursor-pointer font-semibold">
|
||||
|
|
|
|||
|
|
@ -353,7 +353,9 @@ export const tags = pgTable("tags", {
|
|||
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
|
||||
name: text("name").notNull(), // Tag name (unique)
|
||||
description: text("description"),
|
||||
colorPreset: text("color_preset").notNull().default("preset-filled-primary-500"), // Color preset for the tag
|
||||
colorPreset: text("color_preset")
|
||||
.notNull()
|
||||
.default("preset-filled-primary-500") // Color preset for the tag
|
||||
})
|
||||
|
||||
export const tagsRelations = relations(tags, ({ many }) => ({
|
||||
|
|
@ -549,13 +551,10 @@ export const chatMessages = pgTable(
|
|||
.default(sql`(CURRENT_TIMESTAMP)`)
|
||||
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
|
||||
isEdited: boolean("is_edited").notNull().default(false), // 1 if edited, 0 otherwise
|
||||
metadata: json("metadata")
|
||||
.notNull()
|
||||
.default({})
|
||||
.$type<{
|
||||
isGreeting?: boolean
|
||||
swipes?: { currentIdx: number | null; history: [] }
|
||||
}>(), // JSON for extra info
|
||||
metadata: json("metadata").notNull().default({}).$type<{
|
||||
isGreeting?: boolean
|
||||
swipes?: { currentIdx: number | null; history: [] }
|
||||
}>(), // JSON for extra info
|
||||
isGenerating: boolean("is_generating").notNull().default(false), // 1 if processing, 0 otherwise
|
||||
adapterId: text("adapter_id"), // UUID for in-flight adapter instance, nullable
|
||||
isHidden: boolean("is_hidden").notNull().default(false) // Whether this message is processed or not
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@
|
|||
class="preset-filled-warning-100-900 mx-auto w-full rounded-lg p-2 text-center text-sm"
|
||||
>
|
||||
<strong>Serene Pub is in alpha!</strong>
|
||||
Expect bugs and rapid changes. This project is under heavy development.
|
||||
Expect bugs and rapid changes. This project is under heavy development.
|
||||
</div>
|
||||
|
||||
{#if !isSetup}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue