Spaces:
Sleeping
Sleeping
Commit
·
725337f
1
Parent(s):
145bf5e
Initial commit
Browse files- BRANCHES.md +180 -0
- src/lib/components/CopyToClipBoardBtn.svelte +2 -7
- src/lib/components/PersonaSelector.svelte +70 -10
- src/lib/components/chat/ChatInput.svelte +1 -1
- src/lib/components/chat/ChatIntroduction.svelte +1 -1
- src/lib/components/chat/ChatMessage.svelte +81 -55
- src/lib/components/chat/ChatWindow.svelte +69 -61
- src/lib/components/chat/PersonaResponseCards.svelte +192 -0
- src/lib/components/chat/PersonaResponseCarousel.svelte +445 -0
- src/lib/constants/thinkBlockRegex.ts +2 -0
- src/lib/migrations/routines/11-add-personas.ts +2 -2
- src/lib/server/api/routes/groups/user.ts +2 -2
- src/lib/server/defaultPersonas.ts +38 -42
- src/lib/server/textGeneration/multiPersona.ts +179 -0
- src/lib/stores/settings.ts +1 -1
- src/lib/types/Message.ts +21 -0
- src/lib/types/MessageUpdate.ts +28 -1
- src/lib/types/Persona.ts +30 -3
- src/lib/types/Settings.ts +3 -3
- src/lib/utils/messageUpdates.ts +2 -0
- src/routes/api/conversation/[id]/+server.ts +2 -0
- src/routes/conversation/+server.ts +6 -3
- src/routes/conversation/[id]/+page.svelte +165 -76
- src/routes/conversation/[id]/+server.ts +208 -42
- src/routes/login/callback/+server.ts +11 -2
- src/routes/personas/+page.svelte +318 -74
- src/routes/settings/(nav)/+layout.svelte +200 -46
- src/routes/settings/(nav)/+page.svelte +26 -0
- src/routes/settings/(nav)/+server.ts +9 -3
- src/routes/settings/(nav)/models/+page.svelte +31 -0
- src/routes/settings/(nav)/{personas/[persona] → models}/+page.ts +0 -0
- src/routes/settings/(nav)/{[...model] → models/[...model]}/+page.svelte +1 -0
- src/routes/settings/(nav)/{[...model] → models/[...model]}/+page.ts +0 -0
- src/routes/settings/(nav)/personas/+page.svelte +20 -262
- src/routes/settings/(nav)/personas/+page.ts +1 -12
- src/routes/settings/(nav)/personas/[...persona]/+page.svelte +435 -0
- src/routes/settings/(nav)/personas/[...persona]/+page.ts +4 -0
- src/routes/settings/(nav)/personas/[persona]/+page.svelte +0 -277
BRANCHES.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Git Branches Guide
|
| 2 |
+
|
| 3 |
+
## Current Branches
|
| 4 |
+
|
| 5 |
+
### `feature/personas` Branch
|
| 6 |
+
**Created:** October 3, 2025
|
| 7 |
+
**Purpose:** Complete persona system implementation replacing the old customPrompts system
|
| 8 |
+
**Status:** Ready for testing and review
|
| 9 |
+
|
| 10 |
+
**What's included (24 commits):**
|
| 11 |
+
- Persona type definition and default personas (healthcare debate perspectives)
|
| 12 |
+
- Database migration to replace customPrompts with personas
|
| 13 |
+
- PersonaSelector component for quick switching
|
| 14 |
+
- Personas gallery page with search, edit, and activation
|
| 15 |
+
- Full CRUD persona editor in settings
|
| 16 |
+
- Tab-based settings navigation (Models, Personas, Settings)
|
| 17 |
+
- Integration with conversation creation and message generation
|
| 18 |
+
- UI redesign for model/persona display in chat window
|
| 19 |
+
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
## How to Push to the Feature Branch
|
| 23 |
+
|
| 24 |
+
From your current state (after rebase), push to the feature branch:
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
cd /c/Dev/Plurality/chat
|
| 28 |
+
git push origin main:feature/personas
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
This creates a new branch called `feature/personas` on the remote with all your commits.
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
## How to Test the Feature Branch
|
| 36 |
+
|
| 37 |
+
**On another machine or after switching:**
|
| 38 |
+
|
| 39 |
+
```bash
|
| 40 |
+
# Switch to the feature branch
|
| 41 |
+
git checkout feature/personas
|
| 42 |
+
|
| 43 |
+
# Pull latest changes
|
| 44 |
+
git pull origin feature/personas
|
| 45 |
+
|
| 46 |
+
# Install dependencies (if needed)
|
| 47 |
+
npm install
|
| 48 |
+
|
| 49 |
+
# Run the application
|
| 50 |
+
npm run dev
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
---
|
| 54 |
+
|
| 55 |
+
## How to Merge Back to Main
|
| 56 |
+
|
| 57 |
+
Once the feature is tested and approved, you have two options:
|
| 58 |
+
|
| 59 |
+
### Option 1: Merge via Pull Request (Recommended)
|
| 60 |
+
1. Create a Pull Request on GitHub/GitLab from `feature/personas` to `main`
|
| 61 |
+
2. Request code review from team members
|
| 62 |
+
3. Once approved, merge using the platform's merge button
|
| 63 |
+
4. Delete the feature branch after merge
|
| 64 |
+
|
| 65 |
+
### Option 2: Manual Merge (Command Line)
|
| 66 |
+
```bash
|
| 67 |
+
# Make sure you're on main and it's up to date
|
| 68 |
+
git checkout main
|
| 69 |
+
git pull origin main
|
| 70 |
+
|
| 71 |
+
# Merge the feature branch
|
| 72 |
+
git merge feature/personas
|
| 73 |
+
|
| 74 |
+
# If there are conflicts, resolve them, then:
|
| 75 |
+
git add .
|
| 76 |
+
git commit -m "Resolve merge conflicts"
|
| 77 |
+
|
| 78 |
+
# Push to main
|
| 79 |
+
git push origin main
|
| 80 |
+
|
| 81 |
+
# Delete the feature branch (optional)
|
| 82 |
+
git branch -d feature/personas
|
| 83 |
+
git push origin --delete feature/personas
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
---
|
| 87 |
+
|
| 88 |
+
## Common Branch Commands
|
| 89 |
+
|
| 90 |
+
### See all branches
|
| 91 |
+
```bash
|
| 92 |
+
git branch -a
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
### Switch branches
|
| 96 |
+
```bash
|
| 97 |
+
git checkout branch-name
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
### Create a new branch
|
| 101 |
+
```bash
|
| 102 |
+
git checkout -b new-branch-name
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
### Update your branch with latest main
|
| 106 |
+
```bash
|
| 107 |
+
# While on your feature branch
|
| 108 |
+
git fetch origin
|
| 109 |
+
git rebase origin/main
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
### See what's different between branches
|
| 113 |
+
```bash
|
| 114 |
+
git diff main..feature/personas
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
### Undo/Reset (if needed)
|
| 118 |
+
```bash
|
| 119 |
+
# Undo local changes
|
| 120 |
+
git reset --hard origin/feature/personas
|
| 121 |
+
|
| 122 |
+
# Go back to a specific commit
|
| 123 |
+
git reset --hard COMMIT_HASH
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
---
|
| 127 |
+
|
| 128 |
+
## Branching Best Practices
|
| 129 |
+
|
| 130 |
+
1. **One feature per branch** - Keep branches focused on a single feature or fix
|
| 131 |
+
2. **Descriptive names** - Use names like `feature/personas`, `fix/login-bug`, `refactor/api-cleanup`
|
| 132 |
+
3. **Regular updates** - Rebase with main regularly to avoid large conflicts later
|
| 133 |
+
4. **Small commits** - Make frequent, logical commits with clear messages
|
| 134 |
+
5. **Clean up** - Delete merged branches to keep the repository tidy
|
| 135 |
+
6. **Never force push to main** - Use `--force` only on feature branches and only when necessary
|
| 136 |
+
|
| 137 |
+
---
|
| 138 |
+
|
| 139 |
+
## Troubleshooting
|
| 140 |
+
|
| 141 |
+
### "Your branch has diverged"
|
| 142 |
+
This happens when remote and local have different commits. Solutions:
|
| 143 |
+
```bash
|
| 144 |
+
# If you want to keep your local changes
|
| 145 |
+
git rebase origin/branch-name
|
| 146 |
+
|
| 147 |
+
# If you want to match remote exactly
|
| 148 |
+
git reset --hard origin/branch-name
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
### Merge Conflicts
|
| 152 |
+
When Git can't automatically merge:
|
| 153 |
+
1. Open conflicted files (marked with `<<<<<<<`, `=======`, `>>>>>>>`)
|
| 154 |
+
2. Edit to keep the correct code
|
| 155 |
+
3. Remove conflict markers
|
| 156 |
+
4. Stage the resolved files: `git add filename`
|
| 157 |
+
5. Continue: `git rebase --continue` or `git commit`
|
| 158 |
+
|
| 159 |
+
### Need to switch branches but have uncommitted changes
|
| 160 |
+
```bash
|
| 161 |
+
# Save changes for later
|
| 162 |
+
git stash
|
| 163 |
+
|
| 164 |
+
# Switch branches
|
| 165 |
+
git checkout other-branch
|
| 166 |
+
|
| 167 |
+
# Come back and restore changes
|
| 168 |
+
git checkout original-branch
|
| 169 |
+
git stash pop
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
---
|
| 173 |
+
|
| 174 |
+
## Notes for This Project
|
| 175 |
+
|
| 176 |
+
- **Main branch** should always be deployable
|
| 177 |
+
- Test thoroughly on feature branches before merging
|
| 178 |
+
- The persona feature includes a database migration - ensure it runs successfully before merging
|
| 179 |
+
- Breaking changes: This feature removes `customPrompts` - ensure no other code depends on it
|
| 180 |
+
|
src/lib/components/CopyToClipBoardBtn.svelte
CHANGED
|
@@ -2,7 +2,6 @@
|
|
| 2 |
import { onDestroy } from "svelte";
|
| 3 |
|
| 4 |
import CarbonCopy from "~icons/carbon/copy";
|
| 5 |
-
import Tooltip from "./Tooltip.svelte";
|
| 6 |
|
| 7 |
interface Props {
|
| 8 |
classNames?: string;
|
|
@@ -56,7 +55,7 @@
|
|
| 56 |
}
|
| 57 |
timeout = setTimeout(() => {
|
| 58 |
isSuccess = false;
|
| 59 |
-
},
|
| 60 |
} catch (err) {
|
| 61 |
console.error(err);
|
| 62 |
}
|
|
@@ -78,13 +77,9 @@
|
|
| 78 |
handleClick();
|
| 79 |
}}
|
| 80 |
>
|
| 81 |
-
<div class="relative">
|
| 82 |
{#if children}{@render children()}{:else}
|
| 83 |
<CarbonCopy class={iconClassNames} />
|
| 84 |
{/if}
|
| 85 |
-
|
| 86 |
-
{#if showTooltip}
|
| 87 |
-
<Tooltip classNames={isSuccess ? "opacity-100" : "opacity-0"} />
|
| 88 |
-
{/if}
|
| 89 |
</div>
|
| 90 |
</button>
|
|
|
|
| 2 |
import { onDestroy } from "svelte";
|
| 3 |
|
| 4 |
import CarbonCopy from "~icons/carbon/copy";
|
|
|
|
| 5 |
|
| 6 |
interface Props {
|
| 7 |
classNames?: string;
|
|
|
|
| 55 |
}
|
| 56 |
timeout = setTimeout(() => {
|
| 57 |
isSuccess = false;
|
| 58 |
+
}, 500);
|
| 59 |
} catch (err) {
|
| 60 |
console.error(err);
|
| 61 |
}
|
|
|
|
| 77 |
handleClick();
|
| 78 |
}}
|
| 79 |
>
|
| 80 |
+
<div class="relative transition-transform duration-200 {isSuccess ? 'scale-125' : 'scale-100'}">
|
| 81 |
{#if children}{@render children()}{:else}
|
| 82 |
<CarbonCopy class={iconClassNames} />
|
| 83 |
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
</div>
|
| 85 |
</button>
|
src/lib/components/PersonaSelector.svelte
CHANGED
|
@@ -3,20 +3,80 @@
|
|
| 3 |
import { base } from "$app/paths";
|
| 4 |
import { useSettingsStore } from "$lib/stores/settings";
|
| 5 |
import CarbonUser from "~icons/carbon/user";
|
|
|
|
| 6 |
|
| 7 |
const settings = useSettingsStore();
|
| 8 |
|
| 9 |
-
let
|
| 10 |
-
$settings.
|
|
|
|
|
|
|
| 11 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
</script>
|
| 13 |
|
| 14 |
-
<
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
|
|
|
| 3 |
import { base } from "$app/paths";
|
| 4 |
import { useSettingsStore } from "$lib/stores/settings";
|
| 5 |
import CarbonUser from "~icons/carbon/user";
|
| 6 |
+
import CarbonEdit from "~icons/carbon/edit";
|
| 7 |
|
| 8 |
const settings = useSettingsStore();
|
| 9 |
|
| 10 |
+
let activePersonas = $derived(
|
| 11 |
+
$settings.activePersonas
|
| 12 |
+
.map(id => $settings.personas.find((p) => p.id === id))
|
| 13 |
+
.filter(Boolean)
|
| 14 |
);
|
| 15 |
+
|
| 16 |
+
let displayText = $derived(() => {
|
| 17 |
+
if (activePersonas.length === 0) return "No active personas";
|
| 18 |
+
if (activePersonas.length === 1) return activePersonas[0]?.name ?? "Default";
|
| 19 |
+
return `${activePersonas.length} personas`;
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
let showDropdown = $state(false);
|
| 23 |
+
let hideTimeout: number | undefined = $state();
|
| 24 |
+
|
| 25 |
+
function handleMouseEnter() {
|
| 26 |
+
if (hideTimeout) {
|
| 27 |
+
clearTimeout(hideTimeout);
|
| 28 |
+
hideTimeout = undefined;
|
| 29 |
+
}
|
| 30 |
+
if (activePersonas.length > 1) {
|
| 31 |
+
showDropdown = true;
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function handleMouseLeave() {
|
| 36 |
+
hideTimeout = window.setTimeout(() => {
|
| 37 |
+
showDropdown = false;
|
| 38 |
+
}, 100);
|
| 39 |
+
}
|
| 40 |
</script>
|
| 41 |
|
| 42 |
+
<div class="relative">
|
| 43 |
+
<button
|
| 44 |
+
class="flex items-center gap-1.5 rounded-lg border border-gray-300 bg-gray-100 px-3 py-1.5 text-sm text-gray-400 shadow-sm hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-500 dark:hover:bg-gray-700"
|
| 45 |
+
onclick={() => goto(`${base}/settings/personas/${$settings.activePersonas[0] || $settings.personas[0]?.id || ''}`)}
|
| 46 |
+
onmouseenter={handleMouseEnter}
|
| 47 |
+
onmouseleave={handleMouseLeave}
|
| 48 |
+
title="{activePersonas.length === 1 ? 'Manage personas' : ''}"
|
| 49 |
+
>
|
| 50 |
+
<CarbonUser class="text-xs" />
|
| 51 |
+
<span class="max-w-[150px] truncate">{displayText()}</span>
|
| 52 |
+
</button>
|
| 53 |
+
|
| 54 |
+
{#if showDropdown && activePersonas.length > 1}
|
| 55 |
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
| 56 |
+
<div
|
| 57 |
+
role="menu"
|
| 58 |
+
tabindex="-1"
|
| 59 |
+
class="absolute bottom-full left-0 mb-1 min-w-[200px] animate-in fade-in slide-in-from-bottom-2 duration-300 rounded-lg border border-gray-300 bg-white py-1.5 shadow-lg dark:border-gray-600 dark:bg-gray-800"
|
| 60 |
+
onmouseenter={handleMouseEnter}
|
| 61 |
+
onmouseleave={handleMouseLeave}
|
| 62 |
+
>
|
| 63 |
+
<div class="px-3 py-1.5 text-xs font-semibold text-gray-500 dark:text-gray-400">
|
| 64 |
+
Active Personas
|
| 65 |
+
</div>
|
| 66 |
+
{#each activePersonas as persona (persona?.id)}
|
| 67 |
+
<button
|
| 68 |
+
type="button"
|
| 69 |
+
class="group flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm text-gray-700 transition-all duration-200 hover:bg-gray-100 hover:pl-4 dark:text-gray-300 dark:hover:bg-gray-700"
|
| 70 |
+
onclick={() => goto(`${base}/settings/personas/${persona?.id || ''}`)}
|
| 71 |
+
>
|
| 72 |
+
<div class="flex items-center gap-2 truncate">
|
| 73 |
+
<div class="size-1.5 flex-shrink-0 rounded-full bg-black dark:bg-white"></div>
|
| 74 |
+
<span class="truncate">{persona?.name ?? "Unknown"}</span>
|
| 75 |
+
</div>
|
| 76 |
+
<CarbonEdit class="size-3.5 flex-shrink-0 text-gray-400 opacity-0 transition-opacity duration-200 group-hover:opacity-100 dark:text-gray-500" />
|
| 77 |
+
</button>
|
| 78 |
+
{/each}
|
| 79 |
+
</div>
|
| 80 |
+
{/if}
|
| 81 |
+
</div>
|
| 82 |
|
src/lib/components/chat/ChatInput.svelte
CHANGED
|
@@ -99,7 +99,7 @@
|
|
| 99 |
rows="1"
|
| 100 |
tabindex="0"
|
| 101 |
inputmode="text"
|
| 102 |
-
class="scrollbar-custom max-h-[4lh] w-full resize-none overflow-y-auto overflow-x-hidden border-0 bg-transparent px-2.5 py-2.5 outline-none focus:ring-0 focus-visible:ring-0 sm:px-3 md:max-h-[8lh]"
|
| 103 |
class:text-gray-400={disabled}
|
| 104 |
bind:value
|
| 105 |
bind:this={textareaElement}
|
|
|
|
| 99 |
rows="1"
|
| 100 |
tabindex="0"
|
| 101 |
inputmode="text"
|
| 102 |
+
class="scrollbar-custom max-h-[4lh] w-full resize-none overflow-y-auto overflow-x-hidden border-0 bg-transparent px-2.5 py-2.5 outline-none placeholder:text-gray-400 focus:ring-0 focus-visible:ring-0 dark:placeholder:text-gray-500 sm:px-3 md:max-h-[8lh]"
|
| 103 |
class:text-gray-400={disabled}
|
| 104 |
bind:value
|
| 105 |
bind:this={textareaElement}
|
src/lib/components/chat/ChatIntroduction.svelte
CHANGED
|
@@ -73,7 +73,7 @@
|
|
| 73 |
</div>
|
| 74 |
</div>
|
| 75 |
<a
|
| 76 |
-
href="{base}/settings/{currentModel.id}"
|
| 77 |
aria-label="Settings"
|
| 78 |
class="btn ml-auto flex h-7 w-7 self-start rounded-full bg-gray-100 p-1 text-xs hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-600"
|
| 79 |
><IconGear /></a
|
|
|
|
| 73 |
</div>
|
| 74 |
</div>
|
| 75 |
<a
|
| 76 |
+
href="{base}/settings/models/{currentModel.id}"
|
| 77 |
aria-label="Settings"
|
| 78 |
class="btn ml-auto flex h-7 w-7 self-start rounded-full bg-gray-100 p-1 text-xs hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-600"
|
| 79 |
><IconGear /></a
|
src/lib/components/chat/ChatMessage.svelte
CHANGED
|
@@ -21,6 +21,8 @@
|
|
| 21 |
import OpenReasoningResults from "./OpenReasoningResults.svelte";
|
| 22 |
import Alternatives from "./Alternatives.svelte";
|
| 23 |
import MessageAvatar from "./MessageAvatar.svelte";
|
|
|
|
|
|
|
| 24 |
|
| 25 |
interface Props {
|
| 26 |
message: Message;
|
|
@@ -31,7 +33,10 @@
|
|
| 31 |
alternatives?: Message["id"][];
|
| 32 |
editMsdgId?: Message["id"] | null;
|
| 33 |
isLast?: boolean;
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
| 35 |
onshowAlternateMsg?: (payload: { id: Message["id"] }) => void;
|
| 36 |
}
|
| 37 |
|
|
@@ -44,6 +49,9 @@
|
|
| 44 |
alternatives = [],
|
| 45 |
editMsdgId = $bindable(null),
|
| 46 |
isLast = false,
|
|
|
|
|
|
|
|
|
|
| 47 |
onretry,
|
| 48 |
onshowAlternateMsg,
|
| 49 |
}: Props = $props();
|
|
@@ -84,8 +92,6 @@
|
|
| 84 |
// const urlNotTrailing = $derived(page.url.pathname.replace(/\/$/, ""));
|
| 85 |
// let downloadLink = $derived(urlNotTrailing + `/message/${message.id}/prompt`);
|
| 86 |
|
| 87 |
-
// Zero-config reasoning autodetection: detect <think> blocks in content
|
| 88 |
-
const THINK_BLOCK_REGEX = /(<think>[\s\S]*?(?:<\/think>|$))/g;
|
| 89 |
let thinkSegments = $derived.by(() => message.content.split(THINK_BLOCK_REGEX));
|
| 90 |
let hasServerReasoning = $derived(
|
| 91 |
reasoningUpdates &&
|
|
@@ -128,13 +134,40 @@
|
|
| 128 |
onclick={() => (isTapped = !isTapped)}
|
| 129 |
onkeydown={() => (isTapped = !isTapped)}
|
| 130 |
>
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
<div
|
| 136 |
class="relative flex min-w-[60px] flex-col gap-2 break-words rounded-2xl border border-gray-100 bg-gradient-to-br from-gray-50 px-5 py-3.5 text-gray-600 prose-pre:my-2 dark:border-gray-800 dark:from-gray-800/80 dark:text-gray-300"
|
| 137 |
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
{#if message.files?.length}
|
| 139 |
<div class="flex h-fit flex-wrap gap-x-5 gap-y-2">
|
| 140 |
{#each message.files as file (file.value)}
|
|
@@ -190,64 +223,57 @@
|
|
| 190 |
</div>
|
| 191 |
{/if}
|
| 192 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
</div>
|
|
|
|
| 194 |
|
| 195 |
-
{#if message.routerMetadata
|
| 196 |
<div
|
| 197 |
class="absolute -bottom-3.5 {message.routerMetadata && messageInfoWidth > messageWidth
|
| 198 |
? 'left-1 pl-1 lg:pl-7'
|
| 199 |
: 'right-1'} flex max-w-[calc(100dvw-40px)] items-center gap-0.5 overflow-hidden"
|
| 200 |
bind:offsetWidth={messageInfoWidth}
|
| 201 |
>
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
>
|
| 206 |
-
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
</span>
|
| 209 |
-
<span class="text-gray-500">with</span>
|
| 210 |
-
{#if publicConfig.isHuggingChat}
|
| 211 |
-
<a
|
| 212 |
-
href="/chat/settings/{message.routerMetadata.model}"
|
| 213 |
-
class="truncate rounded bg-gray-100 px-1 font-mono hover:text-gray-500 dark:bg-gray-800 dark:hover:text-gray-300 sm:py-px"
|
| 214 |
-
>
|
| 215 |
-
{message.routerMetadata.model.split("/").pop()}
|
| 216 |
-
</a>
|
| 217 |
-
{:else}
|
| 218 |
-
<span class="truncate rounded bg-gray-100 px-1.5 font-mono dark:bg-gray-800 sm:py-px">
|
| 219 |
-
{message.routerMetadata.model.split("/").pop()}
|
| 220 |
-
</span>
|
| 221 |
-
{/if}
|
| 222 |
-
</div>
|
| 223 |
-
{/if}
|
| 224 |
-
{#if !isLast || !loading}
|
| 225 |
-
<CopyToClipBoardBtn
|
| 226 |
-
onClick={() => {
|
| 227 |
-
isCopied = true;
|
| 228 |
-
}}
|
| 229 |
-
classNames="btn rounded-sm p-1 text-sm text-gray-400 hover:text-gray-500 focus:ring-0 dark:text-gray-400 dark:hover:text-gray-300"
|
| 230 |
-
value={message.content}
|
| 231 |
-
iconClassNames="text-xs"
|
| 232 |
-
/>
|
| 233 |
-
<button
|
| 234 |
-
class="btn rounded-sm p-1 text-xs text-gray-400 hover:text-gray-500 focus:ring-0 dark:text-gray-400 dark:hover:text-gray-300"
|
| 235 |
-
title="Retry"
|
| 236 |
-
type="button"
|
| 237 |
-
onclick={() => {
|
| 238 |
-
onretry?.({ id: message.id });
|
| 239 |
-
}}
|
| 240 |
-
>
|
| 241 |
-
<CarbonRotate360 />
|
| 242 |
-
</button>
|
| 243 |
-
{#if alternatives.length > 1 && editMsdgId === null}
|
| 244 |
-
<Alternatives
|
| 245 |
-
{message}
|
| 246 |
-
{alternatives}
|
| 247 |
-
{loading}
|
| 248 |
-
onshowAlternateMsg={(payload) => onshowAlternateMsg?.(payload)}
|
| 249 |
-
/>
|
| 250 |
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
{/if}
|
| 252 |
</div>
|
| 253 |
{/if}
|
|
|
|
| 21 |
import OpenReasoningResults from "./OpenReasoningResults.svelte";
|
| 22 |
import Alternatives from "./Alternatives.svelte";
|
| 23 |
import MessageAvatar from "./MessageAvatar.svelte";
|
| 24 |
+
import PersonaResponseCarousel from "./PersonaResponseCarousel.svelte";
|
| 25 |
+
import { THINK_BLOCK_REGEX } from "$lib/constants/thinkBlockRegex";
|
| 26 |
|
| 27 |
interface Props {
|
| 28 |
message: Message;
|
|
|
|
| 33 |
alternatives?: Message["id"][];
|
| 34 |
editMsdgId?: Message["id"] | null;
|
| 35 |
isLast?: boolean;
|
| 36 |
+
personaName?: string;
|
| 37 |
+
personaOccupation?: string;
|
| 38 |
+
personaStance?: string;
|
| 39 |
+
onretry?: (payload: { id: Message["id"]; content?: string; personaId?: string }) => void;
|
| 40 |
onshowAlternateMsg?: (payload: { id: Message["id"] }) => void;
|
| 41 |
}
|
| 42 |
|
|
|
|
| 49 |
alternatives = [],
|
| 50 |
editMsdgId = $bindable(null),
|
| 51 |
isLast = false,
|
| 52 |
+
personaName,
|
| 53 |
+
personaOccupation,
|
| 54 |
+
personaStance,
|
| 55 |
onretry,
|
| 56 |
onshowAlternateMsg,
|
| 57 |
}: Props = $props();
|
|
|
|
| 92 |
// const urlNotTrailing = $derived(page.url.pathname.replace(/\/$/, ""));
|
| 93 |
// let downloadLink = $derived(urlNotTrailing + `/message/${message.id}/prompt`);
|
| 94 |
|
|
|
|
|
|
|
| 95 |
let thinkSegments = $derived.by(() => message.content.split(THINK_BLOCK_REGEX));
|
| 96 |
let hasServerReasoning = $derived(
|
| 97 |
reasoningUpdates &&
|
|
|
|
| 134 |
onclick={() => (isTapped = !isTapped)}
|
| 135 |
onkeydown={() => (isTapped = !isTapped)}
|
| 136 |
>
|
| 137 |
+
<MessageAvatar
|
| 138 |
+
classNames="mt-5 size-3.5 flex-none select-none rounded-full shadow-lg max-sm:hidden"
|
| 139 |
+
animating={isLast && loading}
|
| 140 |
+
/>
|
| 141 |
+
|
| 142 |
+
{#if message.personaResponses && message.personaResponses.length > 0}
|
| 143 |
+
<!-- Multi-persona mode: no outer container, just carousel -->
|
| 144 |
+
<div bind:this={contentEl} class="flex-1">
|
| 145 |
+
<PersonaResponseCarousel
|
| 146 |
+
personaResponses={message.personaResponses}
|
| 147 |
+
loading={isLast && loading}
|
| 148 |
+
onretry={(personaId: string) => onretry?.({ id: message.id, content: undefined, personaId })}
|
| 149 |
+
/>
|
| 150 |
+
</div>
|
| 151 |
+
{:else}
|
| 152 |
<div
|
| 153 |
class="relative flex min-w-[60px] flex-col gap-2 break-words rounded-2xl border border-gray-100 bg-gradient-to-br from-gray-50 px-5 py-3.5 text-gray-600 prose-pre:my-2 dark:border-gray-800 dark:from-gray-800/80 dark:text-gray-300"
|
| 154 |
>
|
| 155 |
+
<!-- Persona Name Header (for single-persona mode) -->
|
| 156 |
+
{#if personaName}
|
| 157 |
+
<div class="mb-2 flex items-start justify-between border-b border-gray-200 pb-2 dark:border-gray-700">
|
| 158 |
+
<div>
|
| 159 |
+
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
| 160 |
+
{personaName}
|
| 161 |
+
</h3>
|
| 162 |
+
{#if personaOccupation || personaStance}
|
| 163 |
+
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
| 164 |
+
{#if personaOccupation}<span>{personaOccupation}</span>{/if}{#if personaOccupation && personaStance}<span class="mx-1">•</span>{/if}{#if personaStance}<span>{personaStance}</span>{/if}
|
| 165 |
+
</div>
|
| 166 |
+
{/if}
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
{/if}
|
| 170 |
+
|
| 171 |
{#if message.files?.length}
|
| 172 |
<div class="flex h-fit flex-wrap gap-x-5 gap-y-2">
|
| 173 |
{#each message.files as file (file.value)}
|
|
|
|
| 223 |
</div>
|
| 224 |
{/if}
|
| 225 |
</div>
|
| 226 |
+
|
| 227 |
+
<!-- Copy button inside the bubble -->
|
| 228 |
+
{#if !isLast || !loading}
|
| 229 |
+
<div class="mt-2 flex justify-end">
|
| 230 |
+
<CopyToClipBoardBtn
|
| 231 |
+
onClick={() => {
|
| 232 |
+
isCopied = true;
|
| 233 |
+
}}
|
| 234 |
+
classNames="btn rounded-md p-2 text-sm text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700/50"
|
| 235 |
+
value={message.content}
|
| 236 |
+
iconClassNames="text-xs"
|
| 237 |
+
/>
|
| 238 |
+
</div>
|
| 239 |
+
{/if}
|
| 240 |
</div>
|
| 241 |
+
{/if}
|
| 242 |
|
| 243 |
+
{#if message.routerMetadata && (!isLast || !loading)}
|
| 244 |
<div
|
| 245 |
class="absolute -bottom-3.5 {message.routerMetadata && messageInfoWidth > messageWidth
|
| 246 |
? 'left-1 pl-1 lg:pl-7'
|
| 247 |
: 'right-1'} flex max-w-[calc(100dvw-40px)] items-center gap-0.5 overflow-hidden"
|
| 248 |
bind:offsetWidth={messageInfoWidth}
|
| 249 |
>
|
| 250 |
+
<div
|
| 251 |
+
class="mr-2 flex items-center gap-1.5 truncate whitespace-nowrap text-[.65rem] text-gray-400 dark:text-gray-400 sm:text-xs"
|
| 252 |
+
>
|
| 253 |
+
<span class="truncate rounded bg-gray-100 px-1 font-mono dark:bg-gray-800 sm:py-px">
|
| 254 |
+
{message.routerMetadata.route}
|
| 255 |
+
</span>
|
| 256 |
+
<span class="text-gray-500">with</span>
|
| 257 |
+
{#if publicConfig.isHuggingChat}
|
| 258 |
+
<a
|
| 259 |
+
href="/chat/settings/models/{message.routerMetadata.model}"
|
| 260 |
+
class="truncate rounded bg-gray-100 px-1 font-mono hover:text-gray-500 dark:bg-gray-800 dark:hover:text-gray-300 sm:py-px"
|
| 261 |
+
>
|
| 262 |
+
{message.routerMetadata.model.split("/").pop()}
|
| 263 |
+
</a>
|
| 264 |
+
{:else}
|
| 265 |
+
<span class="truncate rounded bg-gray-100 px-1.5 font-mono dark:bg-gray-800 sm:py-px">
|
| 266 |
+
{message.routerMetadata.model.split("/").pop()}
|
| 267 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
{/if}
|
| 269 |
+
</div>
|
| 270 |
+
{#if alternatives.length > 1 && editMsdgId === null}
|
| 271 |
+
<Alternatives
|
| 272 |
+
{message}
|
| 273 |
+
{alternatives}
|
| 274 |
+
{loading}
|
| 275 |
+
onshowAlternateMsg={(payload) => onshowAlternateMsg?.(payload)}
|
| 276 |
+
/>
|
| 277 |
{/if}
|
| 278 |
</div>
|
| 279 |
{/if}
|
src/lib/components/chat/ChatWindow.svelte
CHANGED
|
@@ -17,8 +17,6 @@
|
|
| 17 |
import { base } from "$app/paths";
|
| 18 |
import ContinueBtn from "../ContinueBtn.svelte";
|
| 19 |
import ChatMessage from "./ChatMessage.svelte";
|
| 20 |
-
import ScrollToBottomBtn from "../ScrollToBottomBtn.svelte";
|
| 21 |
-
import ScrollToPreviousBtn from "../ScrollToPreviousBtn.svelte";
|
| 22 |
import { browser } from "$app/environment";
|
| 23 |
import { snapScrollToBottom } from "$lib/actions/snapScrollToBottom";
|
| 24 |
import SystemPromptModal from "../SystemPromptModal.svelte";
|
|
@@ -47,6 +45,7 @@
|
|
| 47 |
currentModel: Model;
|
| 48 |
models: Model[];
|
| 49 |
preprompt?: string | undefined;
|
|
|
|
| 50 |
files?: File[];
|
| 51 |
onmessage?: (content: string) => void;
|
| 52 |
onstop?: () => void;
|
|
@@ -64,6 +63,7 @@
|
|
| 64 |
currentModel,
|
| 65 |
models,
|
| 66 |
preprompt = undefined,
|
|
|
|
| 67 |
files = $bindable([]),
|
| 68 |
onmessage,
|
| 69 |
onstop,
|
|
@@ -74,6 +74,13 @@
|
|
| 74 |
|
| 75 |
let isReadOnly = $derived(!models.some((model) => model.id === currentModel.id));
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
let message: string = $state("");
|
| 78 |
let shareModalOpen = $state(false);
|
| 79 |
let editMsdgId: Message["id"] | null = $state(null);
|
|
@@ -356,6 +363,9 @@
|
|
| 356 |
isAuthor={!shared}
|
| 357 |
readOnly={isReadOnly}
|
| 358 |
isLast={idx === messages.length - 1}
|
|
|
|
|
|
|
|
|
|
| 359 |
bind:editMsdgId
|
| 360 |
onretry={(payload) => onretry?.(payload)}
|
| 361 |
onshowAlternateMsg={(payload) => onshowAlternateMsg?.(payload)}
|
|
@@ -390,18 +400,14 @@
|
|
| 390 |
/>
|
| 391 |
{/if}
|
| 392 |
</div>
|
| 393 |
-
|
| 394 |
-
<ScrollToPreviousBtn class="fixed bottom-48 right-4 lg:right-10" scrollNode={chatContainer} />
|
| 395 |
-
|
| 396 |
-
<ScrollToBottomBtn class="fixed bottom-36 right-4 lg:right-10" scrollNode={chatContainer} />
|
| 397 |
</div>
|
| 398 |
|
| 399 |
<div
|
| 400 |
class="pointer-events-none absolute inset-x-0 bottom-0 z-0 mx-auto flex w-full
|
| 401 |
max-w-3xl flex-col items-center justify-center bg-gradient-to-t from-white
|
| 402 |
-
via-white/100 to-white/0 px-3.5 pt-2 dark:border-gray-800
|
| 403 |
dark:from-gray-900 dark:via-gray-900/100
|
| 404 |
-
dark:to-gray-900/0 max-sm:py-0 sm:px-5
|
| 405 |
>
|
| 406 |
{#if !message.length && !messages.length && !sources.length && !loading && currentModel.isRouter && routerExamples.length && !hideRouterExamples}
|
| 407 |
<div
|
|
@@ -452,7 +458,8 @@
|
|
| 452 |
<div class="w-full">
|
| 453 |
<div class="flex w-full *:mb-3">
|
| 454 |
{#if !loading}
|
| 455 |
-
|
|
|
|
| 456 |
<RetryBtn
|
| 457 |
classNames="ml-auto"
|
| 458 |
onClick={() => {
|
|
@@ -463,7 +470,8 @@
|
|
| 463 |
}
|
| 464 |
}}
|
| 465 |
/>
|
| 466 |
-
{:else
|
|
|
|
| 467 |
<div class="ml-auto gap-2">
|
| 468 |
<ContinueBtn
|
| 469 |
onClick={() => {
|
|
@@ -478,6 +486,57 @@
|
|
| 478 |
{/if}
|
| 479 |
{/if}
|
| 480 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
<form
|
| 482 |
tabindex="-1"
|
| 483 |
aria-label={isFileUploadEnabled ? "file dropzone" : undefined}
|
|
@@ -551,57 +610,6 @@
|
|
| 551 |
</div>
|
| 552 |
{/if}
|
| 553 |
</form>
|
| 554 |
-
<div
|
| 555 |
-
class={{
|
| 556 |
-
"mt-3 flex items-center justify-end gap-2 self-stretch whitespace-nowrap px-0.5 pt-2 text-xs text-gray-400/90 max-md:mb-2 max-sm:gap-2": true,
|
| 557 |
-
"max-sm:hidden": focused && isVirtualKeyboard(),
|
| 558 |
-
}}
|
| 559 |
-
>
|
| 560 |
-
<PersonaSelector />
|
| 561 |
-
{#if models.find((m) => m.id === currentModel.id)}
|
| 562 |
-
{#if !currentModel.isRouter || !loading}
|
| 563 |
-
<a
|
| 564 |
-
href="{base}/settings/{currentModel.id}"
|
| 565 |
-
class="flex items-center gap-1.5 rounded-lg border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm text-gray-700 shadow-sm hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
| 566 |
-
>
|
| 567 |
-
<CarbonModel class="text-xs" />
|
| 568 |
-
{#if currentModel.isRouter}
|
| 569 |
-
<IconOmni classNames="text-xs" />
|
| 570 |
-
<span class="max-w-[250px] truncate">{currentModel.displayName}</span>
|
| 571 |
-
{:else}
|
| 572 |
-
<span class="max-w-[250px] truncate">{currentModel.displayName}</span>
|
| 573 |
-
{/if}
|
| 574 |
-
</a>
|
| 575 |
-
{:else if showRouterDetails && streamingRouterMetadata}
|
| 576 |
-
<div
|
| 577 |
-
class="mr-2 flex items-center gap-1.5 whitespace-nowrap text-[.70rem] text-xs leading-none text-gray-400 dark:text-gray-400"
|
| 578 |
-
>
|
| 579 |
-
<IconOmni classNames="text-xs animate-pulse" />
|
| 580 |
-
|
| 581 |
-
<span class="router-badge-text router-shimmer">
|
| 582 |
-
{streamingRouterMetadata.route}
|
| 583 |
-
</span>
|
| 584 |
-
|
| 585 |
-
<span class="text-gray-500">with</span>
|
| 586 |
-
|
| 587 |
-
<span class="router-badge-text">
|
| 588 |
-
{streamingRouterModelName}
|
| 589 |
-
</span>
|
| 590 |
-
</div>
|
| 591 |
-
{:else}
|
| 592 |
-
<div
|
| 593 |
-
class="loading-dots relative inline-flex items-center text-gray-400 dark:text-gray-400"
|
| 594 |
-
aria-label="Routing…"
|
| 595 |
-
>
|
| 596 |
-
<IconOmni classNames="text-xs animate-pulse mr-1" /> Routing
|
| 597 |
-
</div>
|
| 598 |
-
{/if}
|
| 599 |
-
{:else}
|
| 600 |
-
<span class="inline-flex items-center line-through dark:border-gray-700">
|
| 601 |
-
{currentModel.id}
|
| 602 |
-
</span>
|
| 603 |
-
{/if}
|
| 604 |
-
</div>
|
| 605 |
</div>
|
| 606 |
</div>
|
| 607 |
</div>
|
|
|
|
| 17 |
import { base } from "$app/paths";
|
| 18 |
import ContinueBtn from "../ContinueBtn.svelte";
|
| 19 |
import ChatMessage from "./ChatMessage.svelte";
|
|
|
|
|
|
|
| 20 |
import { browser } from "$app/environment";
|
| 21 |
import { snapScrollToBottom } from "$lib/actions/snapScrollToBottom";
|
| 22 |
import SystemPromptModal from "../SystemPromptModal.svelte";
|
|
|
|
| 45 |
currentModel: Model;
|
| 46 |
models: Model[];
|
| 47 |
preprompt?: string | undefined;
|
| 48 |
+
personaId?: string;
|
| 49 |
files?: File[];
|
| 50 |
onmessage?: (content: string) => void;
|
| 51 |
onstop?: () => void;
|
|
|
|
| 63 |
currentModel,
|
| 64 |
models,
|
| 65 |
preprompt = undefined,
|
| 66 |
+
personaId,
|
| 67 |
files = $bindable([]),
|
| 68 |
onmessage,
|
| 69 |
onstop,
|
|
|
|
| 74 |
|
| 75 |
let isReadOnly = $derived(!models.some((model) => model.id === currentModel.id));
|
| 76 |
|
| 77 |
+
// Get persona information for single-persona mode
|
| 78 |
+
let userSettings = useSettingsStore();
|
| 79 |
+
let persona = $derived.by(() => {
|
| 80 |
+
if (!personaId) return undefined;
|
| 81 |
+
return $userSettings.personas?.find((p) => p.id === personaId);
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
let message: string = $state("");
|
| 85 |
let shareModalOpen = $state(false);
|
| 86 |
let editMsdgId: Message["id"] | null = $state(null);
|
|
|
|
| 363 |
isAuthor={!shared}
|
| 364 |
readOnly={isReadOnly}
|
| 365 |
isLast={idx === messages.length - 1}
|
| 366 |
+
personaName={message.from === "assistant" && !message.personaResponses ? persona?.name : undefined}
|
| 367 |
+
personaOccupation={message.from === "assistant" && !message.personaResponses ? persona?.occupation : undefined}
|
| 368 |
+
personaStance={message.from === "assistant" && !message.personaResponses ? persona?.stance : undefined}
|
| 369 |
bind:editMsdgId
|
| 370 |
onretry={(payload) => onretry?.(payload)}
|
| 371 |
onshowAlternateMsg={(payload) => onshowAlternateMsg?.(payload)}
|
|
|
|
| 400 |
/>
|
| 401 |
{/if}
|
| 402 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
</div>
|
| 404 |
|
| 405 |
<div
|
| 406 |
class="pointer-events-none absolute inset-x-0 bottom-0 z-0 mx-auto flex w-full
|
| 407 |
max-w-3xl flex-col items-center justify-center bg-gradient-to-t from-white
|
| 408 |
+
via-white/100 to-white/0 px-3.5 pb-4 pt-2 dark:border-gray-800
|
| 409 |
dark:from-gray-900 dark:via-gray-900/100
|
| 410 |
+
dark:to-gray-900/0 max-sm:py-0 sm:px-5 xl:max-w-4xl [&>*]:pointer-events-auto"
|
| 411 |
>
|
| 412 |
{#if !message.length && !messages.length && !sources.length && !loading && currentModel.isRouter && routerExamples.length && !hideRouterExamples}
|
| 413 |
<div
|
|
|
|
| 458 |
<div class="w-full">
|
| 459 |
<div class="flex w-full *:mb-3">
|
| 460 |
{#if !loading}
|
| 461 |
+
<!-- Retry button commented out - regeneration disabled -->
|
| 462 |
+
<!-- {#if lastIsError}
|
| 463 |
<RetryBtn
|
| 464 |
classNames="ml-auto"
|
| 465 |
onClick={() => {
|
|
|
|
| 470 |
}
|
| 471 |
}}
|
| 472 |
/>
|
| 473 |
+
{:else -->
|
| 474 |
+
{#if messages && lastMessage && lastMessage.interrupted && !isReadOnly}
|
| 475 |
<div class="ml-auto gap-2">
|
| 476 |
<ContinueBtn
|
| 477 |
onClick={() => {
|
|
|
|
| 486 |
{/if}
|
| 487 |
{/if}
|
| 488 |
</div>
|
| 489 |
+
<div
|
| 490 |
+
class={{
|
| 491 |
+
"mb-3 flex items-center justify-end gap-2 self-stretch whitespace-nowrap px-0.5 text-xs text-gray-400/90 max-md:mb-2 max-sm:gap-2": true,
|
| 492 |
+
"max-sm:hidden": focused && isVirtualKeyboard(),
|
| 493 |
+
}}
|
| 494 |
+
>
|
| 495 |
+
<PersonaSelector />
|
| 496 |
+
{#if models.find((m) => m.id === currentModel.id)}
|
| 497 |
+
{#if !currentModel.isRouter || !loading}
|
| 498 |
+
<a
|
| 499 |
+
href="{base}/settings/models/{currentModel.id}"
|
| 500 |
+
class="flex items-center gap-1.5 rounded-lg border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm text-gray-400 shadow-sm hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-500 dark:hover:bg-gray-700"
|
| 501 |
+
>
|
| 502 |
+
<CarbonModel class="text-xs" />
|
| 503 |
+
{#if currentModel.isRouter}
|
| 504 |
+
<IconOmni classNames="text-xs" />
|
| 505 |
+
<span class="max-w-[250px] truncate">{currentModel.displayName}</span>
|
| 506 |
+
{:else}
|
| 507 |
+
<span class="max-w-[250px] truncate">{currentModel.displayName}</span>
|
| 508 |
+
{/if}
|
| 509 |
+
</a>
|
| 510 |
+
{:else if showRouterDetails && streamingRouterMetadata}
|
| 511 |
+
<div
|
| 512 |
+
class="mr-2 flex items-center gap-1.5 whitespace-nowrap text-[.70rem] text-xs leading-none text-gray-400 dark:text-gray-400"
|
| 513 |
+
>
|
| 514 |
+
<IconOmni classNames="text-xs animate-pulse" />
|
| 515 |
+
|
| 516 |
+
<span class="router-badge-text router-shimmer">
|
| 517 |
+
{streamingRouterMetadata.route}
|
| 518 |
+
</span>
|
| 519 |
+
|
| 520 |
+
<span class="text-gray-500">with</span>
|
| 521 |
+
|
| 522 |
+
<span class="router-badge-text">
|
| 523 |
+
{streamingRouterModelName}
|
| 524 |
+
</span>
|
| 525 |
+
</div>
|
| 526 |
+
{:else}
|
| 527 |
+
<div
|
| 528 |
+
class="loading-dots relative inline-flex items-center text-gray-400 dark:text-gray-400"
|
| 529 |
+
aria-label="Routing…"
|
| 530 |
+
>
|
| 531 |
+
<IconOmni classNames="text-xs animate-pulse mr-1" /> Routing
|
| 532 |
+
</div>
|
| 533 |
+
{/if}
|
| 534 |
+
{:else}
|
| 535 |
+
<span class="inline-flex items-center line-through dark:border-gray-700">
|
| 536 |
+
{currentModel.id}
|
| 537 |
+
</span>
|
| 538 |
+
{/if}
|
| 539 |
+
</div>
|
| 540 |
<form
|
| 541 |
tabindex="-1"
|
| 542 |
aria-label={isFileUploadEnabled ? "file dropzone" : undefined}
|
|
|
|
| 610 |
</div>
|
| 611 |
{/if}
|
| 612 |
</form>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
</div>
|
| 614 |
</div>
|
| 615 |
</div>
|
src/lib/components/chat/PersonaResponseCards.svelte
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import type { PersonaResponse } from "$lib/types/Message";
|
| 3 |
+
import MarkdownRenderer from "./MarkdownRenderer.svelte";
|
| 4 |
+
import OpenReasoningResults from "./OpenReasoningResults.svelte";
|
| 5 |
+
import CopyToClipBoardBtn from "../CopyToClipBoardBtn.svelte";
|
| 6 |
+
import CarbonRotate360 from "~icons/carbon/rotate-360";
|
| 7 |
+
import CarbonChevronDown from "~icons/carbon/chevron-down";
|
| 8 |
+
import CarbonChevronUp from "~icons/carbon/chevron-up";
|
| 9 |
+
import { THINK_BLOCK_REGEX } from "$lib/constants/thinkBlockRegex";
|
| 10 |
+
import { goto } from "$app/navigation";
|
| 11 |
+
import { base } from "$app/paths";
|
| 12 |
+
|
| 13 |
+
interface Props {
|
| 14 |
+
personaResponses: PersonaResponse[];
|
| 15 |
+
loading?: boolean;
|
| 16 |
+
onretry?: (personaId: string) => void;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
let { personaResponses, loading = false, onretry }: Props = $props();
|
| 20 |
+
|
| 21 |
+
// Track expanded state for each persona
|
| 22 |
+
let expandedStates = $state<Record<string, boolean>>({});
|
| 23 |
+
|
| 24 |
+
// Track content elements for overflow detection
|
| 25 |
+
let contentElements = $state<Record<string, HTMLElement | null>>({});
|
| 26 |
+
const MAX_COLLAPSED_HEIGHT = 400;
|
| 27 |
+
|
| 28 |
+
function toggleExpanded(personaId: string) {
|
| 29 |
+
expandedStates[personaId] = !expandedStates[personaId];
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// Check if content has <think> blocks
|
| 33 |
+
function hasClientThink(content: string | undefined): boolean {
|
| 34 |
+
return content ? THINK_BLOCK_REGEX.test(content) : false;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// Check if content has overflow
|
| 38 |
+
function hasOverflow(personaId: string): boolean {
|
| 39 |
+
const element = contentElements[personaId];
|
| 40 |
+
if (!element) return false;
|
| 41 |
+
return element.scrollHeight > MAX_COLLAPSED_HEIGHT;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// Navigate to persona settings
|
| 45 |
+
function openPersonaSettings(personaId: string) {
|
| 46 |
+
goto(`${base}/settings/personas/${personaId}`);
|
| 47 |
+
}
|
| 48 |
+
</script>
|
| 49 |
+
|
| 50 |
+
<!-- Horizontal scrollable cards -->
|
| 51 |
+
<div class="flex gap-3 overflow-x-auto pb-2">
|
| 52 |
+
{#each personaResponses as response (response.personaId)}
|
| 53 |
+
{@const isExpanded = expandedStates[response.personaId]}
|
| 54 |
+
|
| 55 |
+
<div
|
| 56 |
+
class="persona-card flex-shrink-0 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-850"
|
| 57 |
+
style="min-width: 300px; max-width: {isExpanded ? '600px' : '400px'};"
|
| 58 |
+
>
|
| 59 |
+
<!-- Persona Header -->
|
| 60 |
+
<div class="mb-3 flex items-center justify-between border-b border-gray-200 pb-2 dark:border-gray-700">
|
| 61 |
+
<button
|
| 62 |
+
type="button"
|
| 63 |
+
class="font-semibold text-gray-900 hover:text-gray-700 dark:text-gray-100 dark:hover:text-gray-300 transition-colors"
|
| 64 |
+
onclick={() => openPersonaSettings(response.personaId)}
|
| 65 |
+
aria-label="Open persona settings"
|
| 66 |
+
>
|
| 67 |
+
{response.personaName}
|
| 68 |
+
</button>
|
| 69 |
+
<div class="flex items-center gap-1">
|
| 70 |
+
<CopyToClipBoardBtn
|
| 71 |
+
classNames="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800"
|
| 72 |
+
value={response.content}
|
| 73 |
+
/>
|
| 74 |
+
<!-- Regenerate button commented out - regeneration disabled -->
|
| 75 |
+
<!-- {#if onretry}
|
| 76 |
+
<button
|
| 77 |
+
type="button"
|
| 78 |
+
class="rounded-md p-1.5 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
| 79 |
+
onclick={() => onretry?.(response.personaId)}
|
| 80 |
+
aria-label="Regenerate response"
|
| 81 |
+
>
|
| 82 |
+
<CarbonRotate360 class="text-base" />
|
| 83 |
+
</button>
|
| 84 |
+
{/if} -->
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
<!-- Persona Content -->
|
| 89 |
+
<div
|
| 90 |
+
bind:this={contentElements[response.personaId]}
|
| 91 |
+
class="mt-2"
|
| 92 |
+
style={isExpanded ? '' : `max-height: ${MAX_COLLAPSED_HEIGHT}px; overflow: hidden;`}
|
| 93 |
+
>
|
| 94 |
+
{#if hasClientThink(response.content)}
|
| 95 |
+
{#each response.content.split(THINK_BLOCK_REGEX) as part, _i}
|
| 96 |
+
{#if part && part.startsWith("<think>")}
|
| 97 |
+
{@const isClosed = part.endsWith("</think>")}
|
| 98 |
+
{@const thinkContent = part.slice(7, isClosed ? -8 : undefined)}
|
| 99 |
+
{@const summary = isClosed
|
| 100 |
+
? thinkContent.trim().split(/\n+/)[0] || "Reasoning"
|
| 101 |
+
: "Thinking..."}
|
| 102 |
+
|
| 103 |
+
<OpenReasoningResults
|
| 104 |
+
{summary}
|
| 105 |
+
content={thinkContent}
|
| 106 |
+
loading={loading && !isClosed}
|
| 107 |
+
/>
|
| 108 |
+
{:else if part && part.trim().length > 0}
|
| 109 |
+
<div
|
| 110 |
+
class="prose max-w-none dark:prose-invert max-sm:prose-sm prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 dark:prose-pre:bg-gray-900"
|
| 111 |
+
>
|
| 112 |
+
<MarkdownRenderer content={part} {loading} />
|
| 113 |
+
</div>
|
| 114 |
+
{/if}
|
| 115 |
+
{/each}
|
| 116 |
+
{:else}
|
| 117 |
+
<div
|
| 118 |
+
class="prose max-w-none dark:prose-invert max-sm:prose-sm prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 dark:prose-pre:bg-gray-900"
|
| 119 |
+
>
|
| 120 |
+
<MarkdownRenderer content={response.content} {loading} />
|
| 121 |
+
</div>
|
| 122 |
+
{/if}
|
| 123 |
+
|
| 124 |
+
{#if response.routerMetadata}
|
| 125 |
+
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
| 126 |
+
<span class="font-medium">{response.routerMetadata.route}</span>
|
| 127 |
+
<span class="mx-1">•</span>
|
| 128 |
+
<span>{response.routerMetadata.model}</span>
|
| 129 |
+
</div>
|
| 130 |
+
{/if}
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
<!-- Expand/Collapse button - only show if overflow exists -->
|
| 134 |
+
{#if hasOverflow(response.personaId)}
|
| 135 |
+
<button
|
| 136 |
+
onclick={() => toggleExpanded(response.personaId)}
|
| 137 |
+
class="mt-3 flex w-full items-center justify-center gap-1 rounded-md border border-gray-200 bg-gray-50 py-1.5 text-sm text-gray-600 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
|
| 138 |
+
>
|
| 139 |
+
{#if isExpanded}
|
| 140 |
+
<CarbonChevronUp class="text-base" />
|
| 141 |
+
<span>Show less</span>
|
| 142 |
+
{:else}
|
| 143 |
+
<CarbonChevronDown class="text-base" />
|
| 144 |
+
<span>Show more</span>
|
| 145 |
+
{/if}
|
| 146 |
+
</button>
|
| 147 |
+
{/if}
|
| 148 |
+
</div>
|
| 149 |
+
{/each}
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
<style>
|
| 153 |
+
.persona-card {
|
| 154 |
+
transition: all 0.3s ease;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/* Smooth scrollbar styling */
|
| 158 |
+
.overflow-x-auto {
|
| 159 |
+
scrollbar-width: thin;
|
| 160 |
+
scrollbar-color: rgb(209 213 219) transparent;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.overflow-x-auto::-webkit-scrollbar {
|
| 164 |
+
height: 8px;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.overflow-x-auto::-webkit-scrollbar-track {
|
| 168 |
+
background: transparent;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.overflow-x-auto::-webkit-scrollbar-thumb {
|
| 172 |
+
background-color: rgb(209 213 219);
|
| 173 |
+
border-radius: 4px;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
.overflow-x-auto::-webkit-scrollbar-thumb:hover {
|
| 177 |
+
background-color: rgb(156 163 175);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
:global(.dark) .overflow-x-auto {
|
| 181 |
+
scrollbar-color: rgb(75 85 99) transparent;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
:global(.dark) .overflow-x-auto::-webkit-scrollbar-thumb {
|
| 185 |
+
background-color: rgb(75 85 99);
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
:global(.dark) .overflow-x-auto::-webkit-scrollbar-thumb:hover {
|
| 189 |
+
background-color: rgb(107 114 128);
|
| 190 |
+
}
|
| 191 |
+
</style>
|
| 192 |
+
|
src/lib/components/chat/PersonaResponseCarousel.svelte
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import type { PersonaResponse } from "$lib/types/Message";
|
| 3 |
+
import CarbonChevronLeft from "~icons/carbon/chevron-left";
|
| 4 |
+
import CarbonChevronRight from "~icons/carbon/chevron-right";
|
| 5 |
+
import CarbonChevronDown from "~icons/carbon/chevron-down";
|
| 6 |
+
import CarbonChevronUp from "~icons/carbon/chevron-up";
|
| 7 |
+
import MarkdownRenderer from "./MarkdownRenderer.svelte";
|
| 8 |
+
import OpenReasoningResults from "./OpenReasoningResults.svelte";
|
| 9 |
+
import CopyToClipBoardBtn from "../CopyToClipBoardBtn.svelte";
|
| 10 |
+
import CarbonRotate360 from "~icons/carbon/rotate-360";
|
| 11 |
+
import { THINK_BLOCK_REGEX } from "$lib/constants/thinkBlockRegex";
|
| 12 |
+
|
| 13 |
+
interface Props {
|
| 14 |
+
personaResponses: PersonaResponse[];
|
| 15 |
+
loading?: boolean;
|
| 16 |
+
onretry?: (personaId: string) => void;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
let { personaResponses, loading = false, onretry }: Props = $props();
|
| 20 |
+
|
| 21 |
+
let currentIndex = $state(0);
|
| 22 |
+
let expandedStates = $state<Record<string, boolean>>({});
|
| 23 |
+
let isDragging = $state(false);
|
| 24 |
+
let startX = $state(0);
|
| 25 |
+
let currentX = $state(0);
|
| 26 |
+
let dragOffset = $state(0);
|
| 27 |
+
|
| 28 |
+
// Detect if device has touch/coarse pointer (mobile/tablet)
|
| 29 |
+
let isTouchDevice = $state(false);
|
| 30 |
+
|
| 31 |
+
$effect(() => {
|
| 32 |
+
if (typeof window !== 'undefined') {
|
| 33 |
+
// Check if device has coarse pointer (touchscreen) or no pointer (touch-only)
|
| 34 |
+
isTouchDevice = window.matchMedia('(pointer: coarse)').matches ||
|
| 35 |
+
window.matchMedia('(pointer: none)').matches ||
|
| 36 |
+
'ontouchstart' in window;
|
| 37 |
+
}
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
// Track content heights for overflow detection
|
| 41 |
+
let contentElements = $state<Record<string, HTMLElement | null>>({});
|
| 42 |
+
const MAX_COLLAPSED_HEIGHT = 400;
|
| 43 |
+
|
| 44 |
+
// Track which version of each persona's response is being shown
|
| 45 |
+
let personaVersionIndices = $state<Record<string, number>>({});
|
| 46 |
+
|
| 47 |
+
// Get the currently displayed version of a persona response
|
| 48 |
+
function getDisplayedResponse(response: PersonaResponse): PersonaResponse {
|
| 49 |
+
const versionIndex = personaVersionIndices[response.personaId] ?? response.currentChildIndex ?? 0;
|
| 50 |
+
|
| 51 |
+
if (versionIndex === 0 || !response.children || response.children.length === 0) {
|
| 52 |
+
return response; // Show current response
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// Show a previous version from children
|
| 56 |
+
const childIndex = versionIndex - 1;
|
| 57 |
+
return response.children[childIndex] ?? response;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// Get all versions of a persona response (current + children)
|
| 61 |
+
function getAllVersions(response: PersonaResponse): PersonaResponse[] {
|
| 62 |
+
const versions = [response];
|
| 63 |
+
if (response.children && response.children.length > 0) {
|
| 64 |
+
versions.push(...response.children);
|
| 65 |
+
}
|
| 66 |
+
return versions;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// Navigate to a different version of a persona's response
|
| 70 |
+
function navigateToVersion(personaId: string, versionIndex: number) {
|
| 71 |
+
personaVersionIndices[personaId] = versionIndex;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function next() {
|
| 75 |
+
if (currentIndex < personaResponses.length - 1) {
|
| 76 |
+
// Collapse current card if expanded before navigating
|
| 77 |
+
const currentPersonaId = personaResponses[currentIndex]?.personaId;
|
| 78 |
+
if (currentPersonaId && expandedStates[currentPersonaId]) {
|
| 79 |
+
expandedStates[currentPersonaId] = false;
|
| 80 |
+
}
|
| 81 |
+
currentIndex = currentIndex + 1;
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function previous() {
|
| 86 |
+
if (currentIndex > 0) {
|
| 87 |
+
// Collapse current card if expanded before navigating
|
| 88 |
+
const currentPersonaId = personaResponses[currentIndex]?.personaId;
|
| 89 |
+
if (currentPersonaId && expandedStates[currentPersonaId]) {
|
| 90 |
+
expandedStates[currentPersonaId] = false;
|
| 91 |
+
}
|
| 92 |
+
currentIndex = currentIndex - 1;
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function goToIndex(index: number) {
|
| 97 |
+
// Collapse current card if expanded before navigating
|
| 98 |
+
const currentPersonaId = personaResponses[currentIndex]?.personaId;
|
| 99 |
+
if (currentPersonaId && expandedStates[currentPersonaId]) {
|
| 100 |
+
expandedStates[currentPersonaId] = false;
|
| 101 |
+
}
|
| 102 |
+
currentIndex = index;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function handleDragStart(event: MouseEvent | TouchEvent) {
|
| 106 |
+
// Only allow mouse dragging on touch devices
|
| 107 |
+
if (!isTouchDevice && !('touches' in event)) {
|
| 108 |
+
return;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
isDragging = true;
|
| 112 |
+
startX = 'touches' in event ? event.touches[0].clientX : event.clientX;
|
| 113 |
+
currentX = startX;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
function handleDragMove(event: MouseEvent | TouchEvent) {
|
| 117 |
+
if (!isDragging) return;
|
| 118 |
+
|
| 119 |
+
// Only allow mouse dragging on touch devices
|
| 120 |
+
if (!isTouchDevice && !('touches' in event)) {
|
| 121 |
+
return;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
event.preventDefault();
|
| 125 |
+
currentX = 'touches' in event ? event.touches[0].clientX : event.clientX;
|
| 126 |
+
dragOffset = currentX - startX;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
function handleDragEnd() {
|
| 130 |
+
if (!isDragging) return;
|
| 131 |
+
|
| 132 |
+
isDragging = false;
|
| 133 |
+
const threshold = 50;
|
| 134 |
+
|
| 135 |
+
if (dragOffset < -threshold && currentIndex < personaResponses.length - 1) {
|
| 136 |
+
next();
|
| 137 |
+
} else if (dragOffset > threshold && currentIndex > 0) {
|
| 138 |
+
previous();
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
dragOffset = 0;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
function toggleExpanded(personaId: string) {
|
| 145 |
+
expandedStates[personaId] = !expandedStates[personaId];
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// Check if content has overflow
|
| 149 |
+
function hasOverflow(personaId: string): boolean {
|
| 150 |
+
const element = contentElements[personaId];
|
| 151 |
+
if (!element) return false;
|
| 152 |
+
return element.scrollHeight > MAX_COLLAPSED_HEIGHT;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
let currentResponse = $derived(personaResponses[currentIndex]);
|
| 156 |
+
|
| 157 |
+
// Check if content has <think> blocks
|
| 158 |
+
function hasClientThink(content: string | undefined): boolean {
|
| 159 |
+
return content ? THINK_BLOCK_REGEX.test(content) : false;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
let showLeftArrow = $derived(currentIndex > 0);
|
| 163 |
+
let showRightArrow = $derived(currentIndex < personaResponses.length - 1);
|
| 164 |
+
let showPositionIndicator = $derived(personaResponses.length > 3);
|
| 165 |
+
</script>
|
| 166 |
+
|
| 167 |
+
<!-- Outer wrapper for arrows -->
|
| 168 |
+
<div class="relative w-full">
|
| 169 |
+
<!-- Left Navigation Arrow - positioned outside cards -->
|
| 170 |
+
{#if personaResponses.length > 1 && showLeftArrow}
|
| 171 |
+
<button
|
| 172 |
+
onclick={previous}
|
| 173 |
+
class="absolute -left-16 top-1/2 z-20 -translate-y-1/2 p-2 text-gray-600 transition-all hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
| 174 |
+
aria-label="Previous persona"
|
| 175 |
+
>
|
| 176 |
+
<CarbonChevronLeft class="text-3xl" />
|
| 177 |
+
</button>
|
| 178 |
+
{/if}
|
| 179 |
+
|
| 180 |
+
<!-- Right Navigation Arrow - positioned outside cards -->
|
| 181 |
+
{#if personaResponses.length > 1 && showRightArrow}
|
| 182 |
+
<button
|
| 183 |
+
onclick={next}
|
| 184 |
+
class="absolute -right-16 top-1/2 z-20 -translate-y-1/2 p-2 text-gray-600 transition-all hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
| 185 |
+
aria-label="Next persona"
|
| 186 |
+
>
|
| 187 |
+
<CarbonChevronRight class="text-3xl" />
|
| 188 |
+
</button>
|
| 189 |
+
{/if}
|
| 190 |
+
|
| 191 |
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
| 192 |
+
<div
|
| 193 |
+
class="carousel-container relative w-full overflow-hidden transition-all duration-300"
|
| 194 |
+
onmousedown={handleDragStart}
|
| 195 |
+
onmousemove={handleDragMove}
|
| 196 |
+
onmouseup={handleDragEnd}
|
| 197 |
+
onmouseleave={handleDragEnd}
|
| 198 |
+
ontouchstart={handleDragStart}
|
| 199 |
+
ontouchmove={handleDragMove}
|
| 200 |
+
ontouchend={handleDragEnd}
|
| 201 |
+
>
|
| 202 |
+
<div
|
| 203 |
+
class="carousel-track flex transition-all duration-300 ease-out"
|
| 204 |
+
class:dragging={isDragging}
|
| 205 |
+
style="transform: translateX(calc(-{currentIndex * 100}% + {dragOffset}px));"
|
| 206 |
+
>
|
| 207 |
+
{#each personaResponses as response, index (response.personaId)}
|
| 208 |
+
{@const isActive = index === currentIndex}
|
| 209 |
+
{@const isPrevious = index === currentIndex - 1}
|
| 210 |
+
{@const isNext = index === currentIndex + 1}
|
| 211 |
+
{@const isExpanded = expandedStates[response.personaId]}
|
| 212 |
+
{@const displayedResponse = getDisplayedResponse(response)}
|
| 213 |
+
{@const allVersions = getAllVersions(response)}
|
| 214 |
+
{@const currentVersionIndex = personaVersionIndices[response.personaId] ?? response.currentChildIndex ?? 0}
|
| 215 |
+
|
| 216 |
+
<div
|
| 217 |
+
class="carousel-card flex-shrink-0 transition-all duration-300"
|
| 218 |
+
class:active={isActive}
|
| 219 |
+
class:peek={!isActive}
|
| 220 |
+
style="width: 100%;"
|
| 221 |
+
>
|
| 222 |
+
<div
|
| 223 |
+
class="relative w-full rounded-2xl border border-gray-100 bg-gradient-to-br from-gray-50 px-5 py-3.5 text-gray-600 dark:border-gray-800 dark:from-gray-800/80 dark:text-gray-300"
|
| 224 |
+
class:pointer-events-none={!isActive}
|
| 225 |
+
>
|
| 226 |
+
<!-- Persona Name at Top Left -->
|
| 227 |
+
<div class="mb-4 flex items-center justify-between border-b border-gray-200 pb-2 dark:border-gray-700">
|
| 228 |
+
<div>
|
| 229 |
+
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
| 230 |
+
{response.personaName}
|
| 231 |
+
</h3>
|
| 232 |
+
{#if response.personaOccupation || response.personaStance}
|
| 233 |
+
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
| 234 |
+
{#if response.personaOccupation}<span>{response.personaOccupation}</span>{/if}{#if response.personaOccupation && response.personaStance}<span class="mx-1">•</span>{/if}{#if response.personaStance}<span>{response.personaStance}</span>{/if}
|
| 235 |
+
</div>
|
| 236 |
+
{/if}
|
| 237 |
+
</div>
|
| 238 |
+
|
| 239 |
+
<!-- Position Indicator Dots (inside card) -->
|
| 240 |
+
{#if personaResponses.length > 1}
|
| 241 |
+
<div class="flex items-center gap-2">
|
| 242 |
+
{#if showPositionIndicator}
|
| 243 |
+
<!-- Text indicator for N > 3 -->
|
| 244 |
+
<div class="text-sm text-gray-600 dark:text-gray-400">
|
| 245 |
+
{currentIndex + 1} of {personaResponses.length}
|
| 246 |
+
</div>
|
| 247 |
+
{/if}
|
| 248 |
+
|
| 249 |
+
<!-- Dot indicator -->
|
| 250 |
+
<div class="flex gap-1.5">
|
| 251 |
+
{#each personaResponses as _, idx}
|
| 252 |
+
<button
|
| 253 |
+
onclick={() => goToIndex(idx)}
|
| 254 |
+
class="size-2 rounded-full transition-all {idx === currentIndex
|
| 255 |
+
? 'bg-gray-700 dark:bg-gray-300'
|
| 256 |
+
: 'bg-gray-400 dark:bg-gray-600 hover:bg-gray-500 dark:hover:bg-gray-500'}"
|
| 257 |
+
aria-label={`Go to ${personaResponses[idx].personaName}`}
|
| 258 |
+
></button>
|
| 259 |
+
{/each}
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
{/if}
|
| 263 |
+
</div>
|
| 264 |
+
|
| 265 |
+
<!-- Persona Content -->
|
| 266 |
+
<div
|
| 267 |
+
bind:this={contentElements[response.personaId]}
|
| 268 |
+
class="content-wrapper relative"
|
| 269 |
+
style={isExpanded ? '' : `max-height: ${MAX_COLLAPSED_HEIGHT}px; overflow: hidden;`}
|
| 270 |
+
>
|
| 271 |
+
{#if hasClientThink(displayedResponse.content)}
|
| 272 |
+
{#each displayedResponse.content.split(THINK_BLOCK_REGEX) as part, _i}
|
| 273 |
+
{#if part && part.startsWith("<think>")}
|
| 274 |
+
{@const isClosed = part.endsWith("</think>")}
|
| 275 |
+
{@const thinkContent = part.slice(7, isClosed ? -8 : undefined)}
|
| 276 |
+
{@const summary = isClosed
|
| 277 |
+
? thinkContent.trim().split(/\n+/)[0] || "Reasoning"
|
| 278 |
+
: "Thinking..."}
|
| 279 |
+
|
| 280 |
+
<OpenReasoningResults
|
| 281 |
+
{summary}
|
| 282 |
+
content={thinkContent}
|
| 283 |
+
loading={loading && !isClosed}
|
| 284 |
+
/>
|
| 285 |
+
{:else if part && part.trim().length > 0}
|
| 286 |
+
<div
|
| 287 |
+
class="prose max-w-none dark:prose-invert max-sm:prose-sm prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 dark:prose-pre:bg-gray-900"
|
| 288 |
+
>
|
| 289 |
+
<MarkdownRenderer content={part} {loading} />
|
| 290 |
+
</div>
|
| 291 |
+
{/if}
|
| 292 |
+
{/each}
|
| 293 |
+
{:else}
|
| 294 |
+
<div
|
| 295 |
+
class="prose max-w-none dark:prose-invert max-sm:prose-sm prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 dark:prose-pre:bg-gray-900"
|
| 296 |
+
>
|
| 297 |
+
<MarkdownRenderer content={displayedResponse.content} {loading} />
|
| 298 |
+
</div>
|
| 299 |
+
{/if}
|
| 300 |
+
|
| 301 |
+
{#if displayedResponse.routerMetadata}
|
| 302 |
+
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
| 303 |
+
<span class="font-medium">{displayedResponse.routerMetadata.route}</span>
|
| 304 |
+
<span class="mx-1">•</span>
|
| 305 |
+
<span>{displayedResponse.routerMetadata.model}</span>
|
| 306 |
+
</div>
|
| 307 |
+
{/if}
|
| 308 |
+
</div>
|
| 309 |
+
|
| 310 |
+
<!-- Bottom Actions Row -->
|
| 311 |
+
<div class="mt-4 flex items-center justify-between">
|
| 312 |
+
<!-- Left Side: Show More Button or Version Navigation -->
|
| 313 |
+
<div class="flex items-center gap-2">
|
| 314 |
+
{#if hasOverflow(response.personaId)}
|
| 315 |
+
<button
|
| 316 |
+
onclick={() => toggleExpanded(response.personaId)}
|
| 317 |
+
class="flex items-center gap-1 rounded-md px-3 py-1.5 text-sm text-gray-500 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700/50"
|
| 318 |
+
>
|
| 319 |
+
{#if isExpanded}
|
| 320 |
+
<CarbonChevronUp class="text-base" />
|
| 321 |
+
<span>Show less</span>
|
| 322 |
+
{:else}
|
| 323 |
+
<CarbonChevronDown class="text-base" />
|
| 324 |
+
<span>Show more</span>
|
| 325 |
+
{/if}
|
| 326 |
+
</button>
|
| 327 |
+
{/if}
|
| 328 |
+
|
| 329 |
+
<!-- Version Navigation (if multiple versions exist) -->
|
| 330 |
+
{#if allVersions.length > 1}
|
| 331 |
+
<div class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
|
| 332 |
+
<button
|
| 333 |
+
class="rounded-md p-1 hover:bg-gray-100 dark:hover:bg-gray-700/50 disabled:opacity-30 disabled:cursor-not-allowed"
|
| 334 |
+
onclick={() => navigateToVersion(response.personaId, Math.max(0, currentVersionIndex - 1))}
|
| 335 |
+
disabled={currentVersionIndex === 0 || loading}
|
| 336 |
+
aria-label="Previous version"
|
| 337 |
+
>
|
| 338 |
+
<CarbonChevronLeft class="text-base" />
|
| 339 |
+
</button>
|
| 340 |
+
<span class="text-xs">
|
| 341 |
+
{currentVersionIndex + 1} / {allVersions.length}
|
| 342 |
+
</span>
|
| 343 |
+
<button
|
| 344 |
+
class="rounded-md p-1 hover:bg-gray-100 dark:hover:bg-gray-700/50 disabled:opacity-30 disabled:cursor-not-allowed"
|
| 345 |
+
onclick={() => navigateToVersion(response.personaId, Math.min(allVersions.length - 1, currentVersionIndex + 1))}
|
| 346 |
+
disabled={currentVersionIndex === allVersions.length - 1 || loading}
|
| 347 |
+
aria-label="Next version"
|
| 348 |
+
>
|
| 349 |
+
<CarbonChevronRight class="text-base" />
|
| 350 |
+
</button>
|
| 351 |
+
</div>
|
| 352 |
+
{/if}
|
| 353 |
+
</div>
|
| 354 |
+
|
| 355 |
+
<!-- Copy and Regenerate Icons (Bottom Right) -->
|
| 356 |
+
<div class="flex items-center gap-1">
|
| 357 |
+
<CopyToClipBoardBtn
|
| 358 |
+
classNames="!rounded-md !p-2 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-700/50"
|
| 359 |
+
value={displayedResponse.content}
|
| 360 |
+
/>
|
| 361 |
+
<!-- Regenerate button commented out - regeneration disabled -->
|
| 362 |
+
<!-- {#if onretry}
|
| 363 |
+
<button
|
| 364 |
+
type="button"
|
| 365 |
+
class="rounded-md p-2 text-gray-600 hover:bg-gray-200/50 dark:text-gray-400 dark:hover:bg-gray-700/50"
|
| 366 |
+
onclick={() => onretry?.(response.personaId)}
|
| 367 |
+
aria-label="Regenerate response"
|
| 368 |
+
>
|
| 369 |
+
<CarbonRotate360 class="text-base" />
|
| 370 |
+
</button>
|
| 371 |
+
{/if} -->
|
| 372 |
+
</div>
|
| 373 |
+
</div>
|
| 374 |
+
</div>
|
| 375 |
+
</div>
|
| 376 |
+
{/each}
|
| 377 |
+
</div>
|
| 378 |
+
</div>
|
| 379 |
+
</div>
|
| 380 |
+
|
| 381 |
+
<style>
|
| 382 |
+
.carousel-container {
|
| 383 |
+
touch-action: pan-y pinch-zoom;
|
| 384 |
+
-ms-overflow-style: none;
|
| 385 |
+
scrollbar-width: none;
|
| 386 |
+
user-select: none;
|
| 387 |
+
-webkit-user-select: none;
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
/* Only show grab cursor on touch devices */
|
| 391 |
+
@media (pointer: coarse) {
|
| 392 |
+
.carousel-container {
|
| 393 |
+
cursor: grab;
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
.carousel-container:active {
|
| 397 |
+
cursor: grabbing;
|
| 398 |
+
}
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
.carousel-container::-webkit-scrollbar {
|
| 402 |
+
display: none;
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
.carousel-track {
|
| 406 |
+
display: flex;
|
| 407 |
+
gap: 0;
|
| 408 |
+
transition: transform 0.3s ease-out, height 0.3s ease-out;
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
.carousel-track.dragging {
|
| 412 |
+
transition: height 0.3s ease-out;
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
.carousel-card {
|
| 416 |
+
transition: opacity 0.3s ease, transform 0.3s ease, filter 0.3s ease;
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
.carousel-card.active {
|
| 420 |
+
opacity: 1;
|
| 421 |
+
transform: scale(1);
|
| 422 |
+
z-index: 10;
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
.carousel-card.peek {
|
| 426 |
+
opacity: 1;
|
| 427 |
+
transform: scale(0.92);
|
| 428 |
+
pointer-events: none;
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
/* Additional styling for better peeking effect - subtle dimming */
|
| 432 |
+
.carousel-card.peek > div {
|
| 433 |
+
filter: brightness(0.92) saturate(0.9);
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
:global(.dark) .carousel-card.peek > div {
|
| 437 |
+
filter: brightness(0.75) saturate(0.9);
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
/* Content wrapper auto-sizing */
|
| 441 |
+
.content-wrapper {
|
| 442 |
+
transition: max-height 0.3s ease;
|
| 443 |
+
}
|
| 444 |
+
</style>
|
| 445 |
+
|
src/lib/constants/thinkBlockRegex.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Zero-config reasoning autodetection: detect <think> blocks in content
|
| 2 |
+
export const THINK_BLOCK_REGEX = /(<think>[\s\S]*?(?:<\/think>|$))/g;
|
src/lib/migrations/routines/11-add-personas.ts
CHANGED
|
@@ -9,12 +9,12 @@ const migration: Migration = {
|
|
| 9 |
up: async () => {
|
| 10 |
const { settings } = collections;
|
| 11 |
|
| 12 |
-
// Add personas array and
|
| 13 |
await settings.updateMany(
|
| 14 |
{},
|
| 15 |
{
|
| 16 |
$set: {
|
| 17 |
-
|
| 18 |
personas: DEFAULT_PERSONAS.map((p) => ({
|
| 19 |
...p,
|
| 20 |
createdAt: new Date(),
|
|
|
|
| 9 |
up: async () => {
|
| 10 |
const { settings } = collections;
|
| 11 |
|
| 12 |
+
// Add personas array and activePersonas to all existing settings
|
| 13 |
await settings.updateMany(
|
| 14 |
{},
|
| 15 |
{
|
| 16 |
$set: {
|
| 17 |
+
activePersonas: ["default"],
|
| 18 |
personas: DEFAULT_PERSONAS.map((p) => ({
|
| 19 |
...p,
|
| 20 |
createdAt: new Date(),
|
src/lib/server/api/routes/groups/user.ts
CHANGED
|
@@ -74,7 +74,7 @@ export const userGroup = new Elysia()
|
|
| 74 |
welcomeModalSeenAt: settings?.welcomeModalSeenAt ?? null,
|
| 75 |
|
| 76 |
activeModel: settings?.activeModel ?? DEFAULT_SETTINGS.activeModel,
|
| 77 |
-
|
| 78 |
personas: settings?.personas ?? DEFAULT_SETTINGS.personas,
|
| 79 |
disableStream: settings?.disableStream ?? DEFAULT_SETTINGS.disableStream,
|
| 80 |
directPaste: settings?.directPaste ?? DEFAULT_SETTINGS.directPaste,
|
|
@@ -96,7 +96,7 @@ export const userGroup = new Elysia()
|
|
| 96 |
.default(DEFAULT_SETTINGS.shareConversationsWithModelAuthors),
|
| 97 |
welcomeModalSeen: z.boolean().optional(),
|
| 98 |
activeModel: z.string().default(DEFAULT_SETTINGS.activeModel),
|
| 99 |
-
|
| 100 |
personas: z.array(personaSchema).min(1).default(DEFAULT_SETTINGS.personas),
|
| 101 |
multimodalOverrides: z.record(z.boolean()).default({}),
|
| 102 |
disableStream: z.boolean().default(false),
|
|
|
|
| 74 |
welcomeModalSeenAt: settings?.welcomeModalSeenAt ?? null,
|
| 75 |
|
| 76 |
activeModel: settings?.activeModel ?? DEFAULT_SETTINGS.activeModel,
|
| 77 |
+
activePersonas: settings?.activePersonas ?? DEFAULT_SETTINGS.activePersonas,
|
| 78 |
personas: settings?.personas ?? DEFAULT_SETTINGS.personas,
|
| 79 |
disableStream: settings?.disableStream ?? DEFAULT_SETTINGS.disableStream,
|
| 80 |
directPaste: settings?.directPaste ?? DEFAULT_SETTINGS.directPaste,
|
|
|
|
| 96 |
.default(DEFAULT_SETTINGS.shareConversationsWithModelAuthors),
|
| 97 |
welcomeModalSeen: z.boolean().optional(),
|
| 98 |
activeModel: z.string().default(DEFAULT_SETTINGS.activeModel),
|
| 99 |
+
activePersonas: z.array(z.string()).min(1).default(DEFAULT_SETTINGS.activePersonas),
|
| 100 |
personas: z.array(personaSchema).min(1).default(DEFAULT_SETTINGS.personas),
|
| 101 |
multimodalOverrides: z.record(z.boolean()).default({}),
|
| 102 |
disableStream: z.boolean().default(false),
|
src/lib/server/defaultPersonas.ts
CHANGED
|
@@ -1,68 +1,64 @@
|
|
| 1 |
import type { Persona } from "$lib/types/Persona";
|
| 2 |
|
| 3 |
export const DEFAULT_PERSONAS: Omit<Persona, "createdAt" | "updatedAt">[] = [
|
| 4 |
-
{
|
| 5 |
-
id: "default-persona",
|
| 6 |
-
name: "Default",
|
| 7 |
-
occupation: "Default Role",
|
| 8 |
-
stance: "No Stance",
|
| 9 |
-
prompt: "",
|
| 10 |
-
isDefault: true,
|
| 11 |
-
},
|
| 12 |
{
|
| 13 |
id: "dr-robert-zane",
|
| 14 |
name: "Dr. Robert Zane",
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
| 24 |
isDefault: true,
|
| 25 |
},
|
| 26 |
{
|
| 27 |
id: "mayor-david-chen",
|
| 28 |
name: "Mayor David Chen",
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
| 38 |
isDefault: true,
|
| 39 |
},
|
| 40 |
{
|
| 41 |
id: "dr-evelyn-reed",
|
| 42 |
name: "Dr. Evelyn Reed",
|
| 43 |
-
|
|
|
|
|
|
|
| 44 |
stance: "Status Quo (Hardline Insurance Advocate)",
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
Goal in a Debate: To consistently defend and champion the private health insurance model. You will challenge opponents to provide evidence that a government-run system can be as efficient, innovative, or consumer-centric as the private market. You will aim to frame the debate as a choice between a dynamic, consumer-driven system and a stagnant, bureaucratic government program.`,
|
| 52 |
isDefault: true,
|
| 53 |
},
|
| 54 |
{
|
| 55 |
id: "mr-ben-carter",
|
| 56 |
name: "Mr. Ben Carter",
|
| 57 |
-
|
|
|
|
|
|
|
| 58 |
stance: "Status Quo (Moderate Government Intervention)",
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
Goal in a Debate: To keep the discussion grounded in the everyday reality of patients and families, ensuring that the expert-level conversations don't lose sight of the people they are meant to serve. You will advocate for reforms that fix the most frustrating parts of the current system, like surprise billing and high deductibles, and support government programs that make care more accessible.`,
|
| 66 |
isDefault: true,
|
| 67 |
},
|
| 68 |
];
|
|
|
|
| 1 |
import type { Persona } from "$lib/types/Persona";
|
| 2 |
|
| 3 |
export const DEFAULT_PERSONAS: Omit<Persona, "createdAt" | "updatedAt">[] = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
{
|
| 5 |
id: "dr-robert-zane",
|
| 6 |
name: "Dr. Robert Zane",
|
| 7 |
+
age: "46-55",
|
| 8 |
+
gender: "Male",
|
| 9 |
+
jobSector: "Healthcare Policy Scholar/Academic",
|
| 10 |
+
stance: "In Favor of Medicare for All (M4A)",
|
| 11 |
+
communicationStyle: "Principled, Philosophical, and Technical",
|
| 12 |
+
goalInDebate:
|
| 13 |
+
"To establish M4A as the only truly equitable solution; to highlight how the current system harms the vulnerable",
|
| 14 |
+
incomeBracket: "Comfortable",
|
| 15 |
+
politicalLeanings: "Liberal/Progressive",
|
| 16 |
+
geographicContext: "Urban",
|
| 17 |
isDefault: true,
|
| 18 |
},
|
| 19 |
{
|
| 20 |
id: "mayor-david-chen",
|
| 21 |
name: "Mayor David Chen",
|
| 22 |
+
age: "46-55",
|
| 23 |
+
gender: "Male",
|
| 24 |
+
jobSector: "Community Leader/Elected Official",
|
| 25 |
+
stance: "In Favor of a Public Option (Mixed Public-Private System)",
|
| 26 |
+
communicationStyle: "Authoritative, Pragmatic, and Community-Focused",
|
| 27 |
+
goalInDebate:
|
| 28 |
+
"To position the Public Option as a moderate, effective, and politically viable path to universal care; to address affordability without massive systemic disruption",
|
| 29 |
+
incomeBracket: "Comfortable/High",
|
| 30 |
+
politicalLeanings: "Moderate/Centrist",
|
| 31 |
+
geographicContext: "Urban or Suburban",
|
| 32 |
isDefault: true,
|
| 33 |
},
|
| 34 |
{
|
| 35 |
id: "dr-evelyn-reed",
|
| 36 |
name: "Dr. Evelyn Reed",
|
| 37 |
+
age: "46-55",
|
| 38 |
+
gender: "Female",
|
| 39 |
+
jobSector: "Insurance Executive",
|
| 40 |
stance: "Status Quo (Hardline Insurance Advocate)",
|
| 41 |
+
communicationStyle: "Professional, Confident, and Technical/Jargon Use",
|
| 42 |
+
goalInDebate:
|
| 43 |
+
"To consistently defend and champion the private health insurance model; to frame alternatives as stagnant, bureaucratic, and inefficient",
|
| 44 |
+
incomeBracket: "High",
|
| 45 |
+
politicalLeanings: "Conservative/Libertarian",
|
| 46 |
+
geographicContext: "Urban/Suburban",
|
|
|
|
| 47 |
isDefault: true,
|
| 48 |
},
|
| 49 |
{
|
| 50 |
id: "mr-ben-carter",
|
| 51 |
name: "Mr. Ben Carter",
|
| 52 |
+
age: "36-45",
|
| 53 |
+
gender: "Male",
|
| 54 |
+
jobSector: "Teacher (Middle School)",
|
| 55 |
stance: "Status Quo (Moderate Government Intervention)",
|
| 56 |
+
communicationStyle: "Direct, Relatable, and Informal",
|
| 57 |
+
goalInDebate:
|
| 58 |
+
"To keep the discussion grounded in the everyday reality of patients and families; advocate for reforms like fixing surprise billing and high deductibles",
|
| 59 |
+
incomeBracket: "Middle",
|
| 60 |
+
politicalLeanings: "Moderate/Non-Affiliated",
|
| 61 |
+
geographicContext: "Suburban",
|
|
|
|
| 62 |
isDefault: true,
|
| 63 |
},
|
| 64 |
];
|
src/lib/server/textGeneration/multiPersona.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
type MessageUpdate,
|
| 3 |
+
MessageUpdateType,
|
| 4 |
+
MessageUpdateStatus,
|
| 5 |
+
PersonaUpdateType,
|
| 6 |
+
type MessagePersonaUpdate,
|
| 7 |
+
} from "$lib/types/MessageUpdate";
|
| 8 |
+
import { generate } from "./generate";
|
| 9 |
+
import { mergeAsyncGenerators } from "$lib/utils/mergeAsyncGenerators";
|
| 10 |
+
import type { TextGenerationContext } from "./types";
|
| 11 |
+
import { type Persona, generatePersonaPrompt } from "$lib/types/Persona";
|
| 12 |
+
import { logger } from "$lib/server/logger";
|
| 13 |
+
import { preprocessMessages } from "../endpoints/preprocessMessages";
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* Generate responses from multiple personas in parallel
|
| 17 |
+
* Each persona gets its own text generation stream
|
| 18 |
+
* Updates are tagged with personaId so the client can route them correctly
|
| 19 |
+
*/
|
| 20 |
+
export async function* multiPersonaTextGeneration(
|
| 21 |
+
ctx: TextGenerationContext,
|
| 22 |
+
personas: Persona[]
|
| 23 |
+
): AsyncGenerator<MessageUpdate, undefined, undefined> {
|
| 24 |
+
if (personas.length === 0) {
|
| 25 |
+
logger.error("multiPersonaTextGeneration called with no personas");
|
| 26 |
+
yield {
|
| 27 |
+
type: MessageUpdateType.Status,
|
| 28 |
+
status: MessageUpdateStatus.Error,
|
| 29 |
+
message: "No personas provided",
|
| 30 |
+
};
|
| 31 |
+
return;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
if (personas.length === 1) {
|
| 35 |
+
// If only one persona, use the standard text generation
|
| 36 |
+
// (this shouldn't happen, but handle it gracefully)
|
| 37 |
+
logger.warn("multiPersonaTextGeneration called with single persona, using standard flow");
|
| 38 |
+
const { textGeneration } = await import("./index");
|
| 39 |
+
yield* textGeneration(ctx);
|
| 40 |
+
return;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const { conv, messages } = ctx;
|
| 44 |
+
const convId = conv._id;
|
| 45 |
+
|
| 46 |
+
// Notify start
|
| 47 |
+
yield {
|
| 48 |
+
type: MessageUpdateType.Status,
|
| 49 |
+
status: MessageUpdateStatus.Started,
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
// Preprocess messages ONCE for all personas (performance optimization)
|
| 53 |
+
// This downloads files and prepares messages for the model
|
| 54 |
+
const preprocessedMessages = await preprocessMessages(messages, convId);
|
| 55 |
+
|
| 56 |
+
// Create a generator for each persona with preprocessed messages
|
| 57 |
+
const personaGenerators = personas.map((persona) =>
|
| 58 |
+
createPersonaGenerator(ctx, persona, preprocessedMessages)
|
| 59 |
+
);
|
| 60 |
+
|
| 61 |
+
// Merge all generators and stream their updates
|
| 62 |
+
yield* mergeAsyncGenerators(personaGenerators);
|
| 63 |
+
|
| 64 |
+
// All done
|
| 65 |
+
yield {
|
| 66 |
+
type: MessageUpdateType.Status,
|
| 67 |
+
status: MessageUpdateStatus.Finished,
|
| 68 |
+
};
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/**
|
| 72 |
+
* Create a text generation stream for a single persona
|
| 73 |
+
* Wraps all updates with persona metadata
|
| 74 |
+
* Each persona sees ALL previous messages (user + all persona responses from past turns)
|
| 75 |
+
*/
|
| 76 |
+
async function* createPersonaGenerator(
|
| 77 |
+
ctx: TextGenerationContext,
|
| 78 |
+
persona: Persona,
|
| 79 |
+
preprocessedMessages: Awaited<ReturnType<typeof preprocessMessages>>
|
| 80 |
+
): AsyncGenerator<MessageUpdate, undefined, undefined> {
|
| 81 |
+
try {
|
| 82 |
+
// Messages are already preprocessed and filtered (system messages removed) from the caller
|
| 83 |
+
// Each persona sees ALL previous messages (user + all persona responses)
|
| 84 |
+
// This allows personas to build on each other's responses from previous turns
|
| 85 |
+
|
| 86 |
+
// Generate the persona's prompt from their fields
|
| 87 |
+
const preprompt = generatePersonaPrompt(persona);
|
| 88 |
+
|
| 89 |
+
// Generate text for this persona using preprocessed messages
|
| 90 |
+
// Type assertion is safe here because we know messages are preprocessed
|
| 91 |
+
const generateCtx = { ...ctx, messages: preprocessedMessages };
|
| 92 |
+
for await (const update of generate(generateCtx, preprompt)) {
|
| 93 |
+
// Wrap each update with persona information
|
| 94 |
+
yield wrapWithPersona(update, persona);
|
| 95 |
+
}
|
| 96 |
+
} catch (error) {
|
| 97 |
+
logger.error({ error, personaId: persona.id }, "Error in persona text generation");
|
| 98 |
+
yield {
|
| 99 |
+
type: MessageUpdateType.Persona,
|
| 100 |
+
personaId: persona.id,
|
| 101 |
+
personaName: persona.name,
|
| 102 |
+
personaOccupation: persona.jobSector,
|
| 103 |
+
personaStance: persona.stance,
|
| 104 |
+
updateType: PersonaUpdateType.Status,
|
| 105 |
+
error: error instanceof Error ? error.message : "Unknown error",
|
| 106 |
+
} as MessagePersonaUpdate;
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
/**
|
| 111 |
+
* Wraps a standard MessageUpdate with persona metadata
|
| 112 |
+
*/
|
| 113 |
+
function wrapWithPersona(update: MessageUpdate, persona: Persona): MessageUpdate {
|
| 114 |
+
// Handle different update types and wrap them as persona updates
|
| 115 |
+
if (update.type === MessageUpdateType.Stream) {
|
| 116 |
+
return {
|
| 117 |
+
type: MessageUpdateType.Persona,
|
| 118 |
+
personaId: persona.id,
|
| 119 |
+
personaName: persona.name,
|
| 120 |
+
personaOccupation: persona.jobSector,
|
| 121 |
+
personaStance: persona.stance,
|
| 122 |
+
updateType: PersonaUpdateType.Stream,
|
| 123 |
+
token: update.token,
|
| 124 |
+
} as MessagePersonaUpdate;
|
| 125 |
+
} else if (update.type === MessageUpdateType.FinalAnswer) {
|
| 126 |
+
return {
|
| 127 |
+
type: MessageUpdateType.Persona,
|
| 128 |
+
personaId: persona.id,
|
| 129 |
+
personaName: persona.name,
|
| 130 |
+
personaOccupation: persona.jobSector,
|
| 131 |
+
personaStance: persona.stance,
|
| 132 |
+
updateType: PersonaUpdateType.FinalAnswer,
|
| 133 |
+
text: update.text,
|
| 134 |
+
interrupted: update.interrupted,
|
| 135 |
+
} as MessagePersonaUpdate;
|
| 136 |
+
} else if (update.type === MessageUpdateType.Reasoning) {
|
| 137 |
+
return {
|
| 138 |
+
type: MessageUpdateType.Persona,
|
| 139 |
+
personaId: persona.id,
|
| 140 |
+
personaName: persona.name,
|
| 141 |
+
personaOccupation: persona.jobSector,
|
| 142 |
+
personaStance: persona.stance,
|
| 143 |
+
updateType: PersonaUpdateType.Reasoning,
|
| 144 |
+
status: update.subtype === "status" ? update.status : undefined,
|
| 145 |
+
token: update.subtype === "stream" ? update.token : undefined,
|
| 146 |
+
} as MessagePersonaUpdate;
|
| 147 |
+
} else if (update.type === MessageUpdateType.RouterMetadata) {
|
| 148 |
+
return {
|
| 149 |
+
type: MessageUpdateType.Persona,
|
| 150 |
+
personaId: persona.id,
|
| 151 |
+
personaName: persona.name,
|
| 152 |
+
personaOccupation: persona.jobSector,
|
| 153 |
+
personaStance: persona.stance,
|
| 154 |
+
updateType: PersonaUpdateType.RouterMetadata,
|
| 155 |
+
route: update.route,
|
| 156 |
+
model: update.model,
|
| 157 |
+
} as MessagePersonaUpdate;
|
| 158 |
+
} else if (update.type === MessageUpdateType.Status) {
|
| 159 |
+
// Only pass through error status for individual personas
|
| 160 |
+
if (update.status === MessageUpdateStatus.Error) {
|
| 161 |
+
return {
|
| 162 |
+
type: MessageUpdateType.Persona,
|
| 163 |
+
personaId: persona.id,
|
| 164 |
+
personaName: persona.name,
|
| 165 |
+
personaOccupation: persona.jobSector,
|
| 166 |
+
personaStance: persona.stance,
|
| 167 |
+
updateType: PersonaUpdateType.Status,
|
| 168 |
+
error: update.message,
|
| 169 |
+
} as MessagePersonaUpdate;
|
| 170 |
+
}
|
| 171 |
+
// Filter out other status updates (started, finished, keep-alive)
|
| 172 |
+
// since we handle those at the multi-persona level
|
| 173 |
+
return update;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
// For other update types (Title, File), pass through as-is
|
| 177 |
+
// These are conversation-level, not persona-specific
|
| 178 |
+
return update;
|
| 179 |
+
}
|
src/lib/stores/settings.ts
CHANGED
|
@@ -12,7 +12,7 @@ type SettingsStore = {
|
|
| 12 |
welcomeModalSeen: boolean;
|
| 13 |
welcomeModalSeenAt: Date | null;
|
| 14 |
activeModel: string;
|
| 15 |
-
|
| 16 |
personas: Persona[];
|
| 17 |
multimodalOverrides: Record<string, boolean>;
|
| 18 |
recentlySaved: boolean;
|
|
|
|
| 12 |
welcomeModalSeen: boolean;
|
| 13 |
welcomeModalSeenAt: Date | null;
|
| 14 |
activeModel: string;
|
| 15 |
+
activePersonas: string[];
|
| 16 |
personas: Persona[];
|
| 17 |
multimodalOverrides: Record<string, boolean>;
|
| 18 |
recentlySaved: boolean;
|
src/lib/types/Message.ts
CHANGED
|
@@ -2,6 +2,24 @@ import type { MessageUpdate } from "./MessageUpdate";
|
|
| 2 |
import type { Timestamps } from "./Timestamps";
|
| 3 |
import type { v4 } from "uuid";
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
export type Message = Partial<Timestamps> & {
|
| 6 |
from: "user" | "assistant" | "system";
|
| 7 |
id: ReturnType<typeof v4>;
|
|
@@ -23,6 +41,9 @@ export type Message = Partial<Timestamps> & {
|
|
| 23 |
model: string;
|
| 24 |
};
|
| 25 |
|
|
|
|
|
|
|
|
|
|
| 26 |
// needed for conversation trees
|
| 27 |
ancestors?: Message["id"][];
|
| 28 |
|
|
|
|
| 2 |
import type { Timestamps } from "./Timestamps";
|
| 3 |
import type { v4 } from "uuid";
|
| 4 |
|
| 5 |
+
export type PersonaResponse = {
|
| 6 |
+
personaId: string;
|
| 7 |
+
personaName: string;
|
| 8 |
+
personaOccupation?: string;
|
| 9 |
+
personaStance?: string;
|
| 10 |
+
content: string;
|
| 11 |
+
reasoning?: string;
|
| 12 |
+
updates?: MessageUpdate[];
|
| 13 |
+
interrupted?: boolean;
|
| 14 |
+
routerMetadata?: {
|
| 15 |
+
route: string;
|
| 16 |
+
model: string;
|
| 17 |
+
};
|
| 18 |
+
// Track alternative versions of this persona's response
|
| 19 |
+
children?: PersonaResponse[];
|
| 20 |
+
currentChildIndex?: number;
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
export type Message = Partial<Timestamps> & {
|
| 24 |
from: "user" | "assistant" | "system";
|
| 25 |
id: ReturnType<typeof v4>;
|
|
|
|
| 41 |
model: string;
|
| 42 |
};
|
| 43 |
|
| 44 |
+
// Multi-persona responses (when multiple personas are active)
|
| 45 |
+
personaResponses?: PersonaResponse[];
|
| 46 |
+
|
| 47 |
// needed for conversation trees
|
| 48 |
ancestors?: Message["id"][];
|
| 49 |
|
src/lib/types/MessageUpdate.ts
CHANGED
|
@@ -5,7 +5,8 @@ export type MessageUpdate =
|
|
| 5 |
| MessageFileUpdate
|
| 6 |
| MessageFinalAnswerUpdate
|
| 7 |
| MessageReasoningUpdate
|
| 8 |
-
| MessageRouterMetadataUpdate
|
|
|
|
| 9 |
|
| 10 |
export enum MessageUpdateType {
|
| 11 |
Status = "status",
|
|
@@ -15,6 +16,7 @@ export enum MessageUpdateType {
|
|
| 15 |
FinalAnswer = "finalAnswer",
|
| 16 |
Reasoning = "reasoning",
|
| 17 |
RouterMetadata = "routerMetadata",
|
|
|
|
| 18 |
}
|
| 19 |
|
| 20 |
// Status
|
|
@@ -74,3 +76,28 @@ export interface MessageRouterMetadataUpdate {
|
|
| 74 |
route: string;
|
| 75 |
model: string;
|
| 76 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
| MessageFileUpdate
|
| 6 |
| MessageFinalAnswerUpdate
|
| 7 |
| MessageReasoningUpdate
|
| 8 |
+
| MessageRouterMetadataUpdate
|
| 9 |
+
| MessagePersonaUpdate;
|
| 10 |
|
| 11 |
export enum MessageUpdateType {
|
| 12 |
Status = "status",
|
|
|
|
| 16 |
FinalAnswer = "finalAnswer",
|
| 17 |
Reasoning = "reasoning",
|
| 18 |
RouterMetadata = "routerMetadata",
|
| 19 |
+
Persona = "persona",
|
| 20 |
}
|
| 21 |
|
| 22 |
// Status
|
|
|
|
| 76 |
route: string;
|
| 77 |
model: string;
|
| 78 |
}
|
| 79 |
+
|
| 80 |
+
// Multi-persona updates
|
| 81 |
+
export enum PersonaUpdateType {
|
| 82 |
+
Stream = "stream",
|
| 83 |
+
Reasoning = "reasoning",
|
| 84 |
+
RouterMetadata = "routerMetadata",
|
| 85 |
+
FinalAnswer = "finalAnswer",
|
| 86 |
+
Status = "status",
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
export interface MessagePersonaUpdate {
|
| 90 |
+
type: MessageUpdateType.Persona;
|
| 91 |
+
personaId: string;
|
| 92 |
+
personaName: string;
|
| 93 |
+
personaOccupation?: string;
|
| 94 |
+
personaStance?: string;
|
| 95 |
+
updateType: PersonaUpdateType;
|
| 96 |
+
token?: string; // for stream updates
|
| 97 |
+
text?: string; // for final answer
|
| 98 |
+
interrupted?: boolean;
|
| 99 |
+
status?: string; // for reasoning status
|
| 100 |
+
route?: string; // for router metadata
|
| 101 |
+
model?: string; // for router metadata
|
| 102 |
+
error?: string; // for error status
|
| 103 |
+
}
|
src/lib/types/Persona.ts
CHANGED
|
@@ -1,10 +1,37 @@
|
|
| 1 |
export interface Persona {
|
| 2 |
id: string; // UUID
|
| 3 |
name: string;
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
isDefault: boolean; // True for built-in personas
|
| 8 |
createdAt: Date;
|
| 9 |
updatedAt: Date;
|
| 10 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
export interface Persona {
|
| 2 |
id: string; // UUID
|
| 3 |
name: string;
|
| 4 |
+
age: string; // Required: age range or custom value
|
| 5 |
+
gender: string; // Required: gender identity
|
| 6 |
+
jobSector?: string; // Optional: professional sector
|
| 7 |
+
stance?: string; // Optional: debate stance
|
| 8 |
+
communicationStyle?: string; // Optional: how they communicate
|
| 9 |
+
goalInDebate?: string; // Optional: objective in discussions
|
| 10 |
+
incomeBracket?: string; // Optional: socioeconomic status
|
| 11 |
+
politicalLeanings?: string; // Optional: political orientation
|
| 12 |
+
geographicContext?: string; // Optional: geographic setting
|
| 13 |
isDefault: boolean; // True for built-in personas
|
| 14 |
createdAt: Date;
|
| 15 |
updatedAt: Date;
|
| 16 |
}
|
| 17 |
+
|
| 18 |
+
// Helper function to generate system prompt from persona fields
|
| 19 |
+
export function generatePersonaPrompt(persona: Persona): string {
|
| 20 |
+
const fields: Array<[string, string | undefined]> = [
|
| 21 |
+
["Name", persona.name],
|
| 22 |
+
["Age", persona.age],
|
| 23 |
+
["Gender", persona.gender],
|
| 24 |
+
["Job Sector", persona.jobSector],
|
| 25 |
+
["Stance", persona.stance],
|
| 26 |
+
["Communication Style", persona.communicationStyle],
|
| 27 |
+
["Goal in the Debate", persona.goalInDebate],
|
| 28 |
+
["Income Bracket", persona.incomeBracket],
|
| 29 |
+
["Political Leanings", persona.politicalLeanings],
|
| 30 |
+
["Geographic Context", persona.geographicContext],
|
| 31 |
+
];
|
| 32 |
+
|
| 33 |
+
return fields
|
| 34 |
+
.filter(([_, value]) => value && value.trim() !== "")
|
| 35 |
+
.map(([field, value]) => `${field}: ${value}`)
|
| 36 |
+
.join("\n\n");
|
| 37 |
+
}
|
src/lib/types/Settings.ts
CHANGED
|
@@ -13,8 +13,8 @@ export interface Settings extends Timestamps {
|
|
| 13 |
welcomeModalSeenAt?: Date | null;
|
| 14 |
activeModel: string;
|
| 15 |
|
| 16 |
-
// Active
|
| 17 |
-
|
| 18 |
personas: Persona[]; // User's custom personas + edited defaults
|
| 19 |
|
| 20 |
/**
|
|
@@ -39,7 +39,7 @@ export type SettingsEditable = Omit<Settings, "welcomeModalSeenAt" | "createdAt"
|
|
| 39 |
export const DEFAULT_SETTINGS = {
|
| 40 |
shareConversationsWithModelAuthors: true,
|
| 41 |
activeModel: defaultModel.id,
|
| 42 |
-
|
| 43 |
personas: DEFAULT_PERSONAS.map((p) => ({
|
| 44 |
...p,
|
| 45 |
createdAt: new Date(),
|
|
|
|
| 13 |
welcomeModalSeenAt?: Date | null;
|
| 14 |
activeModel: string;
|
| 15 |
|
| 16 |
+
// Active personas and user's custom personas
|
| 17 |
+
activePersonas: string[]; // Persona IDs
|
| 18 |
personas: Persona[]; // User's custom personas + edited defaults
|
| 19 |
|
| 20 |
/**
|
|
|
|
| 39 |
export const DEFAULT_SETTINGS = {
|
| 40 |
shareConversationsWithModelAuthors: true,
|
| 41 |
activeModel: defaultModel.id,
|
| 42 |
+
activePersonas: ["dr-robert-zane", "mayor-david-chen"], // Default personas (can have multiple)
|
| 43 |
personas: DEFAULT_PERSONAS.map((p) => ({
|
| 44 |
...p,
|
| 45 |
createdAt: new Date(),
|
src/lib/utils/messageUpdates.ts
CHANGED
|
@@ -14,6 +14,7 @@ type MessageUpdateRequestOptions = {
|
|
| 14 |
isRetry: boolean;
|
| 15 |
isContinue: boolean;
|
| 16 |
files?: MessageFile[];
|
|
|
|
| 17 |
};
|
| 18 |
export async function fetchMessageUpdates(
|
| 19 |
conversationId: string,
|
|
@@ -30,6 +31,7 @@ export async function fetchMessageUpdates(
|
|
| 30 |
id: opts.messageId,
|
| 31 |
is_retry: opts.isRetry,
|
| 32 |
is_continue: opts.isContinue,
|
|
|
|
| 33 |
});
|
| 34 |
|
| 35 |
opts.files?.forEach((file) => {
|
|
|
|
| 14 |
isRetry: boolean;
|
| 15 |
isContinue: boolean;
|
| 16 |
files?: MessageFile[];
|
| 17 |
+
personaId?: string; // Optional: specific persona to regenerate
|
| 18 |
};
|
| 19 |
export async function fetchMessageUpdates(
|
| 20 |
conversationId: string,
|
|
|
|
| 31 |
id: opts.messageId,
|
| 32 |
is_retry: opts.isRetry,
|
| 33 |
is_continue: opts.isContinue,
|
| 34 |
+
persona_id: opts.personaId,
|
| 35 |
});
|
| 36 |
|
| 37 |
opts.files?.forEach((file) => {
|
src/routes/api/conversation/[id]/+server.ts
CHANGED
|
@@ -19,6 +19,7 @@ export async function GET({ locals, params }) {
|
|
| 19 |
title: conv.title,
|
| 20 |
updatedAt: conv.updatedAt,
|
| 21 |
modelId: conv.model,
|
|
|
|
| 22 |
messages: conv.messages.map((message) => ({
|
| 23 |
content: message.content,
|
| 24 |
from: message.from,
|
|
@@ -29,6 +30,7 @@ export async function GET({ locals, params }) {
|
|
| 29 |
files: message.files,
|
| 30 |
updates: message.updates,
|
| 31 |
reasoning: message.reasoning,
|
|
|
|
| 32 |
})),
|
| 33 |
};
|
| 34 |
return Response.json(res);
|
|
|
|
| 19 |
title: conv.title,
|
| 20 |
updatedAt: conv.updatedAt,
|
| 21 |
modelId: conv.model,
|
| 22 |
+
personaId: conv.personaId,
|
| 23 |
messages: conv.messages.map((message) => ({
|
| 24 |
content: message.content,
|
| 25 |
from: message.from,
|
|
|
|
| 30 |
files: message.files,
|
| 31 |
updates: message.updates,
|
| 32 |
reasoning: message.reasoning,
|
| 33 |
+
personaResponses: message.personaResponses,
|
| 34 |
})),
|
| 35 |
};
|
| 36 |
return Response.json(res);
|
src/routes/conversation/+server.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { models, validateModel } from "$lib/server/models";
|
|
| 9 |
import { v4 } from "uuid";
|
| 10 |
import { authCondition } from "$lib/server/auth";
|
| 11 |
import { usageLimits } from "$lib/server/usageLimits";
|
|
|
|
| 12 |
|
| 13 |
export const POST: RequestHandler = async ({ locals, request }) => {
|
| 14 |
const body = await request.text();
|
|
@@ -75,13 +76,15 @@ export const POST: RequestHandler = async ({ locals, request }) => {
|
|
| 75 |
error(400, "Can't start a conversation with an unlisted model");
|
| 76 |
}
|
| 77 |
|
| 78 |
-
// Get user settings to retrieve active
|
| 79 |
const userSettings = await collections.settings.findOne(authCondition(locals));
|
| 80 |
-
const activePersonaId = userSettings?.
|
| 81 |
const activePersona = userSettings?.personas?.find((p) => p.id === activePersonaId);
|
| 82 |
|
| 83 |
// Use persona prompt as preprompt, fall back to model preprompt or provided preprompt
|
| 84 |
-
values.preprompt = activePersona
|
|
|
|
|
|
|
| 85 |
|
| 86 |
if (messages && messages.length > 0 && messages[0].from === "system") {
|
| 87 |
messages[0].content = values.preprompt;
|
|
|
|
| 9 |
import { v4 } from "uuid";
|
| 10 |
import { authCondition } from "$lib/server/auth";
|
| 11 |
import { usageLimits } from "$lib/server/usageLimits";
|
| 12 |
+
import { generatePersonaPrompt } from "$lib/types/Persona";
|
| 13 |
|
| 14 |
export const POST: RequestHandler = async ({ locals, request }) => {
|
| 15 |
const body = await request.text();
|
|
|
|
| 76 |
error(400, "Can't start a conversation with an unlisted model");
|
| 77 |
}
|
| 78 |
|
| 79 |
+
// Get user settings to retrieve active personas (use first one for conversation)
|
| 80 |
const userSettings = await collections.settings.findOne(authCondition(locals));
|
| 81 |
+
const activePersonaId = userSettings?.activePersonas?.[0] ?? "default-neutral";
|
| 82 |
const activePersona = userSettings?.personas?.find((p) => p.id === activePersonaId);
|
| 83 |
|
| 84 |
// Use persona prompt as preprompt, fall back to model preprompt or provided preprompt
|
| 85 |
+
values.preprompt = activePersona
|
| 86 |
+
? generatePersonaPrompt(activePersona)
|
| 87 |
+
: (values.preprompt ?? model?.preprompt ?? "");
|
| 88 |
|
| 89 |
if (messages && messages.length > 0 && messages[0].from === "system") {
|
| 90 |
messages[0].content = values.preprompt;
|
src/routes/conversation/[id]/+page.svelte
CHANGED
|
@@ -132,11 +132,13 @@
|
|
| 132 |
messageId = messagesPath.at(-1)?.id ?? undefined,
|
| 133 |
isRetry = false,
|
| 134 |
isContinue = false,
|
|
|
|
| 135 |
}: {
|
| 136 |
prompt?: string;
|
| 137 |
messageId?: ReturnType<typeof v4>;
|
| 138 |
isRetry?: boolean;
|
| 139 |
isContinue?: boolean;
|
|
|
|
| 140 |
}): Promise<void> {
|
| 141 |
try {
|
| 142 |
$isAborted = false;
|
|
@@ -259,6 +261,7 @@
|
|
| 259 |
isRetry,
|
| 260 |
isContinue,
|
| 261 |
files: isRetry ? userMessage?.files : base64Files,
|
|
|
|
| 262 |
},
|
| 263 |
messageUpdatesAbortController.signal
|
| 264 |
).catch((err) => {
|
|
@@ -266,90 +269,148 @@
|
|
| 266 |
});
|
| 267 |
if (messageUpdatesIterator === undefined) return;
|
| 268 |
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
|
| 274 |
-
|
| 275 |
-
|
| 276 |
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
return;
|
| 281 |
-
}
|
| 282 |
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
|
| 296 |
-
|
| 297 |
-
|
|
|
|
|
|
|
|
|
|
| 298 |
}
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
}
|
| 309 |
pending = false;
|
| 310 |
-
} else if (
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
) {
|
| 314 |
-
|
| 315 |
-
} else if (update.type === MessageUpdateType.Title) {
|
| 316 |
-
const convInData = conversations.find(({ id }) => id === page.params.id);
|
| 317 |
-
if (convInData) {
|
| 318 |
-
convInData.title = update.title;
|
| 319 |
-
|
| 320 |
-
$titleUpdate = {
|
| 321 |
-
title: update.title,
|
| 322 |
-
convId: page.params.id,
|
| 323 |
-
};
|
| 324 |
-
}
|
| 325 |
-
} else if (update.type === MessageUpdateType.File) {
|
| 326 |
-
messageToWriteTo.files = [
|
| 327 |
-
...(messageToWriteTo.files ?? []),
|
| 328 |
-
{ type: "hash", value: update.sha, mime: update.mime, name: update.name },
|
| 329 |
-
];
|
| 330 |
-
} else if (update.type === MessageUpdateType.Reasoning) {
|
| 331 |
-
if (!messageToWriteTo.reasoning) {
|
| 332 |
-
messageToWriteTo.reasoning = "";
|
| 333 |
-
}
|
| 334 |
-
if (update.subtype === MessageReasoningUpdateType.Stream) {
|
| 335 |
-
reasoningBuffer += update.token;
|
| 336 |
-
if (
|
| 337 |
-
currentTime.getTime() - reasoningLastUpdate.getTime() >
|
| 338 |
-
updateDebouncer.maxUpdateTime
|
| 339 |
-
) {
|
| 340 |
-
messageToWriteTo.reasoning += reasoningBuffer;
|
| 341 |
-
reasoningBuffer = "";
|
| 342 |
-
reasoningLastUpdate = currentTime;
|
| 343 |
-
}
|
| 344 |
-
}
|
| 345 |
-
} else if (update.type === MessageUpdateType.RouterMetadata) {
|
| 346 |
-
// Update router metadata immediately when received
|
| 347 |
-
messageToWriteTo.routerMetadata = {
|
| 348 |
route: update.route,
|
| 349 |
model: update.model,
|
| 350 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
}
|
|
|
|
| 353 |
} catch (err) {
|
| 354 |
if (err instanceof Error && err.message.includes("overloaded")) {
|
| 355 |
$error = "Too much traffic, please try again.";
|
|
@@ -390,7 +451,7 @@
|
|
| 390 |
}
|
| 391 |
}
|
| 392 |
|
| 393 |
-
async function onRetry(payload: { id: Message["id"]; content?: string }) {
|
| 394 |
const lastMsgId = payload.id;
|
| 395 |
messagesPath = createMessagesPath(messages, lastMsgId);
|
| 396 |
|
|
@@ -399,6 +460,7 @@
|
|
| 399 |
prompt: payload.content,
|
| 400 |
messageId: payload.id,
|
| 401 |
isRetry: true,
|
|
|
|
| 402 |
});
|
| 403 |
} else {
|
| 404 |
await convFromShared()
|
|
@@ -411,6 +473,7 @@
|
|
| 411 |
prompt: payload.content,
|
| 412 |
messageId: payload.id,
|
| 413 |
isRetry: true,
|
|
|
|
| 414 |
})
|
| 415 |
)
|
| 416 |
.finally(() => (loading = false));
|
|
@@ -450,15 +513,40 @@
|
|
| 450 |
function isConversationStreaming(msgs: Message[]): boolean {
|
| 451 |
const lastAssistant = [...msgs].reverse().find((msg) => msg.from === "assistant");
|
| 452 |
if (!lastAssistant) return false;
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
false;
|
| 456 |
const hasError =
|
| 457 |
lastAssistant.updates?.some(
|
| 458 |
(update) =>
|
| 459 |
update.type === MessageUpdateType.Status && update.status === MessageUpdateStatus.Error
|
| 460 |
) ?? false;
|
| 461 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
}
|
| 463 |
|
| 464 |
$effect(() => {
|
|
@@ -524,6 +612,7 @@
|
|
| 524 |
{messagesAlternatives}
|
| 525 |
shared={data.shared}
|
| 526 |
preprompt={data.preprompt}
|
|
|
|
| 527 |
bind:files
|
| 528 |
onmessage={onMessage}
|
| 529 |
onretry={onRetry}
|
|
|
|
| 132 |
messageId = messagesPath.at(-1)?.id ?? undefined,
|
| 133 |
isRetry = false,
|
| 134 |
isContinue = false,
|
| 135 |
+
personaId,
|
| 136 |
}: {
|
| 137 |
prompt?: string;
|
| 138 |
messageId?: ReturnType<typeof v4>;
|
| 139 |
isRetry?: boolean;
|
| 140 |
isContinue?: boolean;
|
| 141 |
+
personaId?: string;
|
| 142 |
}): Promise<void> {
|
| 143 |
try {
|
| 144 |
$isAborted = false;
|
|
|
|
| 261 |
isRetry,
|
| 262 |
isContinue,
|
| 263 |
files: isRetry ? userMessage?.files : base64Files,
|
| 264 |
+
personaId,
|
| 265 |
},
|
| 266 |
messageUpdatesAbortController.signal
|
| 267 |
).catch((err) => {
|
|
|
|
| 269 |
});
|
| 270 |
if (messageUpdatesIterator === undefined) return;
|
| 271 |
|
| 272 |
+
files = [];
|
| 273 |
+
let buffer = "";
|
| 274 |
+
// Initialize lastUpdateTime outside the loop to persist between updates
|
| 275 |
+
let lastUpdateTime = new Date();
|
| 276 |
|
| 277 |
+
let reasoningBuffer = "";
|
| 278 |
+
let reasoningLastUpdate = new Date();
|
| 279 |
|
| 280 |
+
// Per-persona buffers for multi-persona mode
|
| 281 |
+
const personaBuffers = new Map<string, string>();
|
| 282 |
+
const personaLastUpdateTimes = new Map<string, Date>();
|
|
|
|
|
|
|
| 283 |
|
| 284 |
+
for await (const update of messageUpdatesIterator) {
|
| 285 |
+
if ($isAborted) {
|
| 286 |
+
messageUpdatesAbortController.abort();
|
| 287 |
+
return;
|
| 288 |
+
}
|
| 289 |
|
| 290 |
+
// Remove null characters added due to remote keylogging prevention
|
| 291 |
+
// See server code for more details
|
| 292 |
+
if (update.type === MessageUpdateType.Stream) {
|
| 293 |
+
update.token = update.token.replaceAll("\0", "");
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
const isHighFrequencyUpdate =
|
| 297 |
+
(update.type === MessageUpdateType.Reasoning &&
|
| 298 |
+
update.subtype === MessageReasoningUpdateType.Stream) ||
|
| 299 |
+
update.type === MessageUpdateType.Stream ||
|
| 300 |
+
update.type === MessageUpdateType.Persona ||
|
| 301 |
+
(update.type === MessageUpdateType.Status &&
|
| 302 |
+
update.status === MessageUpdateStatus.KeepAlive);
|
| 303 |
+
|
| 304 |
+
if (!isHighFrequencyUpdate) {
|
| 305 |
+
messageToWriteTo.updates = [...(messageToWriteTo.updates ?? []), update];
|
| 306 |
+
}
|
| 307 |
+
const currentTime = new Date();
|
| 308 |
|
| 309 |
+
// Handle multi-persona updates
|
| 310 |
+
if (update.type === MessageUpdateType.Persona) {
|
| 311 |
+
// Initialize personaResponses if not already present
|
| 312 |
+
if (!messageToWriteTo.personaResponses) {
|
| 313 |
+
messageToWriteTo.personaResponses = [];
|
| 314 |
}
|
| 315 |
+
|
| 316 |
+
// Find or create persona response
|
| 317 |
+
let personaResponse = messageToWriteTo.personaResponses.find(
|
| 318 |
+
(pr) => pr.personaId === update.personaId
|
| 319 |
+
);
|
| 320 |
+
if (!personaResponse) {
|
| 321 |
+
personaResponse = {
|
| 322 |
+
personaId: update.personaId,
|
| 323 |
+
personaName: update.personaName,
|
| 324 |
+
personaOccupation: update.personaOccupation,
|
| 325 |
+
personaStance: update.personaStance,
|
| 326 |
+
content: "",
|
| 327 |
+
};
|
| 328 |
+
messageToWriteTo.personaResponses.push(personaResponse);
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
// Handle different update types for this persona
|
| 332 |
+
if (update.updateType === "stream" && update.token && !$settings.disableStream) {
|
| 333 |
+
const personaBuffer = personaBuffers.get(update.personaId) || "";
|
| 334 |
+
const newBuffer = personaBuffer + update.token;
|
| 335 |
+
personaBuffers.set(update.personaId, newBuffer);
|
| 336 |
+
|
| 337 |
+
const lastUpdate = personaLastUpdateTimes.get(update.personaId) || new Date(0);
|
| 338 |
+
if (currentTime.getTime() - lastUpdate.getTime() > updateDebouncer.maxUpdateTime) {
|
| 339 |
+
personaResponse.content += newBuffer;
|
| 340 |
+
personaBuffers.set(update.personaId, "");
|
| 341 |
+
personaLastUpdateTimes.set(update.personaId, currentTime);
|
| 342 |
}
|
| 343 |
pending = false;
|
| 344 |
+
} else if (update.updateType === "finalAnswer" && update.text) {
|
| 345 |
+
personaResponse.content = update.text;
|
| 346 |
+
personaResponse.interrupted = update.interrupted;
|
| 347 |
+
} else if (update.updateType === "routerMetadata" && update.route && update.model) {
|
| 348 |
+
personaResponse.routerMetadata = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
route: update.route,
|
| 350 |
model: update.model,
|
| 351 |
};
|
| 352 |
+
} else if (update.updateType === "status" && update.error) {
|
| 353 |
+
// Handle persona errors - mark as interrupted so loading can stop
|
| 354 |
+
personaResponse.interrupted = true;
|
| 355 |
+
personaResponse.content = personaResponse.content || `Error: ${update.error}`;
|
| 356 |
}
|
| 357 |
+
} else if (update.type === MessageUpdateType.Stream && !$settings.disableStream) {
|
| 358 |
+
buffer += update.token;
|
| 359 |
+
// Check if this is the first update or if enough time has passed
|
| 360 |
+
if (currentTime.getTime() - lastUpdateTime.getTime() > updateDebouncer.maxUpdateTime) {
|
| 361 |
+
messageToWriteTo.content += buffer;
|
| 362 |
+
buffer = "";
|
| 363 |
+
lastUpdateTime = currentTime;
|
| 364 |
+
}
|
| 365 |
+
pending = false;
|
| 366 |
+
} else if (
|
| 367 |
+
update.type === MessageUpdateType.Status &&
|
| 368 |
+
update.status === MessageUpdateStatus.Error
|
| 369 |
+
) {
|
| 370 |
+
$error = update.message ?? "An error has occurred";
|
| 371 |
+
} else if (update.type === MessageUpdateType.Title) {
|
| 372 |
+
const convInData = conversations.find(({ id }) => id === page.params.id);
|
| 373 |
+
if (convInData) {
|
| 374 |
+
convInData.title = update.title;
|
| 375 |
+
|
| 376 |
+
$titleUpdate = {
|
| 377 |
+
title: update.title,
|
| 378 |
+
convId: page.params.id,
|
| 379 |
+
};
|
| 380 |
+
}
|
| 381 |
+
} else if (update.type === MessageUpdateType.File) {
|
| 382 |
+
messageToWriteTo.files = [
|
| 383 |
+
...(messageToWriteTo.files ?? []),
|
| 384 |
+
{ type: "hash", value: update.sha, mime: update.mime, name: update.name },
|
| 385 |
+
];
|
| 386 |
+
} else if (update.type === MessageUpdateType.Reasoning) {
|
| 387 |
+
if (!messageToWriteTo.reasoning) {
|
| 388 |
+
messageToWriteTo.reasoning = "";
|
| 389 |
+
}
|
| 390 |
+
if (update.subtype === MessageReasoningUpdateType.Stream) {
|
| 391 |
+
reasoningBuffer += update.token;
|
| 392 |
+
if (
|
| 393 |
+
currentTime.getTime() - reasoningLastUpdate.getTime() >
|
| 394 |
+
updateDebouncer.maxUpdateTime
|
| 395 |
+
) {
|
| 396 |
+
messageToWriteTo.reasoning += reasoningBuffer;
|
| 397 |
+
reasoningBuffer = "";
|
| 398 |
+
reasoningLastUpdate = currentTime;
|
| 399 |
+
}
|
| 400 |
+
}
|
| 401 |
+
} else if (update.type === MessageUpdateType.RouterMetadata) {
|
| 402 |
+
// Update router metadata immediately when received
|
| 403 |
+
messageToWriteTo.routerMetadata = {
|
| 404 |
+
route: update.route,
|
| 405 |
+
model: update.model,
|
| 406 |
+
};
|
| 407 |
+
} else if (update.type === MessageUpdateType.FinalAnswer) {
|
| 408 |
+
// Handle final answer - set authoritative final content from server
|
| 409 |
+
messageToWriteTo.content = update.text;
|
| 410 |
+
messageToWriteTo.interrupted = update.interrupted;
|
| 411 |
+
pending = false;
|
| 412 |
}
|
| 413 |
+
}
|
| 414 |
} catch (err) {
|
| 415 |
if (err instanceof Error && err.message.includes("overloaded")) {
|
| 416 |
$error = "Too much traffic, please try again.";
|
|
|
|
| 451 |
}
|
| 452 |
}
|
| 453 |
|
| 454 |
+
async function onRetry(payload: { id: Message["id"]; content?: string; personaId?: string }) {
|
| 455 |
const lastMsgId = payload.id;
|
| 456 |
messagesPath = createMessagesPath(messages, lastMsgId);
|
| 457 |
|
|
|
|
| 460 |
prompt: payload.content,
|
| 461 |
messageId: payload.id,
|
| 462 |
isRetry: true,
|
| 463 |
+
personaId: payload.personaId,
|
| 464 |
});
|
| 465 |
} else {
|
| 466 |
await convFromShared()
|
|
|
|
| 473 |
prompt: payload.content,
|
| 474 |
messageId: payload.id,
|
| 475 |
isRetry: true,
|
| 476 |
+
personaId: payload.personaId,
|
| 477 |
})
|
| 478 |
)
|
| 479 |
.finally(() => (loading = false));
|
|
|
|
| 513 |
function isConversationStreaming(msgs: Message[]): boolean {
|
| 514 |
const lastAssistant = [...msgs].reverse().find((msg) => msg.from === "assistant");
|
| 515 |
if (!lastAssistant) return false;
|
| 516 |
+
|
| 517 |
+
// Check for errors
|
|
|
|
| 518 |
const hasError =
|
| 519 |
lastAssistant.updates?.some(
|
| 520 |
(update) =>
|
| 521 |
update.type === MessageUpdateType.Status && update.status === MessageUpdateStatus.Error
|
| 522 |
) ?? false;
|
| 523 |
+
if (hasError) return false;
|
| 524 |
+
|
| 525 |
+
// Check if multi-persona mode
|
| 526 |
+
if (lastAssistant.personaResponses && lastAssistant.personaResponses.length > 0) {
|
| 527 |
+
// Check if we have a Finished status (sent when all personas complete)
|
| 528 |
+
const hasFinished =
|
| 529 |
+
lastAssistant.updates?.some(
|
| 530 |
+
(update) =>
|
| 531 |
+
update.type === MessageUpdateType.Status &&
|
| 532 |
+
update.status === MessageUpdateStatus.Finished
|
| 533 |
+
) ?? false;
|
| 534 |
+
if (hasFinished) return false;
|
| 535 |
+
|
| 536 |
+
// In multi-persona mode, check if all personas have sent their final answers
|
| 537 |
+
const allPersonasComplete = lastAssistant.personaResponses.every((pr) => {
|
| 538 |
+
// A persona is complete if interrupted field is defined (set when final answer arrives)
|
| 539 |
+
// The interrupted field is only set when updateType === "finalAnswer" is processed
|
| 540 |
+
return pr.interrupted !== undefined;
|
| 541 |
+
});
|
| 542 |
+
return !allPersonasComplete;
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
// Single persona mode: check for FinalAnswer update
|
| 546 |
+
const hasFinalAnswer =
|
| 547 |
+
lastAssistant.updates?.some((update) => update.type === MessageUpdateType.FinalAnswer) ??
|
| 548 |
+
false;
|
| 549 |
+
return !hasFinalAnswer;
|
| 550 |
}
|
| 551 |
|
| 552 |
$effect(() => {
|
|
|
|
| 612 |
{messagesAlternatives}
|
| 613 |
shared={data.shared}
|
| 614 |
preprompt={data.preprompt}
|
| 615 |
+
personaId={(data as any).personaId}
|
| 616 |
bind:files
|
| 617 |
onmessage={onMessage}
|
| 618 |
onretry={onRetry}
|
src/routes/conversation/[id]/+server.ts
CHANGED
|
@@ -18,7 +18,6 @@ import { convertLegacyConversation } from "$lib/utils/tree/convertLegacyConversa
|
|
| 18 |
import { isMessageId } from "$lib/utils/tree/isMessageId";
|
| 19 |
import { buildSubtree } from "$lib/utils/tree/buildSubtree.js";
|
| 20 |
import { addChildren } from "$lib/utils/tree/addChildren.js";
|
| 21 |
-
import { addSibling } from "$lib/utils/tree/addSibling.js";
|
| 22 |
import { usageLimits } from "$lib/server/usageLimits";
|
| 23 |
import { textGeneration } from "$lib/server/textGeneration";
|
| 24 |
import type { TextGenerationContext } from "$lib/server/textGeneration/types";
|
|
@@ -160,6 +159,7 @@ export async function POST({ request, locals, params, getClientAddress }) {
|
|
| 160 |
),
|
| 161 |
is_retry: z.optional(z.boolean()),
|
| 162 |
is_continue: z.optional(z.boolean()),
|
|
|
|
| 163 |
files: z.optional(
|
| 164 |
z.array(
|
| 165 |
z.object({
|
|
@@ -229,6 +229,11 @@ export async function POST({ request, locals, params, getClientAddress }) {
|
|
| 229 |
messageToWriteToId = messageId;
|
| 230 |
messagesForPrompt = buildSubtree(conv, messageId);
|
| 231 |
} else if (isRetry && messageId) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
// two cases, if we're retrying a user message with a newPrompt set,
|
| 233 |
// it means we're editing a user message
|
| 234 |
// if we're retrying on an assistant message, newPrompt cannot be set
|
|
@@ -276,6 +281,7 @@ export async function POST({ request, locals, params, getClientAddress }) {
|
|
| 276 |
messagesForPrompt = buildSubtree(conv, messageId);
|
| 277 |
messagesForPrompt.pop(); // don't need the latest assistant message in the prompt since we're retrying it
|
| 278 |
}
|
|
|
|
| 279 |
} else {
|
| 280 |
// just a normal linear conversation, so we add the user message
|
| 281 |
// and the blank assistant message back to back
|
|
@@ -335,6 +341,18 @@ export async function POST({ request, locals, params, getClientAddress }) {
|
|
| 335 |
const stream = new ReadableStream({
|
| 336 |
async start(controller) {
|
| 337 |
messageToWriteTo.updates ??= [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
async function update(event: MessageUpdate) {
|
| 339 |
if (!messageToWriteTo || !conv) {
|
| 340 |
throw Error("No message or conversation to write events to");
|
|
@@ -444,53 +462,201 @@ export async function POST({ request, locals, params, getClientAddress }) {
|
|
| 444 |
|
| 445 |
// Get current persona definition (may have been updated since conversation creation)
|
| 446 |
const userSettings = await collections.settings.findOne(authCondition(locals));
|
| 447 |
-
const
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
|
| 459 |
-
|
| 460 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
conv,
|
| 467 |
-
messages: messagesForPrompt,
|
| 468 |
-
assistant: undefined,
|
| 469 |
-
isContinue: isContinue ?? false,
|
| 470 |
-
promptedAt,
|
| 471 |
-
ip: getClientAddress(),
|
| 472 |
-
username: locals.user?.username,
|
| 473 |
-
// Force-enable multimodal if user settings say so for this model
|
| 474 |
-
forceMultimodal: Boolean(userSettings?.multimodalOverrides?.[model.id]),
|
| 475 |
-
};
|
| 476 |
-
// run the text generation and send updates to the client
|
| 477 |
-
for await (const event of textGeneration(ctx)) await update(event);
|
| 478 |
-
} catch (e) {
|
| 479 |
-
hasError = true;
|
| 480 |
-
await update({
|
| 481 |
-
type: MessageUpdateType.Status,
|
| 482 |
-
status: MessageUpdateStatus.Error,
|
| 483 |
-
message: (e as Error).message,
|
| 484 |
-
});
|
| 485 |
-
logger.error(e);
|
| 486 |
-
} finally {
|
| 487 |
-
// check if no output was generated
|
| 488 |
-
if (!hasError && messageToWriteTo.content === initialMessageContent) {
|
| 489 |
await update({
|
| 490 |
type: MessageUpdateType.Status,
|
| 491 |
status: MessageUpdateStatus.Error,
|
| 492 |
-
message:
|
| 493 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
}
|
| 495 |
}
|
| 496 |
|
|
|
|
| 18 |
import { isMessageId } from "$lib/utils/tree/isMessageId";
|
| 19 |
import { buildSubtree } from "$lib/utils/tree/buildSubtree.js";
|
| 20 |
import { addChildren } from "$lib/utils/tree/addChildren.js";
|
|
|
|
| 21 |
import { usageLimits } from "$lib/server/usageLimits";
|
| 22 |
import { textGeneration } from "$lib/server/textGeneration";
|
| 23 |
import type { TextGenerationContext } from "$lib/server/textGeneration/types";
|
|
|
|
| 159 |
),
|
| 160 |
is_retry: z.optional(z.boolean()),
|
| 161 |
is_continue: z.optional(z.boolean()),
|
| 162 |
+
persona_id: z.optional(z.string()), // Optional: specific persona to regenerate
|
| 163 |
files: z.optional(
|
| 164 |
z.array(
|
| 165 |
z.object({
|
|
|
|
| 229 |
messageToWriteToId = messageId;
|
| 230 |
messagesForPrompt = buildSubtree(conv, messageId);
|
| 231 |
} else if (isRetry && messageId) {
|
| 232 |
+
// REGENERATION DISABLED - commenting out retry logic
|
| 233 |
+
// Returning error when retry is attempted
|
| 234 |
+
error(400, "Regeneration is currently disabled");
|
| 235 |
+
|
| 236 |
+
/*
|
| 237 |
// two cases, if we're retrying a user message with a newPrompt set,
|
| 238 |
// it means we're editing a user message
|
| 239 |
// if we're retrying on an assistant message, newPrompt cannot be set
|
|
|
|
| 281 |
messagesForPrompt = buildSubtree(conv, messageId);
|
| 282 |
messagesForPrompt.pop(); // don't need the latest assistant message in the prompt since we're retrying it
|
| 283 |
}
|
| 284 |
+
*/
|
| 285 |
} else {
|
| 286 |
// just a normal linear conversation, so we add the user message
|
| 287 |
// and the blank assistant message back to back
|
|
|
|
| 341 |
const stream = new ReadableStream({
|
| 342 |
async start(controller) {
|
| 343 |
messageToWriteTo.updates ??= [];
|
| 344 |
+
|
| 345 |
+
// Send immediate "Started" status for optimistic UI feedback
|
| 346 |
+
const startedEvent = {
|
| 347 |
+
type: MessageUpdateType.Status,
|
| 348 |
+
status: MessageUpdateStatus.Started,
|
| 349 |
+
};
|
| 350 |
+
try {
|
| 351 |
+
controller.enqueue(JSON.stringify(startedEvent) + "\n");
|
| 352 |
+
} catch (err) {
|
| 353 |
+
// Client may have disconnected already
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
async function update(event: MessageUpdate) {
|
| 357 |
if (!messageToWriteTo || !conv) {
|
| 358 |
throw Error("No message or conversation to write events to");
|
|
|
|
| 462 |
|
| 463 |
// Get current persona definition (may have been updated since conversation creation)
|
| 464 |
const userSettings = await collections.settings.findOne(authCondition(locals));
|
| 465 |
+
const activePersonaIds = userSettings?.activePersonas ?? [];
|
| 466 |
+
|
| 467 |
+
// Get all active personas
|
| 468 |
+
const activePersonas = activePersonaIds
|
| 469 |
+
.map((id) => userSettings?.personas?.find((p) => p.id === id))
|
| 470 |
+
.filter((p): p is import("$lib/types/Persona").Persona => p !== undefined);
|
| 471 |
+
|
| 472 |
+
// Determine if we should use multi-persona mode
|
| 473 |
+
const useMultiPersona = activePersonas.length > 1;
|
| 474 |
+
|
| 475 |
+
if (!useMultiPersona) {
|
| 476 |
+
// Use current persona prompt (reflects any edits)
|
| 477 |
+
const preprompt = conv.preprompt ?? "";
|
| 478 |
+
|
| 479 |
+
// Update conversation's preprompt to reflect current persona
|
| 480 |
+
await collections.conversations.updateOne(
|
| 481 |
+
{ _id: conv._id },
|
| 482 |
+
{ $set: { preprompt, updatedAt: new Date() } }
|
| 483 |
+
);
|
| 484 |
+
|
| 485 |
+
// Update conv object with current preprompt
|
| 486 |
+
conv.preprompt = preprompt;
|
| 487 |
+
|
| 488 |
+
try {
|
| 489 |
+
const ctx: TextGenerationContext = {
|
| 490 |
+
model,
|
| 491 |
+
endpoint: await model.getEndpoint(),
|
| 492 |
+
conv,
|
| 493 |
+
messages: messagesForPrompt,
|
| 494 |
+
assistant: undefined,
|
| 495 |
+
isContinue: isContinue ?? false,
|
| 496 |
+
promptedAt,
|
| 497 |
+
ip: getClientAddress(),
|
| 498 |
+
username: locals.user?.username,
|
| 499 |
+
// Force-enable multimodal if user settings say so for this model
|
| 500 |
+
forceMultimodal: Boolean(userSettings?.multimodalOverrides?.[model.id]),
|
| 501 |
+
};
|
| 502 |
+
// run the text generation and send updates to the client
|
| 503 |
+
for await (const event of textGeneration(ctx)) await update(event);
|
| 504 |
+
} catch (e) {
|
| 505 |
+
hasError = true;
|
| 506 |
+
await update({
|
| 507 |
+
type: MessageUpdateType.Status,
|
| 508 |
+
status: MessageUpdateStatus.Error,
|
| 509 |
+
message: (e as Error).message,
|
| 510 |
+
});
|
| 511 |
+
logger.error(e);
|
| 512 |
+
} finally {
|
| 513 |
+
// check if no output was generated
|
| 514 |
+
if (!hasError && messageToWriteTo.content === initialMessageContent) {
|
| 515 |
+
await update({
|
| 516 |
+
type: MessageUpdateType.Status,
|
| 517 |
+
status: MessageUpdateStatus.Error,
|
| 518 |
+
message: "No output was generated. Something went wrong.",
|
| 519 |
+
});
|
| 520 |
+
}
|
| 521 |
+
}
|
| 522 |
+
} else {
|
| 523 |
+
// Multi-persona mode - parallel generation for all active personas
|
| 524 |
+
const { multiPersonaTextGeneration } = await import(
|
| 525 |
+
"$lib/server/textGeneration/multiPersona"
|
| 526 |
+
);
|
| 527 |
+
const { generateTitleForConversation } = await import("$lib/server/textGeneration/title");
|
| 528 |
+
|
| 529 |
+
// REGENERATION DISABLED - commenting out persona retry logic
|
| 530 |
+
/*
|
| 531 |
+
// Check if this is a retry for a specific persona
|
| 532 |
+
const isPersonaRetry = isRetry && personaId;
|
| 533 |
+
|
| 534 |
+
// If retrying a specific persona, get the previous message's persona responses
|
| 535 |
+
const previousMessage = isPersonaRetry
|
| 536 |
+
? conv.messages.find((msg) => msg.id === messageId)
|
| 537 |
+
: null;
|
| 538 |
+
|
| 539 |
+
// Initialize persona responses structure
|
| 540 |
+
if (isPersonaRetry && previousMessage?.personaResponses) {
|
| 541 |
+
// Copy all previous persona responses
|
| 542 |
+
messageToWriteTo.personaResponses = previousMessage.personaResponses.map((pr) => {
|
| 543 |
+
if (pr.personaId === personaId) {
|
| 544 |
+
// For the persona being regenerated, store the old response as a child
|
| 545 |
+
const oldResponse = { ...pr };
|
| 546 |
+
delete oldResponse.children; // Don't nest children recursively
|
| 547 |
+
delete oldResponse.currentChildIndex;
|
| 548 |
+
|
| 549 |
+
return {
|
| 550 |
+
personaId: pr.personaId,
|
| 551 |
+
personaName: pr.personaName,
|
| 552 |
+
personaOccupation: pr.personaOccupation,
|
| 553 |
+
personaStance: pr.personaStance,
|
| 554 |
+
content: "",
|
| 555 |
+
updates: [],
|
| 556 |
+
children: [oldResponse], // Store the old response
|
| 557 |
+
currentChildIndex: 0, // New response will be at index 0
|
| 558 |
+
};
|
| 559 |
+
} else {
|
| 560 |
+
// Keep other personas' responses as-is
|
| 561 |
+
return { ...pr };
|
| 562 |
+
}
|
| 563 |
+
});
|
| 564 |
+
} else {
|
| 565 |
+
*/
|
| 566 |
+
// Normal generation: initialize empty responses for all personas
|
| 567 |
+
messageToWriteTo.personaResponses = activePersonas.map((p) => ({
|
| 568 |
+
personaId: p.id,
|
| 569 |
+
personaName: p.name,
|
| 570 |
+
personaOccupation: p.jobSector,
|
| 571 |
+
personaStance: p.stance,
|
| 572 |
+
content: "",
|
| 573 |
+
updates: [],
|
| 574 |
+
}));
|
| 575 |
+
// }
|
| 576 |
+
|
| 577 |
+
try {
|
| 578 |
+
// Generate title if needed (do this once, not per-persona)
|
| 579 |
+
if (conv.title === "New Chat") {
|
| 580 |
+
for await (const titleUpdate of generateTitleForConversation(conv)) {
|
| 581 |
+
await update(titleUpdate);
|
| 582 |
+
}
|
| 583 |
+
}
|
| 584 |
|
| 585 |
+
// Filter out system messages to prevent contamination
|
| 586 |
+
// Each persona will get their own prompt via the preprompt parameter
|
| 587 |
+
const messagesWithoutSystem = messagesForPrompt.filter((msg) => msg.from !== "system");
|
| 588 |
+
|
| 589 |
+
const ctx: TextGenerationContext = {
|
| 590 |
+
model,
|
| 591 |
+
endpoint: await model.getEndpoint(),
|
| 592 |
+
conv,
|
| 593 |
+
messages: messagesWithoutSystem,
|
| 594 |
+
assistant: undefined,
|
| 595 |
+
isContinue: isContinue ?? false,
|
| 596 |
+
promptedAt,
|
| 597 |
+
ip: getClientAddress(),
|
| 598 |
+
username: locals.user?.username,
|
| 599 |
+
forceMultimodal: Boolean(userSettings?.multimodalOverrides?.[model.id]),
|
| 600 |
+
};
|
| 601 |
+
|
| 602 |
+
// REGENERATION DISABLED - always generate for all active personas
|
| 603 |
+
/*
|
| 604 |
+
// Determine which personas to generate for
|
| 605 |
+
const personasToGenerate = isPersonaRetry && personaId
|
| 606 |
+
? (() => {
|
| 607 |
+
// Find the specific persona from all user personas, not just active ones
|
| 608 |
+
const persona = userSettings?.personas?.find((p) => p.id === personaId);
|
| 609 |
+
return persona ? [persona] : [];
|
| 610 |
+
})()
|
| 611 |
+
: activePersonas;
|
| 612 |
+
*/
|
| 613 |
+
const personasToGenerate = activePersonas;
|
| 614 |
+
|
| 615 |
+
// Run multi-persona text generation (preprocessing happens once inside)
|
| 616 |
+
for await (const event of multiPersonaTextGeneration(ctx, personasToGenerate)) {
|
| 617 |
+
// Handle persona-specific updates
|
| 618 |
+
if (event.type === MessageUpdateType.Persona) {
|
| 619 |
+
const personaResponse = messageToWriteTo.personaResponses?.find(
|
| 620 |
+
(pr) => pr.personaId === event.personaId
|
| 621 |
+
);
|
| 622 |
+
|
| 623 |
+
if (personaResponse) {
|
| 624 |
+
if (event.updateType === "stream" && event.token) {
|
| 625 |
+
personaResponse.content += event.token;
|
| 626 |
+
} else if (event.updateType === "finalAnswer" && event.text) {
|
| 627 |
+
personaResponse.content = event.text;
|
| 628 |
+
personaResponse.interrupted = event.interrupted;
|
| 629 |
+
} else if (event.updateType === "routerMetadata" && event.route && event.model) {
|
| 630 |
+
personaResponse.routerMetadata = {
|
| 631 |
+
route: event.route,
|
| 632 |
+
model: event.model,
|
| 633 |
+
};
|
| 634 |
+
}
|
| 635 |
+
}
|
| 636 |
+
}
|
| 637 |
|
| 638 |
+
await update(event);
|
| 639 |
+
}
|
| 640 |
+
} catch (e) {
|
| 641 |
+
hasError = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 642 |
await update({
|
| 643 |
type: MessageUpdateType.Status,
|
| 644 |
status: MessageUpdateStatus.Error,
|
| 645 |
+
message: (e as Error).message,
|
| 646 |
});
|
| 647 |
+
logger.error(e);
|
| 648 |
+
} finally {
|
| 649 |
+
// Check if at least one persona generated output
|
| 650 |
+
const hasAnyOutput = messageToWriteTo.personaResponses?.some(
|
| 651 |
+
(pr) => pr.content.length > 0
|
| 652 |
+
);
|
| 653 |
+
if (!hasError && !hasAnyOutput) {
|
| 654 |
+
await update({
|
| 655 |
+
type: MessageUpdateType.Status,
|
| 656 |
+
status: MessageUpdateStatus.Error,
|
| 657 |
+
message: "No output was generated from any persona. Something went wrong.",
|
| 658 |
+
});
|
| 659 |
+
}
|
| 660 |
}
|
| 661 |
}
|
| 662 |
|
src/routes/login/callback/+server.ts
CHANGED
|
@@ -12,17 +12,26 @@ const sanitizeJSONEnv = (val: string, fallback: string) => {
|
|
| 12 |
return unquoted || fallback;
|
| 13 |
};
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
const allowedUserEmails = z
|
| 16 |
.array(z.string().email())
|
| 17 |
.optional()
|
| 18 |
.default([])
|
| 19 |
-
.parse(
|
| 20 |
|
| 21 |
const allowedUserDomains = z
|
| 22 |
.array(z.string().regex(/\.\w+$/)) // Contains at least a dot
|
| 23 |
.optional()
|
| 24 |
.default([])
|
| 25 |
-
.parse(
|
| 26 |
|
| 27 |
export async function GET({ url, locals, cookies, request, getClientAddress }) {
|
| 28 |
const { error: errorName, error_description: errorDescription } = z
|
|
|
|
| 12 |
return unquoted || fallback;
|
| 13 |
};
|
| 14 |
|
| 15 |
+
const parseJSONEnv = (val: string, fallback: string) => {
|
| 16 |
+
try {
|
| 17 |
+
return JSON5.parse(sanitizeJSONEnv(val, fallback));
|
| 18 |
+
} catch (e) {
|
| 19 |
+
console.warn(`Failed to parse environment variable as JSON5, using fallback: ${fallback}`, e);
|
| 20 |
+
return JSON5.parse(fallback);
|
| 21 |
+
}
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
const allowedUserEmails = z
|
| 25 |
.array(z.string().email())
|
| 26 |
.optional()
|
| 27 |
.default([])
|
| 28 |
+
.parse(parseJSONEnv(config.ALLOWED_USER_EMAILS, "[]"));
|
| 29 |
|
| 30 |
const allowedUserDomains = z
|
| 31 |
.array(z.string().regex(/\.\w+$/)) // Contains at least a dot
|
| 32 |
.optional()
|
| 33 |
.default([])
|
| 34 |
+
.parse(parseJSONEnv(config.ALLOWED_USER_DOMAINS, "[]"));
|
| 35 |
|
| 36 |
export async function GET({ url, locals, cookies, request, getClientAddress }) {
|
| 37 |
const { error: errorName, error_description: errorDescription } = z
|
src/routes/personas/+page.svelte
CHANGED
|
@@ -18,14 +18,24 @@
|
|
| 18 |
let queryTokens = $derived(normalize(personaFilter).trim().split(/\s+/).filter(Boolean));
|
| 19 |
let filtered = $derived(
|
| 20 |
$settings.personas.filter((p: Persona) => {
|
| 21 |
-
const haystack = normalize(`${p.name} ${p.
|
| 22 |
return queryTokens.every((q) => haystack.includes(q));
|
| 23 |
})
|
| 24 |
);
|
| 25 |
|
| 26 |
-
function
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
}
|
| 30 |
}
|
| 31 |
|
|
@@ -42,7 +52,7 @@
|
|
| 42 |
clearTimeout(clickTimeout);
|
| 43 |
clickTimeout = null;
|
| 44 |
}
|
| 45 |
-
|
| 46 |
}
|
| 47 |
|
| 48 |
// Edit modal state
|
|
@@ -51,16 +61,28 @@
|
|
| 51 |
$settings.personas.find((p) => p.id === editingPersonaId) ?? null
|
| 52 |
);
|
| 53 |
let editableName = $state("");
|
| 54 |
-
let
|
|
|
|
|
|
|
| 55 |
let editableStance = $state("");
|
| 56 |
-
let
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
$effect(() => {
|
| 59 |
if (editingPersona) {
|
| 60 |
editableName = editingPersona.name;
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
}
|
| 65 |
});
|
| 66 |
|
|
@@ -79,9 +101,15 @@ function closeEdit() {
|
|
| 79 |
? {
|
| 80 |
...p,
|
| 81 |
name: editableName,
|
| 82 |
-
|
|
|
|
|
|
|
| 83 |
stance: editableStance,
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
updatedAt: new Date(),
|
| 86 |
}
|
| 87 |
: p
|
|
@@ -89,23 +117,30 @@ function closeEdit() {
|
|
| 89 |
closeEdit();
|
| 90 |
}
|
| 91 |
|
| 92 |
-
function
|
| 93 |
if (!editingPersona) return;
|
| 94 |
const id = editingPersona.id;
|
| 95 |
saveEdit();
|
| 96 |
-
|
| 97 |
}
|
| 98 |
|
| 99 |
function deleteEditingPersona() {
|
| 100 |
if (!editingPersona) return;
|
| 101 |
if ($settings.personas.length === 1) return alert("Cannot delete the last persona.");
|
| 102 |
-
if (editingPersona.id
|
| 103 |
-
return alert("Cannot delete
|
| 104 |
if (confirm(`Delete "${editingPersona.name}"?`)) {
|
| 105 |
$settings.personas = $settings.personas.filter((p) => p.id !== editingPersona!.id);
|
| 106 |
closeEdit();
|
| 107 |
}
|
| 108 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
</script>
|
| 110 |
|
| 111 |
<svelte:head>
|
|
@@ -125,7 +160,7 @@ function closeEdit() {
|
|
| 125 |
<input
|
| 126 |
type="search"
|
| 127 |
bind:value={personaFilter}
|
| 128 |
-
placeholder="Search by name,
|
| 129 |
aria-label="Search personas"
|
| 130 |
class="mt-4 w-full rounded-3xl border border-gray-300 bg-white px-5 py-2 text-[15px]
|
| 131 |
placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-300
|
|
@@ -141,22 +176,30 @@ function closeEdit() {
|
|
| 141 |
ondblclick={() => handleCardDblClick(persona.id)}
|
| 142 |
onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleCardClick(persona.id); } }}
|
| 143 |
aria-label={`Open persona ${persona.name}`}
|
| 144 |
-
class="group relative flex min-h-[112px] flex-col gap-2 overflow-hidden rounded-xl
|
| 145 |
-
class:active-model={persona.id === $settings.activePersona}
|
| 146 |
>
|
| 147 |
<div class="flex items-center justify-between gap-1">
|
| 148 |
<span class="flex items-center gap-2 font-semibold">{persona.name}</span>
|
| 149 |
-
{#if persona.id
|
| 150 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
{/if}
|
| 152 |
</div>
|
| 153 |
-
{#if persona.occupation || persona.stance}
|
| 154 |
-
<div class="text-sm text-gray-600 dark:text-gray-300">
|
| 155 |
-
{#if persona.occupation}<span class="font-medium">{persona.occupation}</span>{/if}
|
| 156 |
-
{#if persona.occupation && persona.stance}<span class="mx-1 text-gray-400">•</span>{/if}
|
| 157 |
-
{#if persona.stance}<span class="italic">{persona.stance}</span>{/if}
|
| 158 |
-
</div>
|
| 159 |
-
{/if}
|
| 160 |
</div>
|
| 161 |
{/each}
|
| 162 |
</div>
|
|
@@ -167,66 +210,267 @@ function closeEdit() {
|
|
| 167 |
<Modal onclose={() => {
|
| 168 |
const dirty = editingPersona && (
|
| 169 |
editableName !== editingPersona.name ||
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
);
|
| 174 |
if (!dirty) return closeEdit();
|
| 175 |
showCloseConfirm = true;
|
| 176 |
-
}} width="w-full !max-w-
|
| 177 |
-
<div class="flex h-full max-h-[
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
</div>
|
| 189 |
</div>
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
</div>
|
| 200 |
</div>
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
</div>
|
| 211 |
</div>
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
</div>
|
| 222 |
</div>
|
|
|
|
| 223 |
<div class="flex flex-wrap gap-2 pt-2">
|
| 224 |
<button
|
| 225 |
-
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50
|
| 226 |
-
onclick={
|
| 227 |
-
disabled={editingPersona.id === $settings.activePersona}
|
| 228 |
>
|
| 229 |
-
{editingPersona.id
|
| 230 |
</button>
|
| 231 |
<button
|
| 232 |
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
|
|
| 18 |
let queryTokens = $derived(normalize(personaFilter).trim().split(/\s+/).filter(Boolean));
|
| 19 |
let filtered = $derived(
|
| 20 |
$settings.personas.filter((p: Persona) => {
|
| 21 |
+
const haystack = normalize(`${p.name} ${p.age ?? ""} ${p.gender ?? ""} ${p.jobSector ?? ""} ${p.stance ?? ""}`);
|
| 22 |
return queryTokens.every((q) => haystack.includes(q));
|
| 23 |
})
|
| 24 |
);
|
| 25 |
|
| 26 |
+
function togglePersona(personaId: string) {
|
| 27 |
+
const isActive = $settings.activePersonas.includes(personaId);
|
| 28 |
+
if (isActive) {
|
| 29 |
+
// Prevent deactivating the last active persona
|
| 30 |
+
if ($settings.activePersonas.length === 1) {
|
| 31 |
+
alert("At least one persona must be active.");
|
| 32 |
+
return;
|
| 33 |
+
}
|
| 34 |
+
// Deactivate: remove from array
|
| 35 |
+
settings.instantSet({ activePersonas: $settings.activePersonas.filter(id => id !== personaId) });
|
| 36 |
+
} else {
|
| 37 |
+
// Activate: add to array
|
| 38 |
+
settings.instantSet({ activePersonas: [...$settings.activePersonas, personaId] });
|
| 39 |
}
|
| 40 |
}
|
| 41 |
|
|
|
|
| 52 |
clearTimeout(clickTimeout);
|
| 53 |
clickTimeout = null;
|
| 54 |
}
|
| 55 |
+
togglePersona(personaId);
|
| 56 |
}
|
| 57 |
|
| 58 |
// Edit modal state
|
|
|
|
| 61 |
$settings.personas.find((p) => p.id === editingPersonaId) ?? null
|
| 62 |
);
|
| 63 |
let editableName = $state("");
|
| 64 |
+
let editableAge = $state("");
|
| 65 |
+
let editableGender = $state("");
|
| 66 |
+
let editableJobSector = $state("");
|
| 67 |
let editableStance = $state("");
|
| 68 |
+
let editableCommunicationStyle = $state("");
|
| 69 |
+
let editableGoalInDebate = $state("");
|
| 70 |
+
let editableIncomeBracket = $state("");
|
| 71 |
+
let editablePoliticalLeanings = $state("");
|
| 72 |
+
let editableGeographicContext = $state("");
|
| 73 |
|
| 74 |
$effect(() => {
|
| 75 |
if (editingPersona) {
|
| 76 |
editableName = editingPersona.name;
|
| 77 |
+
editableAge = editingPersona.age;
|
| 78 |
+
editableGender = editingPersona.gender;
|
| 79 |
+
editableJobSector = editingPersona.jobSector || "";
|
| 80 |
+
editableStance = editingPersona.stance || "";
|
| 81 |
+
editableCommunicationStyle = editingPersona.communicationStyle || "";
|
| 82 |
+
editableGoalInDebate = editingPersona.goalInDebate || "";
|
| 83 |
+
editableIncomeBracket = editingPersona.incomeBracket || "";
|
| 84 |
+
editablePoliticalLeanings = editingPersona.politicalLeanings || "";
|
| 85 |
+
editableGeographicContext = editingPersona.geographicContext || "";
|
| 86 |
}
|
| 87 |
});
|
| 88 |
|
|
|
|
| 101 |
? {
|
| 102 |
...p,
|
| 103 |
name: editableName,
|
| 104 |
+
age: editableAge,
|
| 105 |
+
gender: editableGender,
|
| 106 |
+
jobSector: editableJobSector,
|
| 107 |
stance: editableStance,
|
| 108 |
+
communicationStyle: editableCommunicationStyle,
|
| 109 |
+
goalInDebate: editableGoalInDebate,
|
| 110 |
+
incomeBracket: editableIncomeBracket,
|
| 111 |
+
politicalLeanings: editablePoliticalLeanings,
|
| 112 |
+
geographicContext: editableGeographicContext,
|
| 113 |
updatedAt: new Date(),
|
| 114 |
}
|
| 115 |
: p
|
|
|
|
| 117 |
closeEdit();
|
| 118 |
}
|
| 119 |
|
| 120 |
+
function toggleEditingPersona() {
|
| 121 |
if (!editingPersona) return;
|
| 122 |
const id = editingPersona.id;
|
| 123 |
saveEdit();
|
| 124 |
+
togglePersona(id);
|
| 125 |
}
|
| 126 |
|
| 127 |
function deleteEditingPersona() {
|
| 128 |
if (!editingPersona) return;
|
| 129 |
if ($settings.personas.length === 1) return alert("Cannot delete the last persona.");
|
| 130 |
+
if ($settings.activePersonas.includes(editingPersona.id))
|
| 131 |
+
return alert("Cannot delete an active persona. Deactivate it first.");
|
| 132 |
if (confirm(`Delete "${editingPersona.name}"?`)) {
|
| 133 |
$settings.personas = $settings.personas.filter((p) => p.id !== editingPersona!.id);
|
| 134 |
closeEdit();
|
| 135 |
}
|
| 136 |
}
|
| 137 |
+
|
| 138 |
+
// Function to show datalist on focus
|
| 139 |
+
function showDatalist(event: FocusEvent) {
|
| 140 |
+
const input = event.target as HTMLInputElement;
|
| 141 |
+
// Dispatch a synthetic input event to trigger the datalist dropdown
|
| 142 |
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
| 143 |
+
}
|
| 144 |
</script>
|
| 145 |
|
| 146 |
<svelte:head>
|
|
|
|
| 160 |
<input
|
| 161 |
type="search"
|
| 162 |
bind:value={personaFilter}
|
| 163 |
+
placeholder="Search by name, age, gender, job sector, or stance"
|
| 164 |
aria-label="Search personas"
|
| 165 |
class="mt-4 w-full rounded-3xl border border-gray-300 bg-white px-5 py-2 text-[15px]
|
| 166 |
placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-300
|
|
|
|
| 176 |
ondblclick={() => handleCardDblClick(persona.id)}
|
| 177 |
onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleCardClick(persona.id); } }}
|
| 178 |
aria-label={`Open persona ${persona.name}`}
|
| 179 |
+
class="group relative flex min-h-[112px] flex-col gap-2 overflow-hidden rounded-xl bg-gray-50/50 px-6 py-5 text-left shadow hover:bg-gray-50 hover:shadow-inner dark:bg-gray-950/20 dark:hover:bg-gray-950/40 {$settings.activePersonas.includes(persona.id) ? 'border-2 border-black dark:border-white' : 'border border-gray-800/70'}"
|
|
|
|
| 180 |
>
|
| 181 |
<div class="flex items-center justify-between gap-1">
|
| 182 |
<span class="flex items-center gap-2 font-semibold">{persona.name}</span>
|
| 183 |
+
{#if $settings.activePersonas.includes(persona.id)}
|
| 184 |
+
<div class="size-2.5 rounded-full bg-black dark:bg-white" title="Active persona"></div>
|
| 185 |
+
{/if}
|
| 186 |
+
</div>
|
| 187 |
+
<div class="text-sm text-gray-600 dark:text-gray-300">
|
| 188 |
+
{#if persona.age || persona.gender}
|
| 189 |
+
<div class="mb-1">
|
| 190 |
+
{#if persona.age}<span>{persona.age}</span>{/if}
|
| 191 |
+
{#if persona.age && persona.gender}<span class="mx-1 text-gray-400">•</span>{/if}
|
| 192 |
+
{#if persona.gender}<span>{persona.gender}</span>{/if}
|
| 193 |
+
</div>
|
| 194 |
+
{/if}
|
| 195 |
+
{#if persona.jobSector || persona.stance}
|
| 196 |
+
<div>
|
| 197 |
+
{#if persona.jobSector}<span class="font-medium">{persona.jobSector}</span>{/if}
|
| 198 |
+
{#if persona.jobSector && persona.stance}<span class="mx-1 text-gray-400">•</span>{/if}
|
| 199 |
+
{#if persona.stance}<span class="italic">{persona.stance}</span>{/if}
|
| 200 |
+
</div>
|
| 201 |
{/if}
|
| 202 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
</div>
|
| 204 |
{/each}
|
| 205 |
</div>
|
|
|
|
| 210 |
<Modal onclose={() => {
|
| 211 |
const dirty = editingPersona && (
|
| 212 |
editableName !== editingPersona.name ||
|
| 213 |
+
editableAge !== editingPersona.age ||
|
| 214 |
+
editableGender !== editingPersona.gender ||
|
| 215 |
+
editableJobSector !== (editingPersona.jobSector || "") ||
|
| 216 |
+
editableStance !== (editingPersona.stance || "") ||
|
| 217 |
+
editableCommunicationStyle !== (editingPersona.communicationStyle || "") ||
|
| 218 |
+
editableGoalInDebate !== (editingPersona.goalInDebate || "") ||
|
| 219 |
+
editableIncomeBracket !== (editingPersona.incomeBracket || "") ||
|
| 220 |
+
editablePoliticalLeanings !== (editingPersona.politicalLeanings || "") ||
|
| 221 |
+
editableGeographicContext !== (editingPersona.geographicContext || "")
|
| 222 |
);
|
| 223 |
if (!dirty) return closeEdit();
|
| 224 |
showCloseConfirm = true;
|
| 225 |
+
}} width="w-full !max-w-4xl">
|
| 226 |
+
<div class="scrollbar-custom flex h-full max-h-[85vh] w-full flex-col gap-5 overflow-y-auto p-6">
|
| 227 |
+
<div class="text-xl font-semibold text-gray-800 dark:text-gray-200">Edit Persona</div>
|
| 228 |
+
|
| 229 |
+
<!-- Group 1: Core Identity -->
|
| 230 |
+
<div>
|
| 231 |
+
<h3 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">Core Identity</h3>
|
| 232 |
+
<div class="grid grid-cols-1 gap-4 md:grid-cols-3 md:gap-6">
|
| 233 |
+
<div class="flex flex-col gap-2">
|
| 234 |
+
<label for="edit-name" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 235 |
+
Name <span class="text-red-500">*</span>
|
| 236 |
+
</label>
|
| 237 |
+
<input
|
| 238 |
+
id="edit-name"
|
| 239 |
+
type="text"
|
| 240 |
+
bind:value={editableName}
|
| 241 |
+
required
|
| 242 |
+
class="w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 243 |
+
maxlength="100"
|
| 244 |
+
/>
|
| 245 |
+
</div>
|
| 246 |
+
|
| 247 |
+
<div class="flex flex-col gap-2">
|
| 248 |
+
<label for="edit-age" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 249 |
+
Age <span class="text-red-500">*</span>
|
| 250 |
+
</label>
|
| 251 |
+
<input
|
| 252 |
+
id="edit-age"
|
| 253 |
+
type="text"
|
| 254 |
+
list="edit-age-options"
|
| 255 |
+
bind:value={editableAge}
|
| 256 |
+
onfocus={showDatalist}
|
| 257 |
+
required
|
| 258 |
+
class="w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 259 |
+
maxlength="50"
|
| 260 |
+
/>
|
| 261 |
+
<datalist id="edit-age-options">
|
| 262 |
+
<option value="18-25">18-25</option>
|
| 263 |
+
<option value="26-35">26-35</option>
|
| 264 |
+
<option value="36-45">36-45</option>
|
| 265 |
+
<option value="46-55">46-55</option>
|
| 266 |
+
<option value="56-65">56-65</option>
|
| 267 |
+
<option value="66+">66+</option>
|
| 268 |
+
</datalist>
|
| 269 |
+
</div>
|
| 270 |
+
|
| 271 |
+
<div class="flex flex-col gap-2">
|
| 272 |
+
<label for="edit-gender" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 273 |
+
Gender <span class="text-red-500">*</span>
|
| 274 |
+
</label>
|
| 275 |
+
<input
|
| 276 |
+
id="edit-gender"
|
| 277 |
+
type="text"
|
| 278 |
+
list="edit-gender-options"
|
| 279 |
+
bind:value={editableGender}
|
| 280 |
+
onfocus={showDatalist}
|
| 281 |
+
required
|
| 282 |
+
class="w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 283 |
+
maxlength="50"
|
| 284 |
+
/>
|
| 285 |
+
<datalist id="edit-gender-options">
|
| 286 |
+
<option value="Male">Male</option>
|
| 287 |
+
<option value="Female">Female</option>
|
| 288 |
+
<option value="Prefer not to say">Prefer not to say</option>
|
| 289 |
+
</datalist>
|
| 290 |
+
</div>
|
| 291 |
</div>
|
| 292 |
</div>
|
| 293 |
+
|
| 294 |
+
<!-- Group 2: Professional & Stance -->
|
| 295 |
+
<div>
|
| 296 |
+
<h3 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">Professional & Stance</h3>
|
| 297 |
+
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6">
|
| 298 |
+
<div class="flex flex-col gap-2">
|
| 299 |
+
<label for="edit-job-sector" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 300 |
+
Job Sector
|
| 301 |
+
</label>
|
| 302 |
+
<input
|
| 303 |
+
id="edit-job-sector"
|
| 304 |
+
type="text"
|
| 305 |
+
list="edit-job-sector-options"
|
| 306 |
+
bind:value={editableJobSector}
|
| 307 |
+
onfocus={showDatalist}
|
| 308 |
+
class="w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 309 |
+
maxlength="200"
|
| 310 |
+
/>
|
| 311 |
+
<datalist id="edit-job-sector-options">
|
| 312 |
+
<option value="Healthcare provider">Healthcare provider</option>
|
| 313 |
+
<option value="Small business owner">Small business owner</option>
|
| 314 |
+
<option value="Tech worker">Tech worker</option>
|
| 315 |
+
<option value="Teacher">Teacher</option>
|
| 316 |
+
<option value="Unemployed/Retired">Unemployed/Retired</option>
|
| 317 |
+
<option value="Government worker">Government worker</option>
|
| 318 |
+
<option value="Student">Student</option>
|
| 319 |
+
</datalist>
|
| 320 |
+
</div>
|
| 321 |
+
|
| 322 |
+
<div class="flex flex-col gap-2">
|
| 323 |
+
<label for="edit-stance" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 324 |
+
Stance
|
| 325 |
+
</label>
|
| 326 |
+
<input
|
| 327 |
+
id="edit-stance"
|
| 328 |
+
type="text"
|
| 329 |
+
list="edit-stance-options"
|
| 330 |
+
bind:value={editableStance}
|
| 331 |
+
onfocus={showDatalist}
|
| 332 |
+
class="w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 333 |
+
maxlength="200"
|
| 334 |
+
/>
|
| 335 |
+
<datalist id="edit-stance-options">
|
| 336 |
+
<option value="In Favor of Medicare for All">In Favor of Medicare for All</option>
|
| 337 |
+
<option value="Hardline Insurance Advocate">Hardline Insurance Advocate</option>
|
| 338 |
+
<option value="Improvement of Current System">Improvement of Current System</option>
|
| 339 |
+
<option value="Public Option Supporter">Public Option Supporter</option>
|
| 340 |
+
<option value="Status Quo">Status Quo</option>
|
| 341 |
+
</datalist>
|
| 342 |
+
</div>
|
| 343 |
</div>
|
| 344 |
</div>
|
| 345 |
+
|
| 346 |
+
<!-- Group 3: Communication & Goals -->
|
| 347 |
+
<div>
|
| 348 |
+
<h3 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">Communication & Goals</h3>
|
| 349 |
+
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6">
|
| 350 |
+
<div class="flex flex-col gap-2">
|
| 351 |
+
<label for="edit-communication-style" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 352 |
+
Communication Style
|
| 353 |
+
</label>
|
| 354 |
+
<input
|
| 355 |
+
id="edit-communication-style"
|
| 356 |
+
type="text"
|
| 357 |
+
list="edit-communication-style-options"
|
| 358 |
+
bind:value={editableCommunicationStyle}
|
| 359 |
+
onfocus={showDatalist}
|
| 360 |
+
class="w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 361 |
+
maxlength="200"
|
| 362 |
+
/>
|
| 363 |
+
<datalist id="edit-communication-style-options">
|
| 364 |
+
<option value="Direct">Direct</option>
|
| 365 |
+
<option value="Technical/Jargon use">Technical/Jargon use</option>
|
| 366 |
+
<option value="Informal">Informal</option>
|
| 367 |
+
<option value="Philosophical">Philosophical</option>
|
| 368 |
+
<option value="Pragmatic">Pragmatic</option>
|
| 369 |
+
<option value="Conversational">Conversational</option>
|
| 370 |
+
</datalist>
|
| 371 |
+
</div>
|
| 372 |
+
|
| 373 |
+
<div class="flex flex-col gap-2">
|
| 374 |
+
<label for="edit-goal-in-debate" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 375 |
+
Goal in the Debate
|
| 376 |
+
</label>
|
| 377 |
+
<input
|
| 378 |
+
id="edit-goal-in-debate"
|
| 379 |
+
type="text"
|
| 380 |
+
list="edit-goal-in-debate-options"
|
| 381 |
+
bind:value={editableGoalInDebate}
|
| 382 |
+
onfocus={showDatalist}
|
| 383 |
+
class="w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 384 |
+
maxlength="300"
|
| 385 |
+
/>
|
| 386 |
+
<datalist id="edit-goal-in-debate-options">
|
| 387 |
+
<option value="Keep discussion grounded">Keep discussion grounded</option>
|
| 388 |
+
<option value="Explain complexity">Explain complexity</option>
|
| 389 |
+
<option value="Advocate for change">Advocate for change</option>
|
| 390 |
+
<option value="Defend current system">Defend current system</option>
|
| 391 |
+
<option value="Find compromise">Find compromise</option>
|
| 392 |
+
</datalist>
|
| 393 |
+
</div>
|
| 394 |
</div>
|
| 395 |
</div>
|
| 396 |
+
|
| 397 |
+
<!-- Group 4: Demographics -->
|
| 398 |
+
<div>
|
| 399 |
+
<h3 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">Demographics</h3>
|
| 400 |
+
<div class="grid grid-cols-1 gap-4 md:grid-cols-3 md:gap-6">
|
| 401 |
+
<div class="flex flex-col gap-2">
|
| 402 |
+
<label for="edit-income-bracket" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 403 |
+
Income Bracket
|
| 404 |
+
</label>
|
| 405 |
+
<input
|
| 406 |
+
id="edit-income-bracket"
|
| 407 |
+
type="text"
|
| 408 |
+
list="edit-income-bracket-options"
|
| 409 |
+
bind:value={editableIncomeBracket}
|
| 410 |
+
onfocus={showDatalist}
|
| 411 |
+
class="w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 412 |
+
maxlength="100"
|
| 413 |
+
/>
|
| 414 |
+
<datalist id="edit-income-bracket-options">
|
| 415 |
+
<option value="Low">Low</option>
|
| 416 |
+
<option value="Middle">Middle</option>
|
| 417 |
+
<option value="High">High</option>
|
| 418 |
+
<option value="Comfortable">Comfortable</option>
|
| 419 |
+
<option value="Struggling">Struggling</option>
|
| 420 |
+
</datalist>
|
| 421 |
+
</div>
|
| 422 |
+
|
| 423 |
+
<div class="flex flex-col gap-2">
|
| 424 |
+
<label for="edit-political-leanings" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 425 |
+
Political Leanings
|
| 426 |
+
</label>
|
| 427 |
+
<input
|
| 428 |
+
id="edit-political-leanings"
|
| 429 |
+
type="text"
|
| 430 |
+
list="edit-political-leanings-options"
|
| 431 |
+
bind:value={editablePoliticalLeanings}
|
| 432 |
+
onfocus={showDatalist}
|
| 433 |
+
class="w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 434 |
+
maxlength="100"
|
| 435 |
+
/>
|
| 436 |
+
<datalist id="edit-political-leanings-options">
|
| 437 |
+
<option value="Liberal">Liberal</option>
|
| 438 |
+
<option value="Conservative">Conservative</option>
|
| 439 |
+
<option value="Moderate">Moderate</option>
|
| 440 |
+
<option value="Libertarian">Libertarian</option>
|
| 441 |
+
<option value="Non-affiliated">Non-affiliated</option>
|
| 442 |
+
<option value="Progressive">Progressive</option>
|
| 443 |
+
</datalist>
|
| 444 |
+
</div>
|
| 445 |
+
|
| 446 |
+
<div class="flex flex-col gap-2">
|
| 447 |
+
<label for="edit-geographic-context" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 448 |
+
Geographic Context
|
| 449 |
+
</label>
|
| 450 |
+
<input
|
| 451 |
+
id="edit-geographic-context"
|
| 452 |
+
type="text"
|
| 453 |
+
list="edit-geographic-context-options"
|
| 454 |
+
bind:value={editableGeographicContext}
|
| 455 |
+
onfocus={showDatalist}
|
| 456 |
+
class="w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 457 |
+
maxlength="100"
|
| 458 |
+
/>
|
| 459 |
+
<datalist id="edit-geographic-context-options">
|
| 460 |
+
<option value="Rural">Rural</option>
|
| 461 |
+
<option value="Urban">Urban</option>
|
| 462 |
+
<option value="Suburban">Suburban</option>
|
| 463 |
+
</datalist>
|
| 464 |
+
</div>
|
| 465 |
</div>
|
| 466 |
</div>
|
| 467 |
+
|
| 468 |
<div class="flex flex-wrap gap-2 pt-2">
|
| 469 |
<button
|
| 470 |
+
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
| 471 |
+
onclick={toggleEditingPersona}
|
|
|
|
| 472 |
>
|
| 473 |
+
{$settings.activePersonas.includes(editingPersona.id) ? "Deactivate" : "Activate"}
|
| 474 |
</button>
|
| 475 |
<button
|
| 476 |
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
src/routes/settings/(nav)/+layout.svelte
CHANGED
|
@@ -13,6 +13,8 @@
|
|
| 13 |
import { browser } from "$app/environment";
|
| 14 |
import { isDesktop } from "$lib/utils/isDesktop";
|
| 15 |
import { debounce } from "$lib/utils/debounce";
|
|
|
|
|
|
|
| 16 |
|
| 17 |
interface Props {
|
| 18 |
data: LayoutData;
|
|
@@ -26,44 +28,66 @@
|
|
| 26 |
|
| 27 |
let navContainer: HTMLDivElement | undefined = $state();
|
| 28 |
|
| 29 |
-
async function
|
| 30 |
await tick();
|
| 31 |
const container = navContainer;
|
| 32 |
if (!container) return;
|
| 33 |
-
|
| 34 |
-
if (
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
}
|
| 43 |
-
if (!target) return;
|
| 44 |
-
// Use minimal movement; keep within view if needed
|
| 45 |
-
target.scrollIntoView({ block: "nearest", inline: "nearest" });
|
| 46 |
}
|
| 47 |
|
| 48 |
function checkDesktopRedirect() {
|
| 49 |
if (
|
| 50 |
browser &&
|
| 51 |
isDesktop(window) &&
|
| 52 |
-
page.url.pathname === `${base}/settings`
|
| 53 |
-
!page.url.pathname.endsWith("/application")
|
| 54 |
) {
|
| 55 |
goto(`${base}/settings/application`);
|
| 56 |
}
|
| 57 |
}
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
onMount(() => {
|
| 60 |
-
// Show content
|
| 61 |
-
showContent = page.url.pathname
|
| 62 |
// Initial desktop redirect check
|
| 63 |
checkDesktopRedirect();
|
| 64 |
|
| 65 |
-
// Ensure the selected
|
| 66 |
-
void
|
| 67 |
|
| 68 |
// Add resize listener for desktop redirect
|
| 69 |
if (browser) {
|
|
@@ -77,12 +101,12 @@
|
|
| 77 |
if (from?.url && !from.url.pathname.includes("settings")) {
|
| 78 |
previousPage = from.url.toString() || previousPage || base || "/";
|
| 79 |
}
|
| 80 |
-
// Show content
|
| 81 |
-
showContent = page.url.pathname
|
| 82 |
// Check desktop redirect after navigation
|
| 83 |
checkDesktopRedirect();
|
| 84 |
-
// After navigation, keep the selected
|
| 85 |
-
void
|
| 86 |
});
|
| 87 |
|
| 88 |
const settings = useSettingsStore();
|
|
@@ -92,12 +116,49 @@
|
|
| 92 |
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ");
|
| 93 |
let queryTokens = $derived(normalize(modelFilter).trim().split(/\s+/).filter(Boolean));
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
// Determine active tab based on current route
|
| 96 |
let activeTab = $derived.by(() => {
|
| 97 |
if (page.url.pathname.includes("/personas")) return "personas";
|
| 98 |
if (page.url.pathname.includes("/application")) return "application";
|
| 99 |
-
return "models";
|
|
|
|
| 100 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
</script>
|
| 102 |
|
| 103 |
<div
|
|
@@ -105,41 +166,73 @@
|
|
| 105 |
>
|
| 106 |
<div class="col-span-1 mb-3 flex flex-col gap-3 md:col-span-3 md:mb-4">
|
| 107 |
<div class="flex items-center justify-between">
|
| 108 |
-
{#if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
<button
|
| 110 |
-
class="btn rounded-lg
|
| 111 |
-
aria-label="
|
| 112 |
onclick={() => {
|
| 113 |
-
|
| 114 |
-
goto(`${base}/settings`);
|
| 115 |
}}
|
| 116 |
>
|
| 117 |
-
<
|
| 118 |
class="text-xl text-gray-900 hover:text-black dark:text-gray-200 dark:hover:text-white"
|
| 119 |
/>
|
| 120 |
</button>
|
|
|
|
|
|
|
|
|
|
| 121 |
{/if}
|
| 122 |
-
<h2 class=" left-0 right-0 mx-auto w-fit text-center text-xl font-bold md:hidden">Settings</h2>
|
| 123 |
-
<button
|
| 124 |
-
class="btn rounded-lg"
|
| 125 |
-
aria-label="Close settings"
|
| 126 |
-
onclick={() => {
|
| 127 |
-
goto(previousPage);
|
| 128 |
-
}}
|
| 129 |
-
>
|
| 130 |
-
<CarbonClose
|
| 131 |
-
class="text-xl text-gray-900 hover:text-black dark:text-gray-200 dark:hover:text-white"
|
| 132 |
-
/>
|
| 133 |
-
</button>
|
| 134 |
</div>
|
| 135 |
|
| 136 |
<!-- Tab Navigation -->
|
| 137 |
-
<div class="flex gap-2 border-b border-gray-200 dark:border-gray-700 max-md:hidden
|
| 138 |
<button
|
| 139 |
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors {activeTab === 'models'
|
| 140 |
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
| 141 |
: 'border-transparent text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'}"
|
| 142 |
-
onclick={() =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
>
|
| 144 |
Models
|
| 145 |
</button>
|
|
@@ -147,7 +240,14 @@
|
|
| 147 |
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors {activeTab === 'personas'
|
| 148 |
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
| 149 |
: 'border-transparent text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'}"
|
| 150 |
-
onclick={() =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
>
|
| 152 |
Personas
|
| 153 |
</button>
|
|
@@ -186,7 +286,7 @@
|
|
| 186 |
}) as model}
|
| 187 |
<button
|
| 188 |
type="button"
|
| 189 |
-
onclick={() => goto(`${base}/settings/${model.id}`)}
|
| 190 |
class="group flex h-9 w-full flex-none items-center gap-1 rounded-lg px-3 text-[13px] text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800/60 md:rounded-xl md:px-3 {model.id ===
|
| 191 |
page.params.model
|
| 192 |
? '!bg-gray-100 !text-gray-800 dark:!bg-gray-700 dark:!text-gray-200'
|
|
@@ -223,9 +323,63 @@
|
|
| 223 |
{/each}
|
| 224 |
</div>
|
| 225 |
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
{#if showContent}
|
| 227 |
<div
|
| 228 |
-
class="scrollbar-custom col-span-1 w-full overflow-y-auto overflow-x-clip px-1 {activeTab === 'models' ? 'md:col-span-2' : 'md:col-span-3'} md:row-span-2"
|
| 229 |
class:max-md:hidden={!showContent && browser}
|
| 230 |
>
|
| 231 |
{@render children?.()}
|
|
|
|
| 13 |
import { browser } from "$app/environment";
|
| 14 |
import { isDesktop } from "$lib/utils/isDesktop";
|
| 15 |
import { debounce } from "$lib/utils/debounce";
|
| 16 |
+
import type { Persona } from "$lib/types/Persona";
|
| 17 |
+
import { v4 } from "uuid";
|
| 18 |
|
| 19 |
interface Props {
|
| 20 |
data: LayoutData;
|
|
|
|
| 28 |
|
| 29 |
let navContainer: HTMLDivElement | undefined = $state();
|
| 30 |
|
| 31 |
+
async function scrollSelectedItemIntoView() {
|
| 32 |
await tick();
|
| 33 |
const container = navContainer;
|
| 34 |
if (!container) return;
|
| 35 |
+
|
| 36 |
+
if (activeTab === 'models') {
|
| 37 |
+
const currentModelId = page.params.model as string | undefined;
|
| 38 |
+
if (!currentModelId) return;
|
| 39 |
+
const buttons = container.querySelectorAll<HTMLButtonElement>("button[data-model-id]");
|
| 40 |
+
for (const btn of buttons) {
|
| 41 |
+
if (btn.dataset.modelId === currentModelId) {
|
| 42 |
+
btn.scrollIntoView({ block: "nearest", inline: "nearest" });
|
| 43 |
+
break;
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
} else if (activeTab === 'personas') {
|
| 47 |
+
const currentPersonaId = page.params.persona as string | undefined;
|
| 48 |
+
if (!currentPersonaId) return;
|
| 49 |
+
const buttons = container.querySelectorAll<HTMLButtonElement>("button[data-persona-id]");
|
| 50 |
+
for (const btn of buttons) {
|
| 51 |
+
if (btn.dataset.personaId === currentPersonaId) {
|
| 52 |
+
btn.scrollIntoView({ block: "nearest", inline: "nearest" });
|
| 53 |
+
break;
|
| 54 |
+
}
|
| 55 |
}
|
| 56 |
}
|
|
|
|
|
|
|
|
|
|
| 57 |
}
|
| 58 |
|
| 59 |
function checkDesktopRedirect() {
|
| 60 |
if (
|
| 61 |
browser &&
|
| 62 |
isDesktop(window) &&
|
| 63 |
+
page.url.pathname === `${base}/settings`
|
|
|
|
| 64 |
) {
|
| 65 |
goto(`${base}/settings/application`);
|
| 66 |
}
|
| 67 |
}
|
| 68 |
|
| 69 |
+
// Helper to determine if we should show content or list
|
| 70 |
+
function shouldShowContent(pathname: string): boolean {
|
| 71 |
+
// Hide content (show list only) for root settings, models list, and personas list
|
| 72 |
+
if (
|
| 73 |
+
pathname === `${base}/settings` ||
|
| 74 |
+
pathname === `${base}/settings/models` ||
|
| 75 |
+
pathname === `${base}/settings/personas`
|
| 76 |
+
) {
|
| 77 |
+
return false;
|
| 78 |
+
}
|
| 79 |
+
// Show content for everything else (specific model/persona/application)
|
| 80 |
+
return true;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
onMount(() => {
|
| 84 |
+
// Show content based on current path
|
| 85 |
+
showContent = shouldShowContent(page.url.pathname);
|
| 86 |
// Initial desktop redirect check
|
| 87 |
checkDesktopRedirect();
|
| 88 |
|
| 89 |
+
// Ensure the selected item is visible in the nav
|
| 90 |
+
void scrollSelectedItemIntoView();
|
| 91 |
|
| 92 |
// Add resize listener for desktop redirect
|
| 93 |
if (browser) {
|
|
|
|
| 101 |
if (from?.url && !from.url.pathname.includes("settings")) {
|
| 102 |
previousPage = from.url.toString() || previousPage || base || "/";
|
| 103 |
}
|
| 104 |
+
// Show content based on current path
|
| 105 |
+
showContent = shouldShowContent(page.url.pathname);
|
| 106 |
// Check desktop redirect after navigation
|
| 107 |
checkDesktopRedirect();
|
| 108 |
+
// After navigation, keep the selected item in view
|
| 109 |
+
void scrollSelectedItemIntoView();
|
| 110 |
});
|
| 111 |
|
| 112 |
const settings = useSettingsStore();
|
|
|
|
| 116 |
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ");
|
| 117 |
let queryTokens = $derived(normalize(modelFilter).trim().split(/\s+/).filter(Boolean));
|
| 118 |
|
| 119 |
+
// Local filter for persona list
|
| 120 |
+
let personaFilter = $state("");
|
| 121 |
+
let personaQueryTokens = $derived(normalize(personaFilter).trim().split(/\s+/).filter(Boolean));
|
| 122 |
+
|
| 123 |
// Determine active tab based on current route
|
| 124 |
let activeTab = $derived.by(() => {
|
| 125 |
if (page.url.pathname.includes("/personas")) return "personas";
|
| 126 |
if (page.url.pathname.includes("/application")) return "application";
|
| 127 |
+
if (page.url.pathname.includes("/models")) return "models";
|
| 128 |
+
return "models"; // default
|
| 129 |
});
|
| 130 |
+
|
| 131 |
+
function createNewPersona() {
|
| 132 |
+
const newPersona: Persona = {
|
| 133 |
+
id: v4(),
|
| 134 |
+
name: "New Persona",
|
| 135 |
+
occupation: "",
|
| 136 |
+
stance: "",
|
| 137 |
+
prompt: "",
|
| 138 |
+
isDefault: false,
|
| 139 |
+
createdAt: new Date(),
|
| 140 |
+
updatedAt: new Date(),
|
| 141 |
+
};
|
| 142 |
+
|
| 143 |
+
$settings.personas = [...$settings.personas, newPersona];
|
| 144 |
+
// Navigate to the new persona
|
| 145 |
+
goto(`${base}/settings/personas/${newPersona.id}`);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
function handlePersonaDoubleClick(personaId: string) {
|
| 149 |
+
// Toggle the persona on double-click
|
| 150 |
+
const isActive = $settings.activePersonas.includes(personaId);
|
| 151 |
+
if (isActive) {
|
| 152 |
+
// Prevent deactivating the last active persona
|
| 153 |
+
if ($settings.activePersonas.length === 1) {
|
| 154 |
+
alert("At least one persona must be active.");
|
| 155 |
+
return;
|
| 156 |
+
}
|
| 157 |
+
settings.instantSet({ activePersonas: $settings.activePersonas.filter(id => id !== personaId) });
|
| 158 |
+
} else {
|
| 159 |
+
settings.instantSet({ activePersonas: [...$settings.activePersonas, personaId] });
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
</script>
|
| 163 |
|
| 164 |
<div
|
|
|
|
| 166 |
>
|
| 167 |
<div class="col-span-1 mb-3 flex flex-col gap-3 md:col-span-3 md:mb-4">
|
| 168 |
<div class="flex items-center justify-between">
|
| 169 |
+
{#if browser && !isDesktop(window)}
|
| 170 |
+
{#if showContent && (activeTab === 'models' || activeTab === 'personas')}
|
| 171 |
+
<!-- Detail view: show only back button -->
|
| 172 |
+
<button
|
| 173 |
+
class="btn rounded-lg"
|
| 174 |
+
aria-label="Back to list"
|
| 175 |
+
onclick={() => {
|
| 176 |
+
if (activeTab === 'models') {
|
| 177 |
+
goto(`${base}/settings/models`);
|
| 178 |
+
} else if (activeTab === 'personas') {
|
| 179 |
+
goto(`${base}/settings/personas`);
|
| 180 |
+
}
|
| 181 |
+
}}
|
| 182 |
+
>
|
| 183 |
+
<CarbonChevronLeft
|
| 184 |
+
class="text-xl text-gray-900 hover:text-black dark:text-gray-200 dark:hover:text-white"
|
| 185 |
+
/>
|
| 186 |
+
</button>
|
| 187 |
+
{:else}
|
| 188 |
+
<!-- List view or application: show only X button -->
|
| 189 |
+
<button
|
| 190 |
+
class="btn rounded-lg"
|
| 191 |
+
aria-label="Close settings"
|
| 192 |
+
onclick={() => {
|
| 193 |
+
goto(previousPage);
|
| 194 |
+
}}
|
| 195 |
+
>
|
| 196 |
+
<CarbonClose
|
| 197 |
+
class="text-xl text-gray-900 hover:text-black dark:text-gray-200 dark:hover:text-white"
|
| 198 |
+
/>
|
| 199 |
+
</button>
|
| 200 |
+
{/if}
|
| 201 |
+
{/if}
|
| 202 |
+
<h2 class="left-0 right-0 mx-auto w-fit text-center text-xl font-bold md:hidden">Settings</h2>
|
| 203 |
+
{#if browser && isDesktop(window)}
|
| 204 |
+
<!-- Desktop: always show X button on the right -->
|
| 205 |
<button
|
| 206 |
+
class="btn rounded-lg"
|
| 207 |
+
aria-label="Close settings"
|
| 208 |
onclick={() => {
|
| 209 |
+
goto(previousPage);
|
|
|
|
| 210 |
}}
|
| 211 |
>
|
| 212 |
+
<CarbonClose
|
| 213 |
class="text-xl text-gray-900 hover:text-black dark:text-gray-200 dark:hover:text-white"
|
| 214 |
/>
|
| 215 |
</button>
|
| 216 |
+
{:else if showContent && (activeTab === 'models' || activeTab === 'personas')}
|
| 217 |
+
<!-- Mobile detail view: placeholder to maintain layout -->
|
| 218 |
+
<div class="size-8"></div>
|
| 219 |
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
</div>
|
| 221 |
|
| 222 |
<!-- Tab Navigation -->
|
| 223 |
+
<div class="flex gap-2 border-b border-gray-200 dark:border-gray-700" class:max-md:hidden={showContent && browser}>
|
| 224 |
<button
|
| 225 |
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors {activeTab === 'models'
|
| 226 |
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
| 227 |
: 'border-transparent text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'}"
|
| 228 |
+
onclick={() => {
|
| 229 |
+
// On mobile list view, go to list; otherwise go to active item detail
|
| 230 |
+
if (browser && !isDesktop(window) && !showContent) {
|
| 231 |
+
goto(`${base}/settings/models`);
|
| 232 |
+
} else {
|
| 233 |
+
goto(`${base}/settings/models/${$settings.activeModel || data.models[0]?.id || ''}`);
|
| 234 |
+
}
|
| 235 |
+
}}
|
| 236 |
>
|
| 237 |
Models
|
| 238 |
</button>
|
|
|
|
| 240 |
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors {activeTab === 'personas'
|
| 241 |
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
| 242 |
: 'border-transparent text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'}"
|
| 243 |
+
onclick={() => {
|
| 244 |
+
// On mobile list view, go to list; otherwise go to active item detail
|
| 245 |
+
if (browser && !isDesktop(window) && !showContent) {
|
| 246 |
+
goto(`${base}/settings/personas`);
|
| 247 |
+
} else {
|
| 248 |
+
goto(`${base}/settings/personas/${$settings.activePersonas[0] || $settings.personas[0]?.id || ''}`);
|
| 249 |
+
}
|
| 250 |
+
}}
|
| 251 |
>
|
| 252 |
Personas
|
| 253 |
</button>
|
|
|
|
| 286 |
}) as model}
|
| 287 |
<button
|
| 288 |
type="button"
|
| 289 |
+
onclick={() => goto(`${base}/settings/models/${model.id}`)}
|
| 290 |
class="group flex h-9 w-full flex-none items-center gap-1 rounded-lg px-3 text-[13px] text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800/60 md:rounded-xl md:px-3 {model.id ===
|
| 291 |
page.params.model
|
| 292 |
? '!bg-gray-100 !text-gray-800 dark:!bg-gray-700 dark:!text-gray-200'
|
|
|
|
| 323 |
{/each}
|
| 324 |
</div>
|
| 325 |
{/if}
|
| 326 |
+
{#if !(showContent && browser && !isDesktop(window)) && activeTab === 'personas'}
|
| 327 |
+
<div
|
| 328 |
+
class="scrollbar-custom col-span-1 flex flex-col overflow-y-auto whitespace-nowrap rounded-r-xl bg-gradient-to-l from-gray-50 to-10% dark:from-gray-700/40 max-md:-mx-4 max-md:h-full md:pr-6"
|
| 329 |
+
class:max-md:hidden={showContent && browser}
|
| 330 |
+
bind:this={navContainer}
|
| 331 |
+
>
|
| 332 |
+
<!-- Filter input -->
|
| 333 |
+
<div class="px-2 py-2">
|
| 334 |
+
<input
|
| 335 |
+
bind:value={personaFilter}
|
| 336 |
+
type="search"
|
| 337 |
+
placeholder="Search by name"
|
| 338 |
+
aria-label="Search personas by name"
|
| 339 |
+
class="w-full rounded-full border border-gray-300 bg-white px-4 py-1 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:placeholder:text-gray-500 dark:focus:ring-gray-700"
|
| 340 |
+
/>
|
| 341 |
+
</div>
|
| 342 |
+
|
| 343 |
+
{#each $settings.personas
|
| 344 |
+
.filter((persona) => {
|
| 345 |
+
const haystack = normalize(`${persona.name} ${persona.occupation ?? ""} ${persona.stance ?? ""}`);
|
| 346 |
+
return personaQueryTokens.every((q) => haystack.includes(q));
|
| 347 |
+
}) as persona (persona.id)}
|
| 348 |
+
<button
|
| 349 |
+
type="button"
|
| 350 |
+
onclick={() => goto(`${base}/settings/personas/${persona.id}`)}
|
| 351 |
+
ondblclick={() => handlePersonaDoubleClick(persona.id)}
|
| 352 |
+
class="group flex h-9 w-full flex-none items-center gap-1 rounded-lg px-3 text-[13px] text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800/60 md:rounded-xl md:px-3 {persona.id ===
|
| 353 |
+
page.params.persona
|
| 354 |
+
? '!bg-gray-100 !text-gray-800 dark:!bg-gray-700 dark:!text-gray-200'
|
| 355 |
+
: ''}"
|
| 356 |
+
data-persona-id={persona.id}
|
| 357 |
+
aria-label="Select {persona.name}"
|
| 358 |
+
title="Double-click to activate"
|
| 359 |
+
>
|
| 360 |
+
<div class="mr-auto flex items-center gap-1 truncate">
|
| 361 |
+
<span class="truncate">{persona.name}</span>
|
| 362 |
+
</div>
|
| 363 |
+
|
| 364 |
+
{#if $settings.activePersonas.includes(persona.id)}
|
| 365 |
+
<div class="size-2 rounded-full bg-black dark:bg-white" title="Active persona"></div>
|
| 366 |
+
{/if}
|
| 367 |
+
</button>
|
| 368 |
+
{/each}
|
| 369 |
+
|
| 370 |
+
<button
|
| 371 |
+
type="button"
|
| 372 |
+
onclick={createNewPersona}
|
| 373 |
+
class="group sticky bottom-0 mt-1 flex h-9 w-full flex-none items-center justify-center gap-1 rounded-lg bg-white px-3 text-[13px] text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-800/60 md:rounded-xl md:px-3"
|
| 374 |
+
aria-label="Create new persona"
|
| 375 |
+
>
|
| 376 |
+
+ New Persona
|
| 377 |
+
</button>
|
| 378 |
+
</div>
|
| 379 |
+
{/if}
|
| 380 |
{#if showContent}
|
| 381 |
<div
|
| 382 |
+
class="scrollbar-custom col-span-1 w-full overflow-y-auto overflow-x-clip px-1 {activeTab === 'models' || activeTab === 'personas' ? 'md:col-span-2' : 'md:col-span-3'} md:row-span-2"
|
| 383 |
class:max-md:hidden={!showContent && browser}
|
| 384 |
>
|
| 385 |
{@render children?.()}
|
src/routes/settings/(nav)/+page.svelte
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import { browser } from "$app/environment";
|
| 3 |
+
import { base } from "$app/paths";
|
| 4 |
+
import { goto } from "$app/navigation";
|
| 5 |
+
import { onMount } from "svelte";
|
| 6 |
+
import { isDesktop } from "$lib/utils/isDesktop";
|
| 7 |
+
import type { PageData } from "./$types";
|
| 8 |
+
|
| 9 |
+
let { data }: { data: PageData } = $props();
|
| 10 |
+
|
| 11 |
+
onMount(() => {
|
| 12 |
+
// On desktop, redirect to application settings
|
| 13 |
+
if (browser && isDesktop(window)) {
|
| 14 |
+
goto(`${base}/settings/application`, { replaceState: true });
|
| 15 |
+
}
|
| 16 |
+
});
|
| 17 |
+
</script>
|
| 18 |
+
|
| 19 |
+
<div class="flex h-full w-full items-center justify-center text-gray-500 dark:text-gray-400">
|
| 20 |
+
{#if browser && isDesktop(window)}
|
| 21 |
+
<p>Loading...</p>
|
| 22 |
+
{:else}
|
| 23 |
+
<p>Select a tab from above</p>
|
| 24 |
+
{/if}
|
| 25 |
+
</div>
|
| 26 |
+
|
src/routes/settings/(nav)/+server.ts
CHANGED
|
@@ -6,9 +6,15 @@ import { DEFAULT_SETTINGS, type SettingsEditable } from "$lib/types/Settings";
|
|
| 6 |
const personaSchema = z.object({
|
| 7 |
id: z.string().min(1),
|
| 8 |
name: z.string().min(1).max(100),
|
| 9 |
-
|
|
|
|
|
|
|
| 10 |
stance: z.string().max(200).default(""),
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
isDefault: z.boolean(),
|
| 13 |
createdAt: z.coerce.date(),
|
| 14 |
updatedAt: z.coerce.date(),
|
|
@@ -24,7 +30,7 @@ export async function POST({ request, locals }) {
|
|
| 24 |
.default(DEFAULT_SETTINGS.shareConversationsWithModelAuthors),
|
| 25 |
welcomeModalSeen: z.boolean().optional(),
|
| 26 |
activeModel: z.string().default(DEFAULT_SETTINGS.activeModel),
|
| 27 |
-
|
| 28 |
personas: z.array(personaSchema).min(1).default(DEFAULT_SETTINGS.personas),
|
| 29 |
multimodalOverrides: z.record(z.boolean()).default({}),
|
| 30 |
disableStream: z.boolean().default(false),
|
|
|
|
| 6 |
const personaSchema = z.object({
|
| 7 |
id: z.string().min(1),
|
| 8 |
name: z.string().min(1).max(100),
|
| 9 |
+
age: z.string().min(1).max(50),
|
| 10 |
+
gender: z.string().min(1).max(50),
|
| 11 |
+
jobSector: z.string().max(200).default(""),
|
| 12 |
stance: z.string().max(200).default(""),
|
| 13 |
+
communicationStyle: z.string().max(200).default(""),
|
| 14 |
+
goalInDebate: z.string().max(300).default(""),
|
| 15 |
+
incomeBracket: z.string().max(100).default(""),
|
| 16 |
+
politicalLeanings: z.string().max(100).default(""),
|
| 17 |
+
geographicContext: z.string().max(100).default(""),
|
| 18 |
isDefault: z.boolean(),
|
| 19 |
createdAt: z.coerce.date(),
|
| 20 |
updatedAt: z.coerce.date(),
|
|
|
|
| 30 |
.default(DEFAULT_SETTINGS.shareConversationsWithModelAuthors),
|
| 31 |
welcomeModalSeen: z.boolean().optional(),
|
| 32 |
activeModel: z.string().default(DEFAULT_SETTINGS.activeModel),
|
| 33 |
+
activePersonas: z.array(z.string()).min(1).default(DEFAULT_SETTINGS.activePersonas),
|
| 34 |
personas: z.array(personaSchema).min(1).default(DEFAULT_SETTINGS.personas),
|
| 35 |
multimodalOverrides: z.record(z.boolean()).default({}),
|
| 36 |
disableStream: z.boolean().default(false),
|
src/routes/settings/(nav)/models/+page.svelte
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import { browser } from "$app/environment";
|
| 3 |
+
import { base } from "$app/paths";
|
| 4 |
+
import { goto } from "$app/navigation";
|
| 5 |
+
import { onMount } from "svelte";
|
| 6 |
+
import { useSettingsStore } from "$lib/stores/settings";
|
| 7 |
+
import { isDesktop } from "$lib/utils/isDesktop";
|
| 8 |
+
import type { PageData } from "./$types";
|
| 9 |
+
|
| 10 |
+
let { data }: { data: PageData } = $props();
|
| 11 |
+
const settings = useSettingsStore();
|
| 12 |
+
|
| 13 |
+
onMount(() => {
|
| 14 |
+
// On desktop, redirect to the active model or first model
|
| 15 |
+
if (browser && isDesktop(window)) {
|
| 16 |
+
const targetId = $settings.activeModel || data.models[0]?.id || "";
|
| 17 |
+
if (targetId) {
|
| 18 |
+
goto(`${base}/settings/models/${targetId}`, { replaceState: true });
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
});
|
| 22 |
+
</script>
|
| 23 |
+
|
| 24 |
+
<div class="flex h-full w-full items-center justify-center text-gray-500 dark:text-gray-400">
|
| 25 |
+
{#if browser && isDesktop(window)}
|
| 26 |
+
<p>Loading...</p>
|
| 27 |
+
{:else}
|
| 28 |
+
<p>Select a model from the list</p>
|
| 29 |
+
{/if}
|
| 30 |
+
</div>
|
| 31 |
+
|
src/routes/settings/(nav)/{personas/[persona] → models}/+page.ts
RENAMED
|
File without changes
|
src/routes/settings/(nav)/{[...model] → models/[...model]}/+page.svelte
RENAMED
|
@@ -232,3 +232,4 @@
|
|
| 232 |
<!-- Tokenizer-based token counting disabled in this build -->
|
| 233 |
</div>
|
| 234 |
</div>
|
|
|
|
|
|
| 232 |
<!-- Tokenizer-based token counting disabled in this build -->
|
| 233 |
</div>
|
| 234 |
</div>
|
| 235 |
+
|
src/routes/settings/(nav)/{[...model] → models/[...model]}/+page.ts
RENAMED
|
File without changes
|
src/routes/settings/(nav)/personas/+page.svelte
CHANGED
|
@@ -1,273 +1,31 @@
|
|
| 1 |
<script lang="ts">
|
|
|
|
| 2 |
import { base } from "$app/paths";
|
| 3 |
import { goto } from "$app/navigation";
|
| 4 |
-
import {
|
| 5 |
import { useSettingsStore } from "$lib/stores/settings";
|
| 6 |
-
import
|
| 7 |
-
import {
|
| 8 |
-
import CarbonTrashCan from "~icons/carbon/trash-can";
|
| 9 |
|
|
|
|
| 10 |
const settings = useSettingsStore();
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
if (!id) return;
|
| 22 |
-
if (page.params.persona !== id) {
|
| 23 |
-
goto(`${base}/settings/personas/${id}`);
|
| 24 |
-
}
|
| 25 |
-
});
|
| 26 |
-
|
| 27 |
-
// Local editable copy of selected persona
|
| 28 |
-
let editableName = $state("");
|
| 29 |
-
let editableOccupation = $state("");
|
| 30 |
-
let editableStance = $state("");
|
| 31 |
-
let editablePrompt = $state("");
|
| 32 |
-
|
| 33 |
-
// Search filter
|
| 34 |
-
let personaFilter = $state("");
|
| 35 |
-
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ");
|
| 36 |
-
let queryTokens = $derived(normalize(personaFilter).trim().split(/\s+/).filter(Boolean));
|
| 37 |
-
let filteredPersonas = $derived(
|
| 38 |
-
$settings.personas.filter((persona) => {
|
| 39 |
-
const haystack = normalize(
|
| 40 |
-
`${persona.name} ${persona.occupation ?? ""} ${persona.stance ?? ""}`
|
| 41 |
-
);
|
| 42 |
-
return queryTokens.every((q) => haystack.includes(q));
|
| 43 |
-
})
|
| 44 |
-
);
|
| 45 |
-
|
| 46 |
-
// Update editable fields when selection changes
|
| 47 |
-
$effect(() => {
|
| 48 |
-
if (selectedPersona) {
|
| 49 |
-
editableName = selectedPersona.name;
|
| 50 |
-
editableOccupation = selectedPersona.occupation;
|
| 51 |
-
editableStance = selectedPersona.stance;
|
| 52 |
-
editablePrompt = selectedPersona.prompt;
|
| 53 |
-
}
|
| 54 |
-
});
|
| 55 |
-
|
| 56 |
-
function createNewPersona() {
|
| 57 |
-
const newPersona: Persona = {
|
| 58 |
-
id: v4(),
|
| 59 |
-
name: "New Persona",
|
| 60 |
-
occupation: "",
|
| 61 |
-
stance: "",
|
| 62 |
-
prompt: "",
|
| 63 |
-
isDefault: false,
|
| 64 |
-
createdAt: new Date(),
|
| 65 |
-
updatedAt: new Date(),
|
| 66 |
-
};
|
| 67 |
-
|
| 68 |
-
$settings.personas = [...$settings.personas, newPersona];
|
| 69 |
-
// Navigate to the new persona
|
| 70 |
-
goto(`${base}/settings/personas/${newPersona.id}`);
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
function savePersona() {
|
| 74 |
-
if (!selectedPersona) return;
|
| 75 |
-
|
| 76 |
-
const updatedPersonas = $settings.personas.map((p) =>
|
| 77 |
-
p.id === selectedPersona.id
|
| 78 |
-
? {
|
| 79 |
-
...p,
|
| 80 |
-
name: editableName,
|
| 81 |
-
occupation: editableOccupation,
|
| 82 |
-
stance: editableStance,
|
| 83 |
-
prompt: editablePrompt,
|
| 84 |
-
updatedAt: new Date(),
|
| 85 |
-
}
|
| 86 |
-
: p
|
| 87 |
-
);
|
| 88 |
-
|
| 89 |
-
$settings.personas = updatedPersonas;
|
| 90 |
-
}
|
| 91 |
-
|
| 92 |
-
function activatePersona() {
|
| 93 |
-
if (!selectedPersona) return;
|
| 94 |
-
if (hasChanges) savePersona();
|
| 95 |
-
settings.instantSet({ activePersona: selectedPersona.id });
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
function deletePersona() {
|
| 99 |
-
if (!selectedPersona) return;
|
| 100 |
-
|
| 101 |
-
// Can't delete if it's the only persona
|
| 102 |
-
if ($settings.personas.length === 1) {
|
| 103 |
-
alert("Cannot delete the last persona.");
|
| 104 |
-
return;
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
// Can't delete if it's the active persona
|
| 108 |
-
if (selectedPersona.id === $settings.activePersona) {
|
| 109 |
-
alert("Cannot delete the currently active persona. Please activate another persona first.");
|
| 110 |
-
return;
|
| 111 |
-
}
|
| 112 |
-
|
| 113 |
-
if (confirm(`Are you sure you want to delete "${selectedPersona.name}"?`)) {
|
| 114 |
-
const nextId = $settings.personas.find((p) => p.id !== selectedPersona!.id)?.id || null;
|
| 115 |
-
$settings.personas = $settings.personas.filter((p) => p.id !== selectedPersona.id);
|
| 116 |
-
goto(`${base}/settings/personas/${nextId ?? ""}`);
|
| 117 |
-
}
|
| 118 |
-
}
|
| 119 |
-
|
| 120 |
-
function handleDoubleClick(personaId: string) {
|
| 121 |
-
if (selectedPersona && hasChanges) savePersona();
|
| 122 |
-
if (personaId !== $settings.activePersona) {
|
| 123 |
-
settings.instantSet({ activePersona: personaId });
|
| 124 |
-
}
|
| 125 |
-
goto(`${base}/settings/personas/${personaId}`);
|
| 126 |
-
}
|
| 127 |
-
|
| 128 |
-
let hasChanges = $derived(
|
| 129 |
-
selectedPersona &&
|
| 130 |
-
(editableName !== selectedPersona.name ||
|
| 131 |
-
editableOccupation !== selectedPersona.occupation ||
|
| 132 |
-
editableStance !== selectedPersona.stance ||
|
| 133 |
-
editablePrompt !== selectedPersona.prompt)
|
| 134 |
-
);
|
| 135 |
</script>
|
| 136 |
|
| 137 |
-
<div class="
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
<div class="px-2 py-2">
|
| 144 |
-
<input
|
| 145 |
-
bind:value={personaFilter}
|
| 146 |
-
type="search"
|
| 147 |
-
placeholder="Search by name"
|
| 148 |
-
aria-label="Search personas by name"
|
| 149 |
-
class="w-full rounded-full border border-gray-300 bg-white px-4 py-1 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:placeholder:text-gray-500 dark:focus:ring-gray-700"
|
| 150 |
-
/>
|
| 151 |
-
</div>
|
| 152 |
-
|
| 153 |
-
{#each filteredPersonas as persona (persona.id)}
|
| 154 |
-
<button
|
| 155 |
-
type="button"
|
| 156 |
-
onclick={() => goto(`${base}/settings/personas/${persona.id}`)}
|
| 157 |
-
ondblclick={() => handleDoubleClick(persona.id)}
|
| 158 |
-
class="group relative flex h-9 w-full flex-none items-center gap-1 rounded-lg px-3 text-[13px] transition-all {selectedPersonaId ===
|
| 159 |
-
persona.id
|
| 160 |
-
? '!bg-gray-100 !text-gray-800 dark:!bg-gray-700 dark:!text-gray-200'
|
| 161 |
-
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800/60'} md:rounded-xl md:px-3"
|
| 162 |
-
aria-label="Select {persona.name}"
|
| 163 |
-
>
|
| 164 |
-
<div class="mr-auto flex items-center gap-1 truncate">
|
| 165 |
-
<span class="truncate">{persona.name}</span>
|
| 166 |
-
</div>
|
| 167 |
-
{#if persona.id === $settings.activePersona}
|
| 168 |
-
<div
|
| 169 |
-
class="flex h-[21px] items-center rounded-md bg-black/90 px-2 text-[11px] font-semibold leading-none text-white dark:bg-white dark:text-black"
|
| 170 |
-
>
|
| 171 |
-
Active
|
| 172 |
-
</div>
|
| 173 |
-
{/if}
|
| 174 |
-
</button>
|
| 175 |
-
{/each}
|
| 176 |
-
|
| 177 |
-
<button
|
| 178 |
-
type="button"
|
| 179 |
-
onclick={createNewPersona}
|
| 180 |
-
class="group sticky bottom-0 mt-1 flex h-9 w-full flex-none items-center justify-center gap-1 rounded-lg bg-white px-3 text-[13px] text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-800/60 md:rounded-xl md:px-3"
|
| 181 |
-
aria-label="Create new persona"
|
| 182 |
-
>
|
| 183 |
-
+ New Persona
|
| 184 |
-
</button>
|
| 185 |
-
</div>
|
| 186 |
-
|
| 187 |
-
<!-- Right Panel - Persona Detail View -->
|
| 188 |
-
{#if selectedPersona}
|
| 189 |
-
<div class="scrollbar-custom col-span-2 flex h-full w-full flex-col overflow-y-auto px-1">
|
| 190 |
-
<div class="grid grid-cols-1 gap-4 pb-24 md:grid-cols-3 md:gap-6">
|
| 191 |
-
<div class="flex flex-col gap-2 md:col-span-1">
|
| 192 |
-
<label for="persona-name" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 193 |
-
Persona Name
|
| 194 |
-
</label>
|
| 195 |
-
<input
|
| 196 |
-
id="persona-name"
|
| 197 |
-
type="text"
|
| 198 |
-
bind:value={editableName}
|
| 199 |
-
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900"
|
| 200 |
-
maxlength="100"
|
| 201 |
-
/>
|
| 202 |
-
</div>
|
| 203 |
-
|
| 204 |
-
<div class="flex flex-col gap-2 md:col-span-1">
|
| 205 |
-
<label for="occupation" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 206 |
-
Role
|
| 207 |
-
</label>
|
| 208 |
-
<input
|
| 209 |
-
id="occupation"
|
| 210 |
-
type="text"
|
| 211 |
-
bind:value={editableOccupation}
|
| 212 |
-
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900"
|
| 213 |
-
maxlength="200"
|
| 214 |
-
/>
|
| 215 |
-
</div>
|
| 216 |
-
|
| 217 |
-
<div class="flex flex-col gap-2 md:col-span-1">
|
| 218 |
-
<label for="stance" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 219 |
-
Stance
|
| 220 |
-
</label>
|
| 221 |
-
<input
|
| 222 |
-
id="stance"
|
| 223 |
-
type="text"
|
| 224 |
-
bind:value={editableStance}
|
| 225 |
-
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900"
|
| 226 |
-
maxlength="200"
|
| 227 |
-
/>
|
| 228 |
-
</div>
|
| 229 |
-
|
| 230 |
-
<div class="flex flex-col gap-2 md:col-span-2">
|
| 231 |
-
<label for="system-prompt" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 232 |
-
System Prompt
|
| 233 |
-
</label>
|
| 234 |
-
<textarea
|
| 235 |
-
id="system-prompt"
|
| 236 |
-
bind:value={editablePrompt}
|
| 237 |
-
rows="18"
|
| 238 |
-
class="w-full resize-none rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900"
|
| 239 |
-
maxlength="10000"
|
| 240 |
-
></textarea>
|
| 241 |
-
</div>
|
| 242 |
-
</div>
|
| 243 |
-
|
| 244 |
-
<!-- Sticky buttons -->
|
| 245 |
-
<div
|
| 246 |
-
class="sticky bottom-0 flex flex-wrap gap-2 border-t border-gray-200 bg-white py-4 dark:border-gray-700 dark:bg-gray-900"
|
| 247 |
-
>
|
| 248 |
-
<button
|
| 249 |
-
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
| 250 |
-
onclick={activatePersona}
|
| 251 |
-
disabled={selectedPersona.id === $settings.activePersona}
|
| 252 |
-
>
|
| 253 |
-
{selectedPersona.id === $settings.activePersona ? "Active" : "Activate"}
|
| 254 |
-
</button>
|
| 255 |
-
<button
|
| 256 |
-
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
| 257 |
-
onclick={savePersona}
|
| 258 |
-
disabled={!hasChanges}
|
| 259 |
-
>
|
| 260 |
-
Save
|
| 261 |
-
</button>
|
| 262 |
-
<button
|
| 263 |
-
class="ml-auto flex items-center gap-2 rounded-lg border border-red-300 bg-white px-4 py-2 text-sm font-semibold text-red-600 hover:bg-red-50 dark:border-red-700 dark:bg-gray-800 dark:hover:bg-red-900/20"
|
| 264 |
-
onclick={deletePersona}
|
| 265 |
-
>
|
| 266 |
-
<CarbonTrashCan />
|
| 267 |
-
Delete
|
| 268 |
-
</button>
|
| 269 |
-
</div>
|
| 270 |
-
</div>
|
| 271 |
-
{/if}
|
| 272 |
</div>
|
| 273 |
|
|
|
|
| 1 |
<script lang="ts">
|
| 2 |
+
import { browser } from "$app/environment";
|
| 3 |
import { base } from "$app/paths";
|
| 4 |
import { goto } from "$app/navigation";
|
| 5 |
+
import { onMount } from "svelte";
|
| 6 |
import { useSettingsStore } from "$lib/stores/settings";
|
| 7 |
+
import { isDesktop } from "$lib/utils/isDesktop";
|
| 8 |
+
import type { PageData } from "./$types";
|
|
|
|
| 9 |
|
| 10 |
+
let { data }: { data: PageData } = $props();
|
| 11 |
const settings = useSettingsStore();
|
| 12 |
|
| 13 |
+
onMount(() => {
|
| 14 |
+
// On desktop, redirect to the first active persona or first persona
|
| 15 |
+
if (browser && isDesktop(window)) {
|
| 16 |
+
const targetId = $settings.activePersonas[0] || $settings.personas[0]?.id || "";
|
| 17 |
+
if (targetId) {
|
| 18 |
+
goto(`${base}/settings/personas/${targetId}`, { replaceState: true });
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
</script>
|
| 23 |
|
| 24 |
+
<div class="flex h-full w-full items-center justify-center text-gray-500 dark:text-gray-400">
|
| 25 |
+
{#if browser && isDesktop(window)}
|
| 26 |
+
<p>Loading...</p>
|
| 27 |
+
{:else}
|
| 28 |
+
<p>Select a persona from the list</p>
|
| 29 |
+
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
</div>
|
| 31 |
|
src/routes/settings/(nav)/personas/+page.ts
CHANGED
|
@@ -1,15 +1,4 @@
|
|
| 1 |
-
import { base } from "$app/paths";
|
| 2 |
-
import { redirect } from "@sveltejs/kit";
|
| 3 |
-
|
| 4 |
export async function load({ parent }) {
|
| 5 |
const data = await parent();
|
| 6 |
-
|
| 7 |
-
const first = data.settings?.personas?.[0]?.id;
|
| 8 |
-
if (active) {
|
| 9 |
-
redirect(302, `${base}/settings/personas/${active}`);
|
| 10 |
-
}
|
| 11 |
-
if (first) {
|
| 12 |
-
redirect(302, `${base}/settings/personas/${first}`);
|
| 13 |
-
}
|
| 14 |
-
redirect(302, `${base}/settings`);
|
| 15 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
export async function load({ parent }) {
|
| 2 |
const data = await parent();
|
| 3 |
+
return data;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
}
|
src/routes/settings/(nav)/personas/[...persona]/+page.svelte
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import { base } from "$app/paths";
|
| 3 |
+
import { goto } from "$app/navigation";
|
| 4 |
+
import { page } from "$app/state";
|
| 5 |
+
import { useSettingsStore } from "$lib/stores/settings";
|
| 6 |
+
import CarbonTrashCan from "~icons/carbon/trash-can";
|
| 7 |
+
import CarbonEdit from "~icons/carbon/edit";
|
| 8 |
+
|
| 9 |
+
const settings = useSettingsStore();
|
| 10 |
+
|
| 11 |
+
// Selected persona comes from the URL param to mirror model routing
|
| 12 |
+
let selectedPersonaId = $derived(page.params.persona ?? $settings.personas[0]?.id ?? null);
|
| 13 |
+
let selectedPersona = $derived(
|
| 14 |
+
$settings.personas.find((p) => p.id === selectedPersonaId) ?? $settings.personas[0]
|
| 15 |
+
);
|
| 16 |
+
|
| 17 |
+
// Local editable copy of selected persona
|
| 18 |
+
let editableName = $state("");
|
| 19 |
+
let editableAge = $state("");
|
| 20 |
+
let editableGender = $state("");
|
| 21 |
+
let editableJobSector = $state("");
|
| 22 |
+
let editableStance = $state("");
|
| 23 |
+
let editableCommunicationStyle = $state("");
|
| 24 |
+
let editableGoalInDebate = $state("");
|
| 25 |
+
let editableIncomeBracket = $state("");
|
| 26 |
+
let editablePoliticalLeanings = $state("");
|
| 27 |
+
let editableGeographicContext = $state("");
|
| 28 |
+
|
| 29 |
+
// Update editable fields when selection changes
|
| 30 |
+
$effect(() => {
|
| 31 |
+
if (selectedPersona) {
|
| 32 |
+
editableName = selectedPersona.name;
|
| 33 |
+
editableAge = selectedPersona.age;
|
| 34 |
+
editableGender = selectedPersona.gender;
|
| 35 |
+
editableJobSector = selectedPersona.jobSector || "";
|
| 36 |
+
editableStance = selectedPersona.stance || "";
|
| 37 |
+
editableCommunicationStyle = selectedPersona.communicationStyle || "";
|
| 38 |
+
editableGoalInDebate = selectedPersona.goalInDebate || "";
|
| 39 |
+
editableIncomeBracket = selectedPersona.incomeBracket || "";
|
| 40 |
+
editablePoliticalLeanings = selectedPersona.politicalLeanings || "";
|
| 41 |
+
editableGeographicContext = selectedPersona.geographicContext || "";
|
| 42 |
+
}
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
function savePersona() {
|
| 46 |
+
if (!selectedPersona) return;
|
| 47 |
+
|
| 48 |
+
const updatedPersonas = $settings.personas.map((p) =>
|
| 49 |
+
p.id === selectedPersona.id
|
| 50 |
+
? {
|
| 51 |
+
...p,
|
| 52 |
+
name: editableName,
|
| 53 |
+
age: editableAge,
|
| 54 |
+
gender: editableGender,
|
| 55 |
+
jobSector: editableJobSector,
|
| 56 |
+
stance: editableStance,
|
| 57 |
+
communicationStyle: editableCommunicationStyle,
|
| 58 |
+
goalInDebate: editableGoalInDebate,
|
| 59 |
+
incomeBracket: editableIncomeBracket,
|
| 60 |
+
politicalLeanings: editablePoliticalLeanings,
|
| 61 |
+
geographicContext: editableGeographicContext,
|
| 62 |
+
updatedAt: new Date(),
|
| 63 |
+
}
|
| 64 |
+
: p
|
| 65 |
+
);
|
| 66 |
+
|
| 67 |
+
$settings.personas = updatedPersonas;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
function togglePersona() {
|
| 71 |
+
if (!selectedPersona) return;
|
| 72 |
+
if (hasChanges) savePersona();
|
| 73 |
+
|
| 74 |
+
const isActive = $settings.activePersonas.includes(selectedPersona.id);
|
| 75 |
+
if (isActive) {
|
| 76 |
+
// Prevent deactivating the last active persona
|
| 77 |
+
if ($settings.activePersonas.length === 1) {
|
| 78 |
+
alert("At least one persona must be active.");
|
| 79 |
+
return;
|
| 80 |
+
}
|
| 81 |
+
// Deactivate: remove from array
|
| 82 |
+
settings.instantSet({ activePersonas: $settings.activePersonas.filter(id => id !== selectedPersona.id) });
|
| 83 |
+
} else {
|
| 84 |
+
// Activate: add to array
|
| 85 |
+
settings.instantSet({ activePersonas: [...$settings.activePersonas, selectedPersona.id] });
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
function deletePersona() {
|
| 90 |
+
if (!selectedPersona) return;
|
| 91 |
+
|
| 92 |
+
// Can't delete if it's the only persona
|
| 93 |
+
if ($settings.personas.length === 1) {
|
| 94 |
+
alert("Cannot delete the last persona.");
|
| 95 |
+
return;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
// Can't delete if it's an active persona
|
| 99 |
+
if ($settings.activePersonas.includes(selectedPersona.id)) {
|
| 100 |
+
alert("Cannot delete an active persona. Please deactivate it first.");
|
| 101 |
+
return;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
if (confirm(`Are you sure you want to delete "${selectedPersona.name}"?`)) {
|
| 105 |
+
const nextId = $settings.personas.find((p) => p.id !== selectedPersona!.id)?.id || null;
|
| 106 |
+
$settings.personas = $settings.personas.filter((p) => p.id !== selectedPersona.id);
|
| 107 |
+
goto(`${base}/settings/personas/${nextId ?? ""}`);
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
let hasChanges = $derived(
|
| 112 |
+
selectedPersona &&
|
| 113 |
+
(editableName !== selectedPersona.name ||
|
| 114 |
+
editableAge !== selectedPersona.age ||
|
| 115 |
+
editableGender !== selectedPersona.gender ||
|
| 116 |
+
editableJobSector !== (selectedPersona.jobSector || "") ||
|
| 117 |
+
editableStance !== (selectedPersona.stance || "") ||
|
| 118 |
+
editableCommunicationStyle !== (selectedPersona.communicationStyle || "") ||
|
| 119 |
+
editableGoalInDebate !== (selectedPersona.goalInDebate || "") ||
|
| 120 |
+
editableIncomeBracket !== (selectedPersona.incomeBracket || "") ||
|
| 121 |
+
editablePoliticalLeanings !== (selectedPersona.politicalLeanings || "") ||
|
| 122 |
+
editableGeographicContext !== (selectedPersona.geographicContext || ""))
|
| 123 |
+
);
|
| 124 |
+
|
| 125 |
+
// Function to show datalist on focus
|
| 126 |
+
function showDatalist(event: FocusEvent) {
|
| 127 |
+
const input = event.target as HTMLInputElement;
|
| 128 |
+
// Dispatch a synthetic input event to trigger the datalist dropdown
|
| 129 |
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
| 130 |
+
}
|
| 131 |
+
</script>
|
| 132 |
+
|
| 133 |
+
<!-- Persona Detail View -->
|
| 134 |
+
{#if selectedPersona}
|
| 135 |
+
<div class="flex h-full w-full flex-col overflow-hidden">
|
| 136 |
+
<div class="flex flex-col gap-6">
|
| 137 |
+
<!-- Group 1: Core Identity -->
|
| 138 |
+
<div>
|
| 139 |
+
<h3 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">Core Identity</h3>
|
| 140 |
+
<div class="grid grid-cols-1 gap-4 md:grid-cols-3 md:gap-6">
|
| 141 |
+
<div class="flex flex-col gap-2">
|
| 142 |
+
<label for="persona-name" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 143 |
+
Name <span class="text-red-500">*</span>
|
| 144 |
+
</label>
|
| 145 |
+
<div class="relative">
|
| 146 |
+
<input
|
| 147 |
+
id="persona-name"
|
| 148 |
+
type="text"
|
| 149 |
+
bind:value={editableName}
|
| 150 |
+
required
|
| 151 |
+
class="peer w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 pr-9 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 152 |
+
maxlength="100"
|
| 153 |
+
/>
|
| 154 |
+
<CarbonEdit class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 opacity-50 transition-opacity peer-focus:opacity-0 dark:text-gray-500" />
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
<div class="flex flex-col gap-2">
|
| 159 |
+
<label for="age" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 160 |
+
Age <span class="text-red-500">*</span>
|
| 161 |
+
</label>
|
| 162 |
+
<div class="relative">
|
| 163 |
+
<input
|
| 164 |
+
id="age"
|
| 165 |
+
type="text"
|
| 166 |
+
list="age-options"
|
| 167 |
+
bind:value={editableAge}
|
| 168 |
+
onfocus={showDatalist}
|
| 169 |
+
required
|
| 170 |
+
class="peer w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 pr-9 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 171 |
+
maxlength="50"
|
| 172 |
+
/>
|
| 173 |
+
<datalist id="age-options">
|
| 174 |
+
<option value="18-25">18-25</option>
|
| 175 |
+
<option value="26-35">26-35</option>
|
| 176 |
+
<option value="36-45">36-45</option>
|
| 177 |
+
<option value="46-55">46-55</option>
|
| 178 |
+
<option value="56-65">56-65</option>
|
| 179 |
+
<option value="66+">66+</option>
|
| 180 |
+
</datalist>
|
| 181 |
+
<CarbonEdit class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 opacity-50 transition-opacity peer-focus:opacity-0 dark:text-gray-500" />
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
<div class="flex flex-col gap-2">
|
| 186 |
+
<label for="gender" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 187 |
+
Gender <span class="text-red-500">*</span>
|
| 188 |
+
</label>
|
| 189 |
+
<div class="relative">
|
| 190 |
+
<input
|
| 191 |
+
id="gender"
|
| 192 |
+
type="text"
|
| 193 |
+
list="gender-options"
|
| 194 |
+
bind:value={editableGender}
|
| 195 |
+
onfocus={showDatalist}
|
| 196 |
+
required
|
| 197 |
+
class="peer w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 pr-9 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 198 |
+
maxlength="50"
|
| 199 |
+
/>
|
| 200 |
+
<datalist id="gender-options">
|
| 201 |
+
<option value="Male">Male</option>
|
| 202 |
+
<option value="Female">Female</option>
|
| 203 |
+
<option value="Prefer not to say">Prefer not to say</option>
|
| 204 |
+
</datalist>
|
| 205 |
+
<CarbonEdit class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 opacity-50 transition-opacity peer-focus:opacity-0 dark:text-gray-500" />
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
<!-- Group 2: Professional & Stance -->
|
| 212 |
+
<div>
|
| 213 |
+
<h3 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">Professional & Stance</h3>
|
| 214 |
+
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6">
|
| 215 |
+
<div class="flex flex-col gap-2">
|
| 216 |
+
<label for="job-sector" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 217 |
+
Job Sector
|
| 218 |
+
</label>
|
| 219 |
+
<div class="relative">
|
| 220 |
+
<input
|
| 221 |
+
id="job-sector"
|
| 222 |
+
type="text"
|
| 223 |
+
list="job-sector-options"
|
| 224 |
+
bind:value={editableJobSector}
|
| 225 |
+
onfocus={showDatalist}
|
| 226 |
+
class="peer w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 pr-9 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 227 |
+
maxlength="200"
|
| 228 |
+
/>
|
| 229 |
+
<datalist id="job-sector-options">
|
| 230 |
+
<option value="Healthcare provider">Healthcare provider</option>
|
| 231 |
+
<option value="Small business owner">Small business owner</option>
|
| 232 |
+
<option value="Tech worker">Tech worker</option>
|
| 233 |
+
<option value="Teacher">Teacher</option>
|
| 234 |
+
<option value="Unemployed/Retired">Unemployed/Retired</option>
|
| 235 |
+
<option value="Government worker">Government worker</option>
|
| 236 |
+
<option value="Student">Student</option>
|
| 237 |
+
</datalist>
|
| 238 |
+
<CarbonEdit class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 opacity-50 transition-opacity peer-focus:opacity-0 dark:text-gray-500" />
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
|
| 242 |
+
<div class="flex flex-col gap-2">
|
| 243 |
+
<label for="stance" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 244 |
+
Stance
|
| 245 |
+
</label>
|
| 246 |
+
<div class="relative">
|
| 247 |
+
<input
|
| 248 |
+
id="stance"
|
| 249 |
+
type="text"
|
| 250 |
+
list="stance-options"
|
| 251 |
+
bind:value={editableStance}
|
| 252 |
+
onfocus={showDatalist}
|
| 253 |
+
class="peer w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 pr-9 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 254 |
+
maxlength="200"
|
| 255 |
+
/>
|
| 256 |
+
<datalist id="stance-options">
|
| 257 |
+
<option value="In Favor of Medicare for All">In Favor of Medicare for All</option>
|
| 258 |
+
<option value="Hardline Insurance Advocate">Hardline Insurance Advocate</option>
|
| 259 |
+
<option value="Improvement of Current System">Improvement of Current System</option>
|
| 260 |
+
<option value="Public Option Supporter">Public Option Supporter</option>
|
| 261 |
+
<option value="Status Quo">Status Quo</option>
|
| 262 |
+
</datalist>
|
| 263 |
+
<CarbonEdit class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 opacity-50 transition-opacity peer-focus:opacity-0 dark:text-gray-500" />
|
| 264 |
+
</div>
|
| 265 |
+
</div>
|
| 266 |
+
</div>
|
| 267 |
+
</div>
|
| 268 |
+
|
| 269 |
+
<!-- Group 3: Communication & Goals -->
|
| 270 |
+
<div>
|
| 271 |
+
<h3 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">Communication & Goals</h3>
|
| 272 |
+
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6">
|
| 273 |
+
<div class="flex flex-col gap-2">
|
| 274 |
+
<label for="communication-style" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 275 |
+
Communication Style
|
| 276 |
+
</label>
|
| 277 |
+
<div class="relative">
|
| 278 |
+
<input
|
| 279 |
+
id="communication-style"
|
| 280 |
+
type="text"
|
| 281 |
+
list="communication-style-options"
|
| 282 |
+
bind:value={editableCommunicationStyle}
|
| 283 |
+
onfocus={showDatalist}
|
| 284 |
+
class="peer w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 pr-9 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 285 |
+
maxlength="200"
|
| 286 |
+
/>
|
| 287 |
+
<datalist id="communication-style-options">
|
| 288 |
+
<option value="Direct">Direct</option>
|
| 289 |
+
<option value="Technical/Jargon use">Technical/Jargon use</option>
|
| 290 |
+
<option value="Informal">Informal</option>
|
| 291 |
+
<option value="Philosophical">Philosophical</option>
|
| 292 |
+
<option value="Pragmatic">Pragmatic</option>
|
| 293 |
+
<option value="Conversational">Conversational</option>
|
| 294 |
+
</datalist>
|
| 295 |
+
<CarbonEdit class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 opacity-50 transition-opacity peer-focus:opacity-0 dark:text-gray-500" />
|
| 296 |
+
</div>
|
| 297 |
+
</div>
|
| 298 |
+
|
| 299 |
+
<div class="flex flex-col gap-2">
|
| 300 |
+
<label for="goal-in-debate" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 301 |
+
Goal in the Debate
|
| 302 |
+
</label>
|
| 303 |
+
<div class="relative">
|
| 304 |
+
<input
|
| 305 |
+
id="goal-in-debate"
|
| 306 |
+
type="text"
|
| 307 |
+
list="goal-in-debate-options"
|
| 308 |
+
bind:value={editableGoalInDebate}
|
| 309 |
+
onfocus={showDatalist}
|
| 310 |
+
class="peer w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 pr-9 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 311 |
+
maxlength="300"
|
| 312 |
+
/>
|
| 313 |
+
<datalist id="goal-in-debate-options">
|
| 314 |
+
<option value="Keep discussion grounded">Keep discussion grounded</option>
|
| 315 |
+
<option value="Explain complexity">Explain complexity</option>
|
| 316 |
+
<option value="Advocate for change">Advocate for change</option>
|
| 317 |
+
<option value="Defend current system">Defend current system</option>
|
| 318 |
+
<option value="Find compromise">Find compromise</option>
|
| 319 |
+
</datalist>
|
| 320 |
+
<CarbonEdit class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 opacity-50 transition-opacity peer-focus:opacity-0 dark:text-gray-500" />
|
| 321 |
+
</div>
|
| 322 |
+
</div>
|
| 323 |
+
</div>
|
| 324 |
+
</div>
|
| 325 |
+
|
| 326 |
+
<!-- Group 4: Demographics -->
|
| 327 |
+
<div>
|
| 328 |
+
<h3 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">Demographics</h3>
|
| 329 |
+
<div class="grid grid-cols-1 gap-4 md:grid-cols-3 md:gap-6">
|
| 330 |
+
<div class="flex flex-col gap-2">
|
| 331 |
+
<label for="income-bracket" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 332 |
+
Income Bracket
|
| 333 |
+
</label>
|
| 334 |
+
<div class="relative">
|
| 335 |
+
<input
|
| 336 |
+
id="income-bracket"
|
| 337 |
+
type="text"
|
| 338 |
+
list="income-bracket-options"
|
| 339 |
+
bind:value={editableIncomeBracket}
|
| 340 |
+
onfocus={showDatalist}
|
| 341 |
+
class="peer w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 pr-9 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 342 |
+
maxlength="100"
|
| 343 |
+
/>
|
| 344 |
+
<datalist id="income-bracket-options">
|
| 345 |
+
<option value="Low">Low</option>
|
| 346 |
+
<option value="Middle">Middle</option>
|
| 347 |
+
<option value="High">High</option>
|
| 348 |
+
<option value="Comfortable">Comfortable</option>
|
| 349 |
+
<option value="Struggling">Struggling</option>
|
| 350 |
+
</datalist>
|
| 351 |
+
<CarbonEdit class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 opacity-50 transition-opacity peer-focus:opacity-0 dark:text-gray-500" />
|
| 352 |
+
</div>
|
| 353 |
+
</div>
|
| 354 |
+
|
| 355 |
+
<div class="flex flex-col gap-2">
|
| 356 |
+
<label for="political-leanings" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 357 |
+
Political Leanings
|
| 358 |
+
</label>
|
| 359 |
+
<div class="relative">
|
| 360 |
+
<input
|
| 361 |
+
id="political-leanings"
|
| 362 |
+
type="text"
|
| 363 |
+
list="political-leanings-options"
|
| 364 |
+
bind:value={editablePoliticalLeanings}
|
| 365 |
+
onfocus={showDatalist}
|
| 366 |
+
class="peer w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 pr-9 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 367 |
+
maxlength="100"
|
| 368 |
+
/>
|
| 369 |
+
<datalist id="political-leanings-options">
|
| 370 |
+
<option value="Liberal">Liberal</option>
|
| 371 |
+
<option value="Conservative">Conservative</option>
|
| 372 |
+
<option value="Moderate">Moderate</option>
|
| 373 |
+
<option value="Libertarian">Libertarian</option>
|
| 374 |
+
<option value="Non-affiliated">Non-affiliated</option>
|
| 375 |
+
<option value="Progressive">Progressive</option>
|
| 376 |
+
</datalist>
|
| 377 |
+
<CarbonEdit class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 opacity-50 transition-opacity peer-focus:opacity-0 dark:text-gray-500" />
|
| 378 |
+
</div>
|
| 379 |
+
</div>
|
| 380 |
+
|
| 381 |
+
<div class="flex flex-col gap-2">
|
| 382 |
+
<label for="geographic-context" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 383 |
+
Geographic Context
|
| 384 |
+
</label>
|
| 385 |
+
<div class="relative">
|
| 386 |
+
<input
|
| 387 |
+
id="geographic-context"
|
| 388 |
+
type="text"
|
| 389 |
+
list="geographic-context-options"
|
| 390 |
+
bind:value={editableGeographicContext}
|
| 391 |
+
onfocus={showDatalist}
|
| 392 |
+
class="peer w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 pr-9 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 393 |
+
maxlength="100"
|
| 394 |
+
/>
|
| 395 |
+
<datalist id="geographic-context-options">
|
| 396 |
+
<option value="Rural">Rural</option>
|
| 397 |
+
<option value="Urban">Urban</option>
|
| 398 |
+
<option value="Suburban">Suburban</option>
|
| 399 |
+
</datalist>
|
| 400 |
+
<CarbonEdit class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 opacity-50 transition-opacity peer-focus:opacity-0 dark:text-gray-500" />
|
| 401 |
+
</div>
|
| 402 |
+
</div>
|
| 403 |
+
</div>
|
| 404 |
+
</div>
|
| 405 |
+
</div>
|
| 406 |
+
|
| 407 |
+
<!-- Sticky buttons -->
|
| 408 |
+
<div
|
| 409 |
+
class="sticky bottom-0 flex flex-wrap gap-2 py-4"
|
| 410 |
+
>
|
| 411 |
+
<button
|
| 412 |
+
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
| 413 |
+
onclick={togglePersona}
|
| 414 |
+
>
|
| 415 |
+
{$settings.activePersonas.includes(selectedPersona.id) ? "Deactivate" : "Activate"}
|
| 416 |
+
</button>
|
| 417 |
+
<button
|
| 418 |
+
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
| 419 |
+
onclick={savePersona}
|
| 420 |
+
disabled={!hasChanges}
|
| 421 |
+
>
|
| 422 |
+
Save
|
| 423 |
+
</button>
|
| 424 |
+
<button
|
| 425 |
+
class="ml-auto flex items-center gap-2 rounded-lg border border-red-300 bg-white px-4 py-2 text-sm font-semibold text-red-600 hover:bg-red-50 dark:border-red-700 dark:bg-gray-800 dark:hover:bg-red-900/20"
|
| 426 |
+
onclick={deletePersona}
|
| 427 |
+
>
|
| 428 |
+
<CarbonTrashCan />
|
| 429 |
+
Delete
|
| 430 |
+
</button>
|
| 431 |
+
</div>
|
| 432 |
+
</div>
|
| 433 |
+
{/if}
|
| 434 |
+
|
| 435 |
+
|
src/routes/settings/(nav)/personas/[...persona]/+page.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export async function load({ parent }) {
|
| 2 |
+
const data = await parent();
|
| 3 |
+
return data;
|
| 4 |
+
}
|
src/routes/settings/(nav)/personas/[persona]/+page.svelte
DELETED
|
@@ -1,277 +0,0 @@
|
|
| 1 |
-
<script lang="ts">
|
| 2 |
-
import { base } from "$app/paths";
|
| 3 |
-
import { goto } from "$app/navigation";
|
| 4 |
-
import { page } from "$app/state";
|
| 5 |
-
import { useSettingsStore } from "$lib/stores/settings";
|
| 6 |
-
import type { Persona } from "$lib/types/Persona";
|
| 7 |
-
import { v4 } from "uuid";
|
| 8 |
-
import CarbonTrashCan from "~icons/carbon/trash-can";
|
| 9 |
-
import CarbonEdit from "~icons/carbon/edit";
|
| 10 |
-
|
| 11 |
-
const settings = useSettingsStore();
|
| 12 |
-
|
| 13 |
-
// Selected persona comes from the URL param to mirror model routing
|
| 14 |
-
let selectedPersonaId = $derived(page.params.persona ?? $settings.personas[0]?.id ?? null);
|
| 15 |
-
let selectedPersona = $derived(
|
| 16 |
-
$settings.personas.find((p) => p.id === selectedPersonaId) ?? $settings.personas[0]
|
| 17 |
-
);
|
| 18 |
-
|
| 19 |
-
// Local editable copy of selected persona
|
| 20 |
-
let editableName = $state("");
|
| 21 |
-
let editableOccupation = $state("");
|
| 22 |
-
let editableStance = $state("");
|
| 23 |
-
let editablePrompt = $state("");
|
| 24 |
-
|
| 25 |
-
// Search filter
|
| 26 |
-
let personaFilter = $state("");
|
| 27 |
-
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ");
|
| 28 |
-
let queryTokens = $derived(normalize(personaFilter).trim().split(/\s+/).filter(Boolean));
|
| 29 |
-
let filteredPersonas = $derived(
|
| 30 |
-
$settings.personas.filter((persona) => {
|
| 31 |
-
const haystack = normalize(
|
| 32 |
-
`${persona.name} ${persona.occupation ?? ""} ${persona.stance ?? ""}`
|
| 33 |
-
);
|
| 34 |
-
return queryTokens.every((q) => haystack.includes(q));
|
| 35 |
-
})
|
| 36 |
-
);
|
| 37 |
-
|
| 38 |
-
// Update editable fields when selection changes
|
| 39 |
-
$effect(() => {
|
| 40 |
-
if (selectedPersona) {
|
| 41 |
-
editableName = selectedPersona.name;
|
| 42 |
-
editableOccupation = selectedPersona.occupation;
|
| 43 |
-
editableStance = selectedPersona.stance;
|
| 44 |
-
editablePrompt = selectedPersona.prompt;
|
| 45 |
-
}
|
| 46 |
-
});
|
| 47 |
-
|
| 48 |
-
function createNewPersona() {
|
| 49 |
-
const newPersona: Persona = {
|
| 50 |
-
id: v4(),
|
| 51 |
-
name: "New Persona",
|
| 52 |
-
occupation: "",
|
| 53 |
-
stance: "",
|
| 54 |
-
prompt: "",
|
| 55 |
-
isDefault: false,
|
| 56 |
-
createdAt: new Date(),
|
| 57 |
-
updatedAt: new Date(),
|
| 58 |
-
};
|
| 59 |
-
|
| 60 |
-
$settings.personas = [...$settings.personas, newPersona];
|
| 61 |
-
// Navigate to the new persona
|
| 62 |
-
goto(`${base}/settings/personas/${newPersona.id}`);
|
| 63 |
-
}
|
| 64 |
-
|
| 65 |
-
function savePersona() {
|
| 66 |
-
if (!selectedPersona) return;
|
| 67 |
-
|
| 68 |
-
const updatedPersonas = $settings.personas.map((p) =>
|
| 69 |
-
p.id === selectedPersona.id
|
| 70 |
-
? {
|
| 71 |
-
...p,
|
| 72 |
-
name: editableName,
|
| 73 |
-
occupation: editableOccupation,
|
| 74 |
-
stance: editableStance,
|
| 75 |
-
prompt: editablePrompt,
|
| 76 |
-
updatedAt: new Date(),
|
| 77 |
-
}
|
| 78 |
-
: p
|
| 79 |
-
);
|
| 80 |
-
|
| 81 |
-
$settings.personas = updatedPersonas;
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
function activatePersona() {
|
| 85 |
-
if (!selectedPersona) return;
|
| 86 |
-
if (hasChanges) savePersona();
|
| 87 |
-
settings.instantSet({ activePersona: selectedPersona.id });
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
function deletePersona() {
|
| 91 |
-
if (!selectedPersona) return;
|
| 92 |
-
|
| 93 |
-
// Can't delete if it's the only persona
|
| 94 |
-
if ($settings.personas.length === 1) {
|
| 95 |
-
alert("Cannot delete the last persona.");
|
| 96 |
-
return;
|
| 97 |
-
}
|
| 98 |
-
|
| 99 |
-
// Can't delete if it's the active persona
|
| 100 |
-
if (selectedPersona.id === $settings.activePersona) {
|
| 101 |
-
alert("Cannot delete the currently active persona. Please activate another persona first.");
|
| 102 |
-
return;
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
if (confirm(`Are you sure you want to delete "${selectedPersona.name}"?`)) {
|
| 106 |
-
const nextId = $settings.personas.find((p) => p.id !== selectedPersona!.id)?.id || null;
|
| 107 |
-
$settings.personas = $settings.personas.filter((p) => p.id !== selectedPersona.id);
|
| 108 |
-
goto(`${base}/settings/personas/${nextId ?? ""}`);
|
| 109 |
-
}
|
| 110 |
-
}
|
| 111 |
-
|
| 112 |
-
function handleDoubleClick(personaId: string) {
|
| 113 |
-
if (selectedPersona && hasChanges) savePersona();
|
| 114 |
-
if (personaId !== $settings.activePersona) {
|
| 115 |
-
settings.instantSet({ activePersona: personaId });
|
| 116 |
-
}
|
| 117 |
-
goto(`${base}/settings/personas/${personaId}`);
|
| 118 |
-
}
|
| 119 |
-
|
| 120 |
-
let hasChanges = $derived(
|
| 121 |
-
selectedPersona &&
|
| 122 |
-
(editableName !== selectedPersona.name ||
|
| 123 |
-
editableOccupation !== selectedPersona.occupation ||
|
| 124 |
-
editableStance !== selectedPersona.stance ||
|
| 125 |
-
editablePrompt !== selectedPersona.prompt)
|
| 126 |
-
);
|
| 127 |
-
</script>
|
| 128 |
-
|
| 129 |
-
<div class="grid h-full w-full grid-cols-1 grid-rows-[1fr] gap-x-6 md:grid-cols-3">
|
| 130 |
-
<!-- Left Panel - Persona List -->
|
| 131 |
-
<div
|
| 132 |
-
class="scrollbar-custom col-span-1 flex flex-col overflow-y-auto whitespace-nowrap rounded-r-xl bg-gradient-to-l from-gray-50 to-10% dark:from-gray-700/40 md:pr-6"
|
| 133 |
-
>
|
| 134 |
-
<!-- Filter input -->
|
| 135 |
-
<div class="px-2 py-2">
|
| 136 |
-
<input
|
| 137 |
-
bind:value={personaFilter}
|
| 138 |
-
type="search"
|
| 139 |
-
placeholder="Search by name"
|
| 140 |
-
aria-label="Search personas by name"
|
| 141 |
-
class="w-full rounded-full border border-gray-300 bg-white px-4 py-1 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:placeholder:text-gray-500 dark:focus:ring-gray-700"
|
| 142 |
-
/>
|
| 143 |
-
</div>
|
| 144 |
-
|
| 145 |
-
{#each filteredPersonas as persona (persona.id)}
|
| 146 |
-
<button
|
| 147 |
-
type="button"
|
| 148 |
-
onclick={() => goto(`${base}/settings/personas/${persona.id}`)}
|
| 149 |
-
ondblclick={() => handleDoubleClick(persona.id)}
|
| 150 |
-
class="group relative flex h-9 w-full flex-none items-center gap-1 rounded-lg px-3 text-[13px] transition-all {selectedPersonaId ===
|
| 151 |
-
persona.id
|
| 152 |
-
? '!bg-gray-100 !text-gray-800 dark:!bg-gray-700 dark:!text-gray-200'
|
| 153 |
-
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800/60'} md:rounded-xl md:px-3"
|
| 154 |
-
aria-label="Select {persona.name}"
|
| 155 |
-
>
|
| 156 |
-
<div class="mr-auto flex items-center gap-1 truncate">
|
| 157 |
-
<span class="truncate">{persona.name}</span>
|
| 158 |
-
</div>
|
| 159 |
-
{#if persona.id === $settings.activePersona}
|
| 160 |
-
<div
|
| 161 |
-
class="flex h-[21px] items-center rounded-md bg-black/90 px-2 text-[11px] font-semibold leading-none text-white dark:bg-white dark:text-black"
|
| 162 |
-
>
|
| 163 |
-
Active
|
| 164 |
-
</div>
|
| 165 |
-
{/if}
|
| 166 |
-
</button>
|
| 167 |
-
{/each}
|
| 168 |
-
|
| 169 |
-
<button
|
| 170 |
-
type="button"
|
| 171 |
-
onclick={createNewPersona}
|
| 172 |
-
class="group sticky bottom-0 mt-1 flex h-9 w-full flex-none items-center justify-center gap-1 rounded-lg bg-white px-3 text-[13px] text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-800/60 md:rounded-xl md:px-3"
|
| 173 |
-
aria-label="Create new persona"
|
| 174 |
-
>
|
| 175 |
-
+ New Persona
|
| 176 |
-
</button>
|
| 177 |
-
</div>
|
| 178 |
-
|
| 179 |
-
<!-- Right Panel - Persona Detail View -->
|
| 180 |
-
{#if selectedPersona}
|
| 181 |
-
<div class="col-span-2 flex h-full w-full flex-col overflow-hidden px-1">
|
| 182 |
-
<div class="grid grid-cols-1 gap-4 md:grid-cols-3 md:gap-6">
|
| 183 |
-
<div class="flex flex-col gap-2 md:col-span-1">
|
| 184 |
-
<label for="persona-name" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 185 |
-
Persona Name
|
| 186 |
-
</label>
|
| 187 |
-
<div class="relative">
|
| 188 |
-
<input
|
| 189 |
-
id="persona-name"
|
| 190 |
-
type="text"
|
| 191 |
-
bind:value={editableName}
|
| 192 |
-
class="peer w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 pr-9 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 193 |
-
maxlength="100"
|
| 194 |
-
/>
|
| 195 |
-
<CarbonEdit class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 opacity-50 transition-opacity peer-focus:opacity-0 dark:text-gray-500" />
|
| 196 |
-
</div>
|
| 197 |
-
</div>
|
| 198 |
-
|
| 199 |
-
<div class="flex flex-col gap-2 md:col-span-1">
|
| 200 |
-
<label for="occupation" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 201 |
-
Role
|
| 202 |
-
</label>
|
| 203 |
-
<div class="relative">
|
| 204 |
-
<input
|
| 205 |
-
id="occupation"
|
| 206 |
-
type="text"
|
| 207 |
-
bind:value={editableOccupation}
|
| 208 |
-
class="peer w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 pr-9 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 209 |
-
maxlength="200"
|
| 210 |
-
/>
|
| 211 |
-
<CarbonEdit class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 opacity-50 transition-opacity peer-focus:opacity-0 dark:text-gray-500" />
|
| 212 |
-
</div>
|
| 213 |
-
</div>
|
| 214 |
-
|
| 215 |
-
<div class="flex flex-col gap-2 md:col-span-1">
|
| 216 |
-
<label for="stance" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 217 |
-
Stance
|
| 218 |
-
</label>
|
| 219 |
-
<div class="relative">
|
| 220 |
-
<input
|
| 221 |
-
id="stance"
|
| 222 |
-
type="text"
|
| 223 |
-
bind:value={editableStance}
|
| 224 |
-
class="peer w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 pr-9 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 225 |
-
maxlength="200"
|
| 226 |
-
/>
|
| 227 |
-
<CarbonEdit class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 opacity-50 transition-opacity peer-focus:opacity-0 dark:text-gray-500" />
|
| 228 |
-
</div>
|
| 229 |
-
</div>
|
| 230 |
-
|
| 231 |
-
<div class="flex min-h-0 flex-1 flex-col gap-2 md:col-span-3">
|
| 232 |
-
<label for="system-prompt" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 233 |
-
System Prompt
|
| 234 |
-
</label>
|
| 235 |
-
<div class="relative flex flex-1">
|
| 236 |
-
<textarea
|
| 237 |
-
id="system-prompt"
|
| 238 |
-
bind:value={editablePrompt}
|
| 239 |
-
class="peer scrollbar-custom h-full min-h-[300px] w-full flex-1 resize-none overflow-y-auto rounded-md border border-gray-300 bg-transparent px-3 py-2 pr-9 text-sm transition-colors focus:bg-white focus:outline-none dark:border-gray-600 dark:focus:bg-gray-900"
|
| 240 |
-
maxlength="10000"
|
| 241 |
-
></textarea>
|
| 242 |
-
<CarbonEdit class="pointer-events-none absolute right-3 top-3 text-gray-400 opacity-50 transition-opacity peer-focus:opacity-0 dark:text-gray-500" />
|
| 243 |
-
</div>
|
| 244 |
-
</div>
|
| 245 |
-
</div>
|
| 246 |
-
|
| 247 |
-
<!-- Sticky buttons -->
|
| 248 |
-
<div
|
| 249 |
-
class="sticky bottom-0 flex flex-wrap gap-2 py-4"
|
| 250 |
-
>
|
| 251 |
-
<button
|
| 252 |
-
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
| 253 |
-
onclick={activatePersona}
|
| 254 |
-
disabled={selectedPersona.id === $settings.activePersona}
|
| 255 |
-
>
|
| 256 |
-
{selectedPersona.id === $settings.activePersona ? "Active" : "Activate"}
|
| 257 |
-
</button>
|
| 258 |
-
<button
|
| 259 |
-
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
| 260 |
-
onclick={savePersona}
|
| 261 |
-
disabled={!hasChanges}
|
| 262 |
-
>
|
| 263 |
-
Save
|
| 264 |
-
</button>
|
| 265 |
-
<button
|
| 266 |
-
class="ml-auto flex items-center gap-2 rounded-lg border border-red-300 bg-white px-4 py-2 text-sm font-semibold text-red-600 hover:bg-red-50 dark:border-red-700 dark:bg-gray-800 dark:hover:bg-red-900/20"
|
| 267 |
-
onclick={deletePersona}
|
| 268 |
-
>
|
| 269 |
-
<CarbonTrashCan />
|
| 270 |
-
Delete
|
| 271 |
-
</button>
|
| 272 |
-
</div>
|
| 273 |
-
</div>
|
| 274 |
-
{/if}
|
| 275 |
-
</div>
|
| 276 |
-
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|