Prompt params (#1949)
Browse files* Add support for 'prompt' URL param and draft binding
Introduces a utility to sanitize URL parameters and updates chat window logic to use a 'draft' state instead of 'message'. Both main and model-specific chat pages now support pre-filling the chat input from a 'prompt' URL parameter, and clear the parameter after use. This improves UX for deep-linking and sharing prompts.
* Sanitize URL parameters in model page
Added usage of sanitizeUrlParam for 'q' and 'prompt' URL parameters in the model page to prevent unsafe input. Wrapped parameter handling in a try-catch block to improve error handling and log failures.
src/lib/components/chat/ChatWindow.svelte
CHANGED
|
@@ -49,6 +49,7 @@
|
|
| 49 |
onstop?: () => void;
|
| 50 |
onretry?: (payload: { id: Message["id"]; content?: string }) => void;
|
| 51 |
onshowAlternateMsg?: (payload: { id: Message["id"] }) => void;
|
|
|
|
| 52 |
}
|
| 53 |
|
| 54 |
let {
|
|
@@ -61,6 +62,7 @@
|
|
| 61 |
models,
|
| 62 |
preprompt = undefined,
|
| 63 |
files = $bindable([]),
|
|
|
|
| 64 |
onmessage,
|
| 65 |
onstop,
|
| 66 |
onretry,
|
|
@@ -69,15 +71,14 @@
|
|
| 69 |
|
| 70 |
let isReadOnly = $derived(!models.some((model) => model.id === currentModel.id));
|
| 71 |
|
| 72 |
-
let message: string = $state("");
|
| 73 |
let shareModalOpen = $state(false);
|
| 74 |
let editMsdgId: Message["id"] | null = $state(null);
|
| 75 |
let pastedLongContent = $state(false);
|
| 76 |
|
| 77 |
const handleSubmit = () => {
|
| 78 |
-
if (loading) return;
|
| 79 |
-
onmessage?.(
|
| 80 |
-
|
| 81 |
};
|
| 82 |
|
| 83 |
let lastTarget: EventTarget | null = null;
|
|
@@ -248,7 +249,7 @@
|
|
| 248 |
);
|
| 249 |
let routerUserMessages = $derived(messages.filter((msg) => msg.from === "user"));
|
| 250 |
let shouldShowRouterFollowUps = $derived(
|
| 251 |
-
!
|
| 252 |
activeRouterExamplePrompt &&
|
| 253 |
routerFollowUps.length > 0 &&
|
| 254 |
routerUserMessages.length === 1 &&
|
|
@@ -279,7 +280,7 @@
|
|
| 279 |
$loginModalOpen = true;
|
| 280 |
return;
|
| 281 |
}
|
| 282 |
-
|
| 283 |
handleSubmit();
|
| 284 |
}
|
| 285 |
|
|
@@ -398,7 +399,7 @@
|
|
| 398 |
dark:from-gray-900 dark:via-gray-900/100
|
| 399 |
dark:to-gray-900/0 max-sm:py-0 sm:px-5 md:pb-4 xl:max-w-4xl [&>*]:pointer-events-auto"
|
| 400 |
>
|
| 401 |
-
{#if !
|
| 402 |
<div
|
| 403 |
class="no-scrollbar mb-3 flex w-full select-none justify-start gap-2 overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"
|
| 404 |
>
|
|
@@ -485,7 +486,7 @@
|
|
| 485 |
<ChatInput
|
| 486 |
placeholder={isReadOnly ? "This conversation is read-only." : "Ask anything"}
|
| 487 |
{loading}
|
| 488 |
-
bind:value={
|
| 489 |
bind:files
|
| 490 |
mimeTypes={activeMimeTypes}
|
| 491 |
onsubmit={handleSubmit}
|
|
@@ -504,11 +505,11 @@
|
|
| 504 |
/>
|
| 505 |
{:else}
|
| 506 |
<button
|
| 507 |
-
class="btn absolute bottom-2 right-2 size-7 self-end rounded-full border bg-white text-black shadow transition-none enabled:hover:bg-white enabled:hover:shadow-inner dark:border-transparent dark:bg-gray-600 dark:text-white dark:hover:enabled:bg-black {!
|
| 508 |
isReadOnly
|
| 509 |
? ''
|
| 510 |
: '!bg-black !text-white dark:!bg-white dark:!text-black'}"
|
| 511 |
-
disabled={!
|
| 512 |
type="submit"
|
| 513 |
aria-label="Send message"
|
| 514 |
name="submit"
|
|
|
|
| 49 |
onstop?: () => void;
|
| 50 |
onretry?: (payload: { id: Message["id"]; content?: string }) => void;
|
| 51 |
onshowAlternateMsg?: (payload: { id: Message["id"] }) => void;
|
| 52 |
+
draft?: string;
|
| 53 |
}
|
| 54 |
|
| 55 |
let {
|
|
|
|
| 62 |
models,
|
| 63 |
preprompt = undefined,
|
| 64 |
files = $bindable([]),
|
| 65 |
+
draft = $bindable(""),
|
| 66 |
onmessage,
|
| 67 |
onstop,
|
| 68 |
onretry,
|
|
|
|
| 71 |
|
| 72 |
let isReadOnly = $derived(!models.some((model) => model.id === currentModel.id));
|
| 73 |
|
|
|
|
| 74 |
let shareModalOpen = $state(false);
|
| 75 |
let editMsdgId: Message["id"] | null = $state(null);
|
| 76 |
let pastedLongContent = $state(false);
|
| 77 |
|
| 78 |
const handleSubmit = () => {
|
| 79 |
+
if (loading || !draft) return;
|
| 80 |
+
onmessage?.(draft);
|
| 81 |
+
draft = "";
|
| 82 |
};
|
| 83 |
|
| 84 |
let lastTarget: EventTarget | null = null;
|
|
|
|
| 249 |
);
|
| 250 |
let routerUserMessages = $derived(messages.filter((msg) => msg.from === "user"));
|
| 251 |
let shouldShowRouterFollowUps = $derived(
|
| 252 |
+
!draft.length &&
|
| 253 |
activeRouterExamplePrompt &&
|
| 254 |
routerFollowUps.length > 0 &&
|
| 255 |
routerUserMessages.length === 1 &&
|
|
|
|
| 280 |
$loginModalOpen = true;
|
| 281 |
return;
|
| 282 |
}
|
| 283 |
+
draft = prompt;
|
| 284 |
handleSubmit();
|
| 285 |
}
|
| 286 |
|
|
|
|
| 399 |
dark:from-gray-900 dark:via-gray-900/100
|
| 400 |
dark:to-gray-900/0 max-sm:py-0 sm:px-5 md:pb-4 xl:max-w-4xl [&>*]:pointer-events-auto"
|
| 401 |
>
|
| 402 |
+
{#if !draft.length && !messages.length && !sources.length && !loading && currentModel.isRouter && routerExamples.length && !hideRouterExamples && !lastIsError}
|
| 403 |
<div
|
| 404 |
class="no-scrollbar mb-3 flex w-full select-none justify-start gap-2 overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"
|
| 405 |
>
|
|
|
|
| 486 |
<ChatInput
|
| 487 |
placeholder={isReadOnly ? "This conversation is read-only." : "Ask anything"}
|
| 488 |
{loading}
|
| 489 |
+
bind:value={draft}
|
| 490 |
bind:files
|
| 491 |
mimeTypes={activeMimeTypes}
|
| 492 |
onsubmit={handleSubmit}
|
|
|
|
| 505 |
/>
|
| 506 |
{:else}
|
| 507 |
<button
|
| 508 |
+
class="btn absolute bottom-2 right-2 size-7 self-end rounded-full border bg-white text-black shadow transition-none enabled:hover:bg-white enabled:hover:shadow-inner dark:border-transparent dark:bg-gray-600 dark:text-white dark:hover:enabled:bg-black {!draft ||
|
| 509 |
isReadOnly
|
| 510 |
? ''
|
| 511 |
: '!bg-black !text-white dark:!bg-white dark:!text-black'}"
|
| 512 |
+
disabled={!draft || isReadOnly}
|
| 513 |
type="submit"
|
| 514 |
aria-label="Send message"
|
| 515 |
name="submit"
|
src/lib/utils/urlParams.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const MAX_PARAM_LENGTH = 10_000;
|
| 2 |
+
|
| 3 |
+
export function sanitizeUrlParam(value: string | null): string | null {
|
| 4 |
+
if (value == null) return null;
|
| 5 |
+
|
| 6 |
+
const trimmed = value.trim();
|
| 7 |
+
if (!trimmed.length) return null;
|
| 8 |
+
if (trimmed.length > MAX_PARAM_LENGTH) return null;
|
| 9 |
+
|
| 10 |
+
return trimmed;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export { MAX_PARAM_LENGTH };
|
src/routes/+page.svelte
CHANGED
|
@@ -7,17 +7,19 @@
|
|
| 7 |
const publicConfig = usePublicConfig();
|
| 8 |
|
| 9 |
import ChatWindow from "$lib/components/chat/ChatWindow.svelte";
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
| 15 |
|
| 16 |
let { data } = $props();
|
| 17 |
|
| 18 |
let hasModels = $derived(Boolean(data.models?.length));
|
| 19 |
let loading = $state(false);
|
| 20 |
let files: File[] = $state([]);
|
|
|
|
| 21 |
|
| 22 |
const settings = useSettingsStore();
|
| 23 |
|
|
@@ -74,9 +76,26 @@
|
|
| 74 |
}
|
| 75 |
|
| 76 |
onMount(() => {
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
});
|
| 81 |
|
| 82 |
let currentModel = $derived(findCurrentModel(data.models, data.oldModels, $settings.activeModel));
|
|
@@ -93,6 +112,7 @@
|
|
| 93 |
{currentModel}
|
| 94 |
models={data.models}
|
| 95 |
bind:files
|
|
|
|
| 96 |
/>
|
| 97 |
{:else}
|
| 98 |
<div class="mx-auto my-20 max-w-xl rounded-xl border p-6 text-center dark:border-gray-700">
|
|
|
|
| 7 |
const publicConfig = usePublicConfig();
|
| 8 |
|
| 9 |
import ChatWindow from "$lib/components/chat/ChatWindow.svelte";
|
| 10 |
+
import { ERROR_MESSAGES, error } from "$lib/stores/errors";
|
| 11 |
+
import { pendingMessage } from "$lib/stores/pendingMessage";
|
| 12 |
+
import { useSettingsStore } from "$lib/stores/settings.js";
|
| 13 |
+
import { findCurrentModel } from "$lib/utils/models";
|
| 14 |
+
import { sanitizeUrlParam } from "$lib/utils/urlParams";
|
| 15 |
+
import { onMount } from "svelte";
|
| 16 |
|
| 17 |
let { data } = $props();
|
| 18 |
|
| 19 |
let hasModels = $derived(Boolean(data.models?.length));
|
| 20 |
let loading = $state(false);
|
| 21 |
let files: File[] = $state([]);
|
| 22 |
+
let draft = $state("");
|
| 23 |
|
| 24 |
const settings = useSettingsStore();
|
| 25 |
|
|
|
|
| 76 |
}
|
| 77 |
|
| 78 |
onMount(() => {
|
| 79 |
+
try {
|
| 80 |
+
const query = sanitizeUrlParam(page.url.searchParams.get("q"));
|
| 81 |
+
if (query) {
|
| 82 |
+
void createConversation(query);
|
| 83 |
+
const url = new URL(page.url);
|
| 84 |
+
url.searchParams.delete("q");
|
| 85 |
+
history.replaceState({}, "", url);
|
| 86 |
+
return;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
const promptQuery = sanitizeUrlParam(page.url.searchParams.get("prompt"));
|
| 90 |
+
if (promptQuery && !draft) {
|
| 91 |
+
draft = promptQuery;
|
| 92 |
+
const url = new URL(page.url);
|
| 93 |
+
url.searchParams.delete("prompt");
|
| 94 |
+
history.replaceState({}, "", url);
|
| 95 |
+
}
|
| 96 |
+
} catch (err) {
|
| 97 |
+
console.error("Failed to process URL parameters:", err);
|
| 98 |
+
}
|
| 99 |
});
|
| 100 |
|
| 101 |
let currentModel = $derived(findCurrentModel(data.models, data.oldModels, $settings.activeModel));
|
|
|
|
| 112 |
{currentModel}
|
| 113 |
models={data.models}
|
| 114 |
bind:files
|
| 115 |
+
bind:draft
|
| 116 |
/>
|
| 117 |
{:else}
|
| 118 |
<div class="mx-auto my-20 max-w-xl rounded-xl border p-6 text-center dark:border-gray-700">
|
src/routes/models/[...model]/+page.svelte
CHANGED
|
@@ -10,11 +10,13 @@
|
|
| 10 |
import { useSettingsStore } from "$lib/stores/settings";
|
| 11 |
import { ERROR_MESSAGES, error } from "$lib/stores/errors";
|
| 12 |
import { pendingMessage } from "$lib/stores/pendingMessage";
|
|
|
|
| 13 |
|
| 14 |
let { data } = $props();
|
| 15 |
|
| 16 |
let loading = $state(false);
|
| 17 |
let files: File[] = $state([]);
|
|
|
|
| 18 |
|
| 19 |
const settings = useSettingsStore();
|
| 20 |
const modelId = page.params.model;
|
|
@@ -59,9 +61,27 @@
|
|
| 59 |
}
|
| 60 |
}
|
| 61 |
|
| 62 |
-
onMount(
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
settings.instantSet({ activeModel: modelId });
|
| 67 |
});
|
|
@@ -85,4 +105,5 @@
|
|
| 85 |
currentModel={findCurrentModel(data.models, data.oldModels, modelId)}
|
| 86 |
models={data.models}
|
| 87 |
bind:files
|
|
|
|
| 88 |
/>
|
|
|
|
| 10 |
import { useSettingsStore } from "$lib/stores/settings";
|
| 11 |
import { ERROR_MESSAGES, error } from "$lib/stores/errors";
|
| 12 |
import { pendingMessage } from "$lib/stores/pendingMessage";
|
| 13 |
+
import { sanitizeUrlParam } from "$lib/utils/urlParams";
|
| 14 |
|
| 15 |
let { data } = $props();
|
| 16 |
|
| 17 |
let loading = $state(false);
|
| 18 |
let files: File[] = $state([]);
|
| 19 |
+
let draft = $state("");
|
| 20 |
|
| 21 |
const settings = useSettingsStore();
|
| 22 |
const modelId = page.params.model;
|
|
|
|
| 61 |
}
|
| 62 |
}
|
| 63 |
|
| 64 |
+
onMount(() => {
|
| 65 |
+
try {
|
| 66 |
+
const query = sanitizeUrlParam(page.url.searchParams.get("q"));
|
| 67 |
+
if (query) {
|
| 68 |
+
void createConversation(query);
|
| 69 |
+
const url = new URL(page.url);
|
| 70 |
+
url.searchParams.delete("q");
|
| 71 |
+
history.replaceState({}, "", url);
|
| 72 |
+
return;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
const promptQuery = sanitizeUrlParam(page.url.searchParams.get("prompt"));
|
| 76 |
+
if (promptQuery && !draft) {
|
| 77 |
+
draft = promptQuery;
|
| 78 |
+
const url = new URL(page.url);
|
| 79 |
+
url.searchParams.delete("prompt");
|
| 80 |
+
history.replaceState({}, "", url);
|
| 81 |
+
}
|
| 82 |
+
} catch (err) {
|
| 83 |
+
console.error("Failed to process URL parameters:", err);
|
| 84 |
+
}
|
| 85 |
|
| 86 |
settings.instantSet({ activeModel: modelId });
|
| 87 |
});
|
|
|
|
| 105 |
currentModel={findCurrentModel(data.models, data.oldModels, modelId)}
|
| 106 |
models={data.models}
|
| 107 |
bind:files
|
| 108 |
+
bind:draft
|
| 109 |
/>
|