Upgrade forms, add character creator modal

This commit is contained in:
Jody Doolittle 2025-07-27 00:19:09 -07:00
parent 737a1638a5
commit b5a826cc4f
22 changed files with 4037 additions and 2241 deletions

File diff suppressed because it is too large Load diff

View file

@ -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
}
]
}

View file

@ -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()
}
})

View file

@ -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>

View file

@ -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">

View 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>

View file

@ -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

View file

@ -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>

View file

@ -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()
}
})

View file

@ -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)}
/>

View file

@ -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>

View file

@ -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"

View file

@ -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">

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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">

View file

@ -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">

View file

@ -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 &&

View file

@ -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">

View file

@ -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

View file

@ -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}