extonlawrence commited on
Commit
725337f
·
1 Parent(s): 145bf5e

Initial commit

Browse files
Files changed (38) hide show
  1. BRANCHES.md +180 -0
  2. src/lib/components/CopyToClipBoardBtn.svelte +2 -7
  3. src/lib/components/PersonaSelector.svelte +70 -10
  4. src/lib/components/chat/ChatInput.svelte +1 -1
  5. src/lib/components/chat/ChatIntroduction.svelte +1 -1
  6. src/lib/components/chat/ChatMessage.svelte +81 -55
  7. src/lib/components/chat/ChatWindow.svelte +69 -61
  8. src/lib/components/chat/PersonaResponseCards.svelte +192 -0
  9. src/lib/components/chat/PersonaResponseCarousel.svelte +445 -0
  10. src/lib/constants/thinkBlockRegex.ts +2 -0
  11. src/lib/migrations/routines/11-add-personas.ts +2 -2
  12. src/lib/server/api/routes/groups/user.ts +2 -2
  13. src/lib/server/defaultPersonas.ts +38 -42
  14. src/lib/server/textGeneration/multiPersona.ts +179 -0
  15. src/lib/stores/settings.ts +1 -1
  16. src/lib/types/Message.ts +21 -0
  17. src/lib/types/MessageUpdate.ts +28 -1
  18. src/lib/types/Persona.ts +30 -3
  19. src/lib/types/Settings.ts +3 -3
  20. src/lib/utils/messageUpdates.ts +2 -0
  21. src/routes/api/conversation/[id]/+server.ts +2 -0
  22. src/routes/conversation/+server.ts +6 -3
  23. src/routes/conversation/[id]/+page.svelte +165 -76
  24. src/routes/conversation/[id]/+server.ts +208 -42
  25. src/routes/login/callback/+server.ts +11 -2
  26. src/routes/personas/+page.svelte +318 -74
  27. src/routes/settings/(nav)/+layout.svelte +200 -46
  28. src/routes/settings/(nav)/+page.svelte +26 -0
  29. src/routes/settings/(nav)/+server.ts +9 -3
  30. src/routes/settings/(nav)/models/+page.svelte +31 -0
  31. src/routes/settings/(nav)/{personas/[persona] → models}/+page.ts +0 -0
  32. src/routes/settings/(nav)/{[...model] → models/[...model]}/+page.svelte +1 -0
  33. src/routes/settings/(nav)/{[...model] → models/[...model]}/+page.ts +0 -0
  34. src/routes/settings/(nav)/personas/+page.svelte +20 -262
  35. src/routes/settings/(nav)/personas/+page.ts +1 -12
  36. src/routes/settings/(nav)/personas/[...persona]/+page.svelte +435 -0
  37. src/routes/settings/(nav)/personas/[...persona]/+page.ts +4 -0
  38. 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
- }, 1000);
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 activePersona = $derived(
10
- $settings.personas.find((p) => p.id === $settings.activePersona) ?? $settings.personas[0]
 
 
11
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  </script>
13
 
14
- <button
15
- 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-700 shadow-sm hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
16
- onclick={() => goto(`${base}/settings/personas`)}
17
- title="Manage personas"
18
- >
19
- <CarbonUser class="text-xs" />
20
- <span class="max-w-[150px] truncate">{activePersona?.name ?? "Default"}</span>
21
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- onretry?: (payload: { id: Message["id"]; content?: string }) => void;
 
 
 
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
- <MessageAvatar
132
- classNames="mt-5 size-3.5 flex-none select-none rounded-full shadow-lg max-sm:hidden"
133
- animating={isLast && loading}
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 || (!loading && message.content)}
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
- {#if message.routerMetadata && (!isLast || !loading)}
203
- <div
204
- class="mr-2 flex items-center gap-1.5 truncate whitespace-nowrap text-[.65rem] text-gray-400 dark:text-gray-400 sm:text-xs"
205
- >
206
- <span class="truncate rounded bg-gray-100 px-1 font-mono dark:bg-gray-800 sm:py-px">
207
- {message.routerMetadata.route}
 
 
 
 
 
 
 
 
 
 
 
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 md:pb-4 xl:max-w-4xl [&>*]:pointer-events-auto"
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
- {#if lastIsError}
 
456
  <RetryBtn
457
  classNames="ml-auto"
458
  onClick={() => {
@@ -463,7 +470,8 @@
463
  }
464
  }}
465
  />
466
- {:else if messages && lastMessage && lastMessage.interrupted && !isReadOnly}
 
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 activePersona to all existing settings
13
  await settings.updateMany(
14
  {},
15
  {
16
  $set: {
17
- activePersona: "default",
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
- activePersona: settings?.activePersona ?? DEFAULT_SETTINGS.activePersona,
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
- activePersona: z.string().default(DEFAULT_SETTINGS.activePersona),
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
- occupation: "Medical Ethics & Policy Scholar",
16
- stance: "Medicare-for-All (M4A)",
17
- prompt: `Persona Prompt: You are Dr. Robert Zane, a distinguished scholar in medical ethics and healthcare policy. Your expertise centers on the moral and social dimensions of healthcare. You are a staunch advocate for a universal healthcare system, believing it's a moral imperative for a just society.
18
-
19
- Core Stance: Healthcare is a fundamental human right, not a commodity. A just society has a collective responsibility to ensure every individual has timely, comprehensive, and high-quality medical care, regardless of their socioeconomic status. The most ethical and effective way to achieve this is through a single-payer system like Medicare-for-All, which eliminates private insurance for essential care.
20
-
21
- Communication Style: Your tone is thoughtful, principled, and philosophical. You use terms like "distributive justice," "social solidarity," and "moral legitimacy." You consistently frame the debate around ethical obligations, citing successful international systems in countries like Canada and the UK to counter arguments about inefficiency or lack of choice. You challenge opponents to defend a system where health access is tied to employment or personal wealth.
22
-
23
- Goal in a Debate: To establish M4A as the only truly equitable solution. You will highlight how the current system's failures disproportionately harm the vulnerable and argue that universal access is a proven, practical model.`,
 
24
  isDefault: true,
25
  },
26
  {
27
  id: "mayor-david-chen",
28
  name: "Mayor David Chen",
29
- occupation: "Community Leader & Public Health Official",
30
- stance: "Public Option",
31
- prompt: `Persona Prompt: You are Mayor David Chen, a pragmatic and popular community leader with a background as a public health official. Your focus is on the health and well-being of the entire community, and you believe that a mixed public and private system is the most viable path forward for a country like the United States. You advocate for a Public Option that would be available to all citizens.
32
-
33
- Core Stance: While universal healthcare is the ultimate goal, a full M4A system is not a realistic first step due to political and economic hurdles. A public option is the most effective and politically achievable policy to ensure all citizens have access to affordable care. By allowing individuals to choose a government-run insurance plan, we can increase competition, lower costs, and ensure a safety net for those who are uninsured or underinsured, all while preserving the private market for those who prefer it.
34
-
35
- Communication Style: Your tone is authoritative, but also pragmatic and community-focused. You use phrases like "population health," "practical solutions," and "building on what works." You speak from the perspective of someone who has to balance the needs of constituents with the realities of budget and policy implementation. You appeal to a sense of collective responsibility and compromise.
36
-
37
- Goal in a Debate: To position the public option as a moderate, effective, and politically viable path to universal care. You will argue that it addresses the most urgent issues—affordability and access—without the massive disruption of a single-payer overhaul.`,
 
38
  isDefault: true,
39
  },
40
  {
41
  id: "dr-evelyn-reed",
42
  name: "Dr. Evelyn Reed",
43
- occupation: "Insurance Executive",
 
 
44
  stance: "Status Quo (Hardline Insurance Advocate)",
45
- prompt: `Persona Prompt: You are Dr. Evelyn Reed, a seasoned executive for a major health insurance company. Your primary focus is on the financial health and market leadership of the private health insurance industry. You analyze policies through a lens of consumer choice, market innovation, and fiscal responsibility. You believe the private insurance model is the cornerstone of a high-quality, competitive, and sustainable healthcare system.
46
-
47
- Core Stance: Private health insurance is the most effective and efficient way to deliver high-quality healthcare. It provides consumers with a wide range of choices, fosters innovation among providers, and ensures fiscal discipline. Government-run systems, like Medicare-for-All or a public option, would lead to unsustainable tax burdens, long wait times, and a one-size-fits-all approach that stifles innovation and limits patient choice. The current system, while in need of some reforms, is fundamentally sound and should be preserved.
48
-
49
- Communication Style: Your tone is professional, confident, and business-oriented. You use industry-specific terminology like "consumer-driven health plans," "risk pools," "cost-containment strategies," and "market competition." You rely on data about cost-effectiveness, quality-of-care metrics, and patient satisfaction from private plans to support your points. You are direct in your criticism of government-run alternatives, highlighting their potential downsides.
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
- occupation: "Concerned Citizen & Teacher",
 
 
58
  stance: "Status Quo (Moderate Government Intervention)",
59
- prompt: `Persona Prompt: You are Mr. Ben Carter, a concerned citizen and middle school teacher. You are not a healthcare professional, but you have personal experience navigating the healthcare system for yourself and your family. Your perspective is centered on the practical, human, and often frustrating reality of healthcare delivery. You believe in a system that is primarily employer-provided but that also includes a significant role for government regulation and support.
60
-
61
- Core Stance: The healthcare system, regardless of its structure, must be easy to navigate, affordable, and provide high-quality, compassionate care to individuals. My priorities are centered on the real-world impact of policies: out-of-pocket costs, wait times, the complexity of insurance forms, and the feeling of being heard by doctors. While I'm not in favor of a full government takeover, I do believe that the government has an important role to play in regulating the insurance market, protecting consumers from unfair practices, and providing subsidies to help people afford their premiums.
62
-
63
- Communication Style: Your tone is direct, relatable, and sometimes frustrated. You speak from a place of lived experience, using plain language and personal anecdotes to illustrate your points. You are skeptical of jargon and grand, theoretical plans, preferring practical solutions that make a tangible difference in people's lives. You're a pragmatist who sees the value in the current system but also recognizes its deep flaws and the need for more government oversight.
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
- activePersona: string;
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
- occupation: string;
5
- stance: string;
6
- prompt: string; // The full system prompt text
 
 
 
 
 
 
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 persona and user's custom personas
17
- activePersona: string; // Persona ID
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
- activePersona: "default", // Default persona
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 persona
79
  const userSettings = await collections.settings.findOne(authCondition(locals));
80
- const activePersonaId = userSettings?.activePersona ?? "default-neutral";
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?.prompt ?? values.preprompt ?? model?.preprompt ?? "";
 
 
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
- files = [];
270
- let buffer = "";
271
- // Initialize lastUpdateTime outside the loop to persist between updates
272
- let lastUpdateTime = new Date();
273
 
274
- let reasoningBuffer = "";
275
- let reasoningLastUpdate = new Date();
276
 
277
- for await (const update of messageUpdatesIterator) {
278
- if ($isAborted) {
279
- messageUpdatesAbortController.abort();
280
- return;
281
- }
282
 
283
- // Remove null characters added due to remote keylogging prevention
284
- // See server code for more details
285
- if (update.type === MessageUpdateType.Stream) {
286
- update.token = update.token.replaceAll("\0", "");
287
- }
288
 
289
- const isHighFrequencyUpdate =
290
- (update.type === MessageUpdateType.Reasoning &&
291
- update.subtype === MessageReasoningUpdateType.Stream) ||
292
- update.type === MessageUpdateType.Stream ||
293
- (update.type === MessageUpdateType.Status &&
294
- update.status === MessageUpdateStatus.KeepAlive);
 
 
 
 
 
 
 
 
 
 
 
 
295
 
296
- if (!isHighFrequencyUpdate) {
297
- messageToWriteTo.updates = [...(messageToWriteTo.updates ?? []), update];
 
 
 
298
  }
299
- const currentTime = new Date();
300
-
301
- if (update.type === MessageUpdateType.Stream && !$settings.disableStream) {
302
- buffer += update.token;
303
- // Check if this is the first update or if enough time has passed
304
- if (currentTime.getTime() - lastUpdateTime.getTime() > updateDebouncer.maxUpdateTime) {
305
- messageToWriteTo.content += buffer;
306
- buffer = "";
307
- lastUpdateTime = currentTime;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  }
309
  pending = false;
310
- } else if (
311
- update.type === MessageUpdateType.Status &&
312
- update.status === MessageUpdateStatus.Error
313
- ) {
314
- $error = update.message ?? "An error has occurred";
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
- const hasFinalAnswer =
454
- lastAssistant.updates?.some((update) => update.type === MessageUpdateType.FinalAnswer) ??
455
- false;
456
  const hasError =
457
  lastAssistant.updates?.some(
458
  (update) =>
459
  update.type === MessageUpdateType.Status && update.status === MessageUpdateStatus.Error
460
  ) ?? false;
461
- return !hasFinalAnswer && !hasError;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 personaId = conv.personaId ?? userSettings?.activePersona ?? "default-neutral";
448
- const currentPersona = userSettings?.personas?.find((p) => p.id === personaId);
449
-
450
- // Use current persona prompt (reflects any edits)
451
- const preprompt = currentPersona?.prompt ?? conv.preprompt ?? "";
452
-
453
- // Update conversation's preprompt to reflect current persona
454
- await collections.conversations.updateOne(
455
- { _id: conv._id },
456
- { $set: { preprompt, updatedAt: new Date() } }
457
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
 
459
- // Update conv object with current preprompt
460
- conv.preprompt = preprompt;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
 
462
- try {
463
- const ctx: TextGenerationContext = {
464
- model,
465
- endpoint: await model.getEndpoint(),
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: "No output was generated. Something went wrong.",
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(JSON5.parse(sanitizeJSONEnv(config.ALLOWED_USER_EMAILS, "[]")));
20
 
21
  const allowedUserDomains = z
22
  .array(z.string().regex(/\.\w+$/)) // Contains at least a dot
23
  .optional()
24
  .default([])
25
- .parse(JSON5.parse(sanitizeJSONEnv(config.ALLOWED_USER_DOMAINS, "[]")));
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.occupation ?? ""} ${p.stance ?? ""}`);
22
  return queryTokens.every((q) => haystack.includes(q));
23
  })
24
  );
25
 
26
- function activatePersona(personaId: string) {
27
- if (personaId !== $settings.activePersona) {
28
- settings.instantSet({ activePersona: personaId });
 
 
 
 
 
 
 
 
 
 
29
  }
30
  }
31
 
@@ -42,7 +52,7 @@
42
  clearTimeout(clickTimeout);
43
  clickTimeout = null;
44
  }
45
- activatePersona(personaId);
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 editableOccupation = $state("");
 
 
55
  let editableStance = $state("");
56
- let editablePrompt = $state("");
 
 
 
 
57
 
58
  $effect(() => {
59
  if (editingPersona) {
60
  editableName = editingPersona.name;
61
- editableOccupation = editingPersona.occupation;
62
- editableStance = editingPersona.stance;
63
- editablePrompt = editingPersona.prompt;
 
 
 
 
 
 
64
  }
65
  });
66
 
@@ -79,9 +101,15 @@ function closeEdit() {
79
  ? {
80
  ...p,
81
  name: editableName,
82
- occupation: editableOccupation,
 
 
83
  stance: editableStance,
84
- prompt: editablePrompt,
 
 
 
 
85
  updatedAt: new Date(),
86
  }
87
  : p
@@ -89,23 +117,30 @@ function closeEdit() {
89
  closeEdit();
90
  }
91
 
92
- function activateEditingPersona() {
93
  if (!editingPersona) return;
94
  const id = editingPersona.id;
95
  saveEdit();
96
- activatePersona(id);
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 === $settings.activePersona)
103
- return alert("Cannot delete the active persona. Activate another first.");
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, occupation, or stance"
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 border bg-gray-50/50 px-6 py-5 text-left shadow hover:bg-gray-50 hover:shadow-inner dark:border-gray-800/70 dark:bg-gray-950/20 dark:hover:bg-gray-950/40"
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 === $settings.activePersona}
150
- <span class="rounded-full border border-gray-300 bg-white px-2 py-0.5 text-xs text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300">Active</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- editableOccupation !== editingPersona.occupation ||
171
- editableStance !== editingPersona.stance ||
172
- editablePrompt !== editingPersona.prompt
 
 
 
 
 
 
173
  );
174
  if (!dirty) return closeEdit();
175
  showCloseConfirm = true;
176
- }} width="w-full !max-w-2xl">
177
- <div class="flex h-full max-h-[80vh] w-full flex-col gap-5 p-6">
178
- <div class="text-xl font-semibold text-gray-800 dark:text-gray-200">Edit Persona</div>
179
- <div class="flex flex-col gap-2">
180
- <label for="edit-name" class="text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
181
- <div class="relative">
182
- <input
183
- id="edit-name"
184
- bind:value={editableName}
185
- 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"
186
- />
187
- <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" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  </div>
189
  </div>
190
- <div class="flex flex-col gap-2">
191
- <label for="edit-role" class="text-sm font-medium text-gray-700 dark:text-gray-300">Role</label>
192
- <div class="relative">
193
- <input
194
- id="edit-role"
195
- bind:value={editableOccupation}
196
- 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"
197
- />
198
- <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" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  </div>
200
  </div>
201
- <div class="flex flex-col gap-2">
202
- <label for="edit-stance" class="text-sm font-medium text-gray-700 dark:text-gray-300">Stance</label>
203
- <div class="relative">
204
- <input
205
- id="edit-stance"
206
- bind:value={editableStance}
207
- 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"
208
- />
209
- <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" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  </div>
211
  </div>
212
- <div class="flex min-h-0 flex-1 flex-col gap-2">
213
- <label for="edit-prompt" class="text-sm font-medium text-gray-700 dark:text-gray-300">System Prompt</label>
214
- <div class="relative flex flex-1">
215
- <textarea
216
- id="edit-prompt"
217
- bind:value={editablePrompt}
218
- class="peer scrollbar-custom h-full min-h-[200px] 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"
219
- ></textarea>
220
- <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" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
226
- onclick={activateEditingPersona}
227
- disabled={editingPersona.id === $settings.activePersona}
228
  >
229
- {editingPersona.id === $settings.activePersona ? "Active" : "Activate"}
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 scrollSelectedModelIntoView() {
30
  await tick();
31
  const container = navContainer;
32
  if (!container) return;
33
- const currentModelId = page.params.model as string | undefined;
34
- if (!currentModelId) return;
35
- const buttons = container.querySelectorAll<HTMLButtonElement>("button[data-model-id]");
36
- let target: HTMLElement | null = null;
37
- for (const btn of buttons) {
38
- if (btn.dataset.modelId === currentModelId) {
39
- target = btn;
40
- break;
 
 
 
 
 
 
 
 
 
 
 
 
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 when not on the root settings page
61
- showContent = page.url.pathname !== `${base}/settings`;
62
  // Initial desktop redirect check
63
  checkDesktopRedirect();
64
 
65
- // Ensure the selected model (if any) is visible in the nav
66
- void scrollSelectedModelIntoView();
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 when not on the root settings page
81
- showContent = page.url.pathname !== `${base}/settings`;
82
  // Check desktop redirect after navigation
83
  checkDesktopRedirect();
84
- // After navigation, keep the selected model in view
85
- void scrollSelectedModelIntoView();
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 showContent && browser}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  <button
110
- class="btn rounded-lg md:hidden"
111
- aria-label="Back to menu"
112
  onclick={() => {
113
- showContent = false;
114
- goto(`${base}/settings`);
115
  }}
116
  >
117
- <CarbonChevronLeft
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={() => goto(`${base}/settings/${data.models[0]?.id ?? ''}`)}
 
 
 
 
 
 
 
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={() => goto(`${base}/settings/personas/${$settings.activePersona || $settings.personas[0]?.id || ''}`)}
 
 
 
 
 
 
 
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
- occupation: z.string().max(200).default(""),
 
 
10
  stance: z.string().max(200).default(""),
11
- prompt: z.string().max(10000).default(""),
 
 
 
 
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
- activePersona: z.string().default(DEFAULT_SETTINGS.activePersona),
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 { 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
 
 
10
  const settings = useSettingsStore();
11
 
12
- // Selected persona comes from the URL param to mirror model routing
13
- let selectedPersonaId = $state<string | null>(page.params.persona ?? null);
14
- let selectedPersona = $derived(
15
- $settings.personas.find((p) => p.id === selectedPersonaId) ?? $settings.personas[0]
16
- );
17
-
18
- // Keep URL in sync if selection changes locally
19
- $effect(() => {
20
- const id = selectedPersona?.id;
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="grid h-full w-full grid-cols-1 grid-rows-[1fr] gap-x-6 md:grid-cols-3">
138
- <!-- Left Panel - Persona List -->
139
- <div
140
- 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"
141
- >
142
- <!-- Filter input -->
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
- const active = data.settings?.activePersona;
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
-