This commit is contained in:
Timothy Jaeryang Baek 2026-10-10 14:18:09 +04:00
parent 8d0ff76f2b
commit 44b71f87ab
4 changed files with 222 additions and 211 deletions

View file

@ -9,6 +9,7 @@
export let locale = '';
export let localeLabel = '';
export let onChange = () => {};
export let disabled = false;
const clone = (value) => JSON.parse(JSON.stringify(value ?? []));
@ -47,32 +48,30 @@
</script>
{#if !locale}
<PromptSuggestions bind:promptSuggestions onChange={() => onChange()}>
<PromptSuggestions bind:promptSuggestions {disabled} onChange={() => onChange()}>
<span slot="label"><slot name="label" /></span>
<svelte:fragment slot="actions"><slot name="language" /></svelte:fragment>
</PromptSuggestions>
{:else}
<PromptSuggestions
{disabled}
promptSuggestions={localePrompts}
inherited={!hasCustomLocalePrompts}
onChange={updateLocalePrompts}
>
<span
slot="label"
title={localeLabel}
class="flex flex-wrap items-center gap-x-3 gap-y-1 text-[0.6875rem] text-gray-900 dark:text-gray-100"
>
<svelte:fragment slot="label">
{#if hasCustomLocalePrompts}
<button
title={localeLabel}
type="button"
class="text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
aria-label={$i18n.t('Use default')}
{disabled}
class="hover:text-gray-900 disabled:opacity-40 dark:hover:text-gray-100"
on:click={useDefault}
>
{$i18n.t('Use default')}
</button>
{/if}
</span>
</svelte:fragment>
<svelte:fragment slot="actions">
<slot name="language" />
</svelte:fragment>

View file

@ -120,6 +120,7 @@
let showSystemPrompt = true;
let showAdvanced = false;
let showPreview = false;
let customPromptDraft: any[] | null = null;
let showAccessControlModal = false;
let loaded = false;
@ -584,6 +585,7 @@
};
const loadModel = async (value: any) => {
customPromptDraft = null;
model = value ? structuredClone(value) : null;
backgroundFile = null;
avatarFile = null;
@ -970,7 +972,7 @@
<!-- LICENSE covers this Open WebUI fallback logo.
Do not alter, remove, obscure, or replace it except as LICENSE permits:
https://docs.openwebui.com/license. -->
<div class="group relative size-11 shrink-0">
<div class="group relative size-12 shrink-0">
<button
class="group relative flex size-full items-center overflow-hidden rounded-xl {info
.meta.profile_image_url !== `${WEBUI_BASE_URL}/static/favicon.png`
@ -1394,46 +1396,57 @@
<fieldset disabled={readOnly} class="min-w-0">
<section class="my-3">
<div class="flex w-full items-center justify-between">
<ModelSettingsSection
label={$i18n.t('Prompts')}
summary={editingLocale
? Array.isArray(info.meta.i18n?.[editingLocale]?.suggestion_prompts)
? $i18n.t('Custom')
: $i18n.t('Default')
: info.meta.suggestion_prompts == null
? $i18n.t('Default')
: `${$i18n.t('Custom')} · ${info.meta.suggestion_prompts.length}`}
>
{#if editingLocale}
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
{$i18n.t('Prompts')}
</div>
{:else}
<button
class="flex w-full items-center justify-between gap-3 rounded-sm text-start text-xs font-normal text-gray-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
type="button"
aria-pressed={(info?.meta?.suggestion_prompts ?? null) !== null}
on:click={() => {
if ((info?.meta?.suggestion_prompts ?? null) === null) {
info.meta.suggestion_prompts = [{ content: '', title: ['', ''] }];
} else {
info.meta.suggestion_prompts = null;
}
}}
<LocalizedPromptSuggestions
promptSuggestions={info.meta.suggestion_prompts ?? []}
bind:localizedPromptSuggestions={info.meta.i18n}
locale={editingLocale}
localeLabel={editingLocaleLabel}
disabled={readOnly}
/>
{:else if info.meta.suggestion_prompts != null}
<PromptSuggestions
bind:promptSuggestions={info.meta.suggestion_prompts}
disabled={readOnly}
>
<span class="font-normal text-gray-600 dark:text-gray-400"
>{$i18n.t('Prompts')}</span
<button
slot="label"
disabled={readOnly}
type="button"
class="hover:text-gray-900 disabled:opacity-40 dark:hover:text-gray-100"
on:click={() => {
customPromptDraft = structuredClone(info.meta.suggestion_prompts);
info.meta.suggestion_prompts = null;
}}>{$i18n.t('Use default')}</button
>
{#if (info?.meta?.suggestion_prompts ?? null) === null}
<span>{$i18n.t('Default')}</span>
{:else}
<span>{$i18n.t('Custom')}</span>
{/if}
</button>
</PromptSuggestions>
{:else}
<div
class="flex items-center justify-between gap-3 px-1 py-1 text-xs text-gray-500 dark:text-gray-400"
>
<span>{$i18n.t('Using default prompt suggestions')}</span>
<button
type="button"
class="shrink-0 hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => {
info.meta.suggestion_prompts = customPromptDraft ?? [
{ content: '', title: ['', ''] }
];
}}>{$i18n.t('Customize')}</button
>
</div>
{/if}
</div>
{#if editingLocale}
<LocalizedPromptSuggestions
promptSuggestions={info.meta.suggestion_prompts ?? []}
bind:localizedPromptSuggestions={info.meta.i18n}
locale={editingLocale}
localeLabel={editingLocaleLabel}
/>
{:else if info?.meta?.suggestion_prompts}
<PromptSuggestions bind:promptSuggestions={info.meta.suggestion_prompts} />
{/if}
</ModelSettingsSection>
</section>
<hr class="my-3 border-gray-100/60 dark:border-gray-850/60" />

View file

@ -1,21 +1,23 @@
<script lang="ts">
import { getContext } from 'svelte';
import { getContext, tick } from 'svelte';
import { saveAs } from 'file-saver';
import { toast } from 'svelte-sonner';
import Plus from '$lib/components/icons/Plus.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
const i18n = getContext<any>('i18n');
export let promptSuggestions = [];
export let onChange = (suggestions) => {};
export let inherited = false;
export let disabled = false;
let promptInputs: HTMLTextAreaElement[] = [];
let _promptSuggestions = [];
let importInput: HTMLInputElement;
const toolClass =
'flex size-7 shrink-0 items-center justify-center rounded-lg text-gray-500 transition hover:bg-black/5 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200 disabled:opacity-40';
const updateSuggestions = (suggestions) => {
if (disabled) return;
promptSuggestions = suggestions;
onChange(suggestions);
};
@ -49,181 +51,172 @@
}
</script>
<div class="space-y-2">
<div class="mb-1 flex min-h-7 w-full flex-wrap items-center justify-between gap-x-2 gap-y-1">
<div class="min-w-fit flex-1 self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<slot name="label">{$i18n.t('Default Prompt Suggestions')}</slot>
</div>
<div class="space-y-0">
<input
bind:this={importInput}
type="file"
accept=".json"
hidden
on:change={(e) => {
const files = e.target.files;
if (!files || files.length === 0) {
return;
}
<div class="ms-auto flex max-w-full shrink-0 items-center justify-end gap-1">
<slot name="actions" />
<input
bind:this={importInput}
type="file"
accept=".json"
hidden
on:change={(e) => {
const files = e.target.files;
if (!files || files.length === 0) {
return;
}
let reader = new FileReader();
reader.onload = async (event) => {
try {
let suggestions = JSON.parse(event.target.result);
if (
!Array.isArray(suggestions) ||
suggestions.some(
(s) =>
!s ||
typeof s.content !== 'string' ||
(s.title != null &&
typeof s.title !== 'string' &&
(!Array.isArray(s.title) || s.title.some((part) => typeof part !== 'string')))
)
)
throw new Error('Invalid prompt suggestions');
let reader = new FileReader();
reader.onload = async (event) => {
try {
let suggestions = JSON.parse(event.target.result);
if (
!Array.isArray(suggestions) ||
suggestions.some(
(s) =>
!s ||
typeof s.content !== 'string' ||
(s.title != null &&
typeof s.title !== 'string' &&
(!Array.isArray(s.title) || s.title.some((part) => typeof part !== 'string')))
)
)
throw new Error('Invalid prompt suggestions');
suggestions = suggestions.map((s) => {
if (typeof s.title === 'string') {
s.title = [s.title, ''];
} else if (!Array.isArray(s.title)) {
s.title = ['', ''];
}
return s;
});
updateSuggestions([...promptSuggestions, ...suggestions]);
} catch (error) {
toast.error($i18n.t('Invalid JSON file'));
return;
suggestions = suggestions.map((s) => {
if (typeof s.title === 'string') {
s.title = [s.title, ''];
} else if (!Array.isArray(s.title)) {
s.title = ['', ''];
}
};
reader.readAsText(files[0]);
return s;
});
e.target.value = ''; // Reset the input value
}}
/>
updateSuggestions([...promptSuggestions, ...suggestions]);
} catch (error) {
toast.error($i18n.t('Invalid JSON file'));
return;
}
};
<button
class="shrink-0 px-1 py-0.5 text-xs text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
type="button"
aria-label={$i18n.t('Import')}
on:click={() => importInput.click()}
>
{$i18n.t('Import')}
</button>
reader.readAsText(files[0]);
{#if promptSuggestions.length}
<button
class="shrink-0 px-1 py-0.5 text-xs text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
type="button"
aria-label={$i18n.t('Export')}
disabled={!promptSuggestions.length}
on:click={async () => {
let blob = new Blob([JSON.stringify(promptSuggestions)], {
type: 'application/json'
});
saveAs(blob, `prompt-suggestions-export-${Date.now()}.json`);
e.target.value = ''; // Reset the input value
}}
/>
{#each _promptSuggestions as prompt, promptIdx}
<div
class="rounded-xl px-2 py-1.5 focus-within:bg-gray-50 dark:focus-within:bg-gray-850 {inherited
? 'opacity-60 focus-within:opacity-100'
: ''}"
>
<div class="flex items-start gap-2">
<textarea
bind:this={promptInputs[promptIdx]}
class="min-h-5 min-w-0 flex-1 resize-none overflow-hidden bg-transparent text-[0.8125rem] font-normal leading-5 text-gray-900 outline-hidden placeholder:text-gray-500 dark:text-gray-100 dark:placeholder:text-gray-400"
placeholder={$i18n.t('Write a prompt…')}
aria-label={$i18n.t('Prompt')}
rows="1"
{disabled}
use:autosize={prompt.content}
value={prompt.content}
on:input={(e) => {
prompt.content = e.currentTarget.value;
updateSuggestions(_promptSuggestions);
}}
></textarea>
<Tooltip content={$i18n.t('Remove prompt suggestion')}>
<button
type="button"
{disabled}
aria-label={$i18n.t('Remove prompt suggestion')}
class="flex size-5 shrink-0 items-center justify-center text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300"
on:click={() =>
updateSuggestions(promptSuggestions.filter((_, index) => index !== promptIdx))}
>
<XMark className="size-3" />
</button>
</Tooltip>
</div>
<details class="group/prompt mt-0.5">
<summary
class="flex w-fit max-w-full cursor-pointer list-none items-center gap-1 text-xs text-gray-500 dark:text-gray-400 [&::-webkit-details-marker]:hidden"
>
{$i18n.t('Export')}
</button>
{/if}
<Tooltip content={$i18n.t('Add prompt suggestion')}>
<button
class={toolClass}
type="button"
aria-label={$i18n.t('Add prompt suggestion')}
on:click={() => {
if (promptSuggestions.length === 0 || promptSuggestions.at(-1).content !== '') {
updateSuggestions([...promptSuggestions, { content: '', title: ['', ''] }]);
}
}}
>
<Plus className="size-3.5" strokeWidth="2.25" />
</button>
</Tooltip>
</div>
</div>
{#if _promptSuggestions.length > 0}
<div class="flex flex-col gap-1.5">
{#each _promptSuggestions as prompt, promptIdx}
<div
class="flex gap-1 rounded-lg border border-gray-100/40 bg-transparent px-2 py-1 transition focus-within:border-gray-300 dark:border-gray-850/50 dark:focus-within:border-gray-600 {inherited
? 'opacity-60 focus-within:opacity-100'
: ''}"
>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<div class="grid min-w-0 gap-1 md:grid-cols-2 md:gap-1.5">
<Tooltip content={$i18n.t('e.g. Tell me a fun fact')} placement="top-start">
<input
class="w-full bg-transparent text-[0.8125rem] leading-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 font-normal text-gray-900 dark:text-gray-100"
placeholder={$i18n.t('Title')}
aria-label={$i18n.t('Title')}
value={prompt.title[0]}
on:input={(e) => {
prompt.title[0] = e.currentTarget.value;
updateSuggestions(_promptSuggestions);
}}
/>
</Tooltip>
<Tooltip content={$i18n.t('e.g. about the Roman Empire')} placement="top-start">
<input
class="w-full bg-transparent text-[0.8125rem] leading-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 font-normal text-gray-900 dark:text-gray-100"
placeholder={$i18n.t('Subtitle')}
aria-label={$i18n.t('Subtitle')}
value={prompt.title[1]}
on:input={(e) => {
prompt.title[1] = e.currentTarget.value;
updateSuggestions(_promptSuggestions);
}}
/>
</Tooltip>
</div>
<Tooltip
className="flex min-w-0"
content={$i18n.t('e.g. Tell me a fun fact about the Roman Empire')}
placement="top-start"
>
<textarea
class="min-h-5 w-full resize-none overflow-hidden bg-transparent text-[0.8125rem] leading-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 font-normal text-gray-900 dark:text-gray-100"
placeholder={$i18n.t('Content')}
aria-label={$i18n.t('Content')}
rows="1"
use:autosize={prompt.content}
value={prompt.content}
<ChevronRight
className="size-3 shrink-0 transition-transform group-open/prompt:rotate-90"
/>
<span class="shrink-0">{$i18n.t('Display text')}</span>
{#if prompt.title.some((part) => part.trim())}
<span class="truncate text-gray-600 dark:text-gray-300">
{prompt.title.filter((part) => part.trim()).join(' · ')}
</span>
{/if}
</summary>
<div class="grid gap-2 py-1.5 sm:grid-cols-2">
{#each ['Title', 'Subtitle'] as label, index}
<label class="flex min-w-0 flex-col gap-0.5 text-xs text-gray-500 dark:text-gray-400">
<span>{$i18n.t(label)}</span>
<input
{disabled}
class="min-w-0 w-full bg-transparent text-[0.8125rem] font-normal leading-5 text-gray-900 outline-hidden placeholder:text-gray-400 dark:text-gray-100 dark:placeholder:text-gray-500"
aria-label={$i18n.t(label)}
placeholder={index === 0
? $i18n.t('e.g. Tell me a fun fact')
: $i18n.t('e.g. about the Roman Empire')}
value={prompt.title[index]}
on:input={(e) => {
prompt.content = e.currentTarget.value;
prompt.title[index] = e.currentTarget.value;
updateSuggestions(_promptSuggestions);
}}
></textarea>
</Tooltip>
</div>
<button
class="flex size-6 shrink-0 items-center justify-center text-gray-400 opacity-70 transition hover:text-gray-700 hover:opacity-100 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
aria-label={$i18n.t('Remove prompt suggestion')}
on:click={() => {
updateSuggestions(promptSuggestions.filter((_, index) => index !== promptIdx));
}}
>
<XMark className="size-3.5" />
</button>
/>
</label>
{/each}
</div>
{/each}
</details>
</div>
{:else}
<div class="mb-1.5 w-full text-center text-xs text-gray-500 dark:text-gray-600">
<p class="px-2 py-1 text-xs text-gray-500 dark:text-gray-400">
{$i18n.t('No suggestion prompts')}
</p>
{/each}
<div
class="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 pt-1 text-xs text-gray-500 dark:text-gray-400"
>
<button
type="button"
{disabled}
aria-label={$i18n.t('Add prompt suggestion')}
class="flex items-center gap-1.5 px-2 py-1 text-xs text-gray-500 hover:text-gray-700 disabled:opacity-40 dark:text-gray-400 dark:hover:text-gray-300"
on:click={async () => {
if (promptSuggestions.length === 0 || promptSuggestions.at(-1).content.trim() !== '') {
updateSuggestions([...promptSuggestions, { content: '', title: ['', ''] }]);
}
await tick();
promptInputs[promptSuggestions.length - 1]?.focus();
}}
>
<Plus className="size-3" strokeWidth="1.5" />
{$i18n.t('Add prompt')}
</button>
<div class="ml-auto flex max-w-full flex-wrap items-center justify-end gap-3 px-2 py-1">
<slot name="label" />
<slot name="actions" />
<button
type="button"
{disabled}
class="hover:text-gray-900 disabled:opacity-40 dark:hover:text-gray-100"
on:click={() => importInput.click()}>{$i18n.t('Import')}</button
>
<button
type="button"
disabled={disabled || !promptSuggestions.length}
class="hover:text-gray-900 disabled:opacity-40 dark:hover:text-gray-100"
on:click={() => {
saveAs(
new Blob([JSON.stringify(promptSuggestions)], { type: 'application/json' }),
`prompt-suggestions-export-${Date.now()}.json`
);
}}>{$i18n.t('Export')}</button
>
</div>
{/if}
</div>
</div>

View file

@ -3820,5 +3820,11 @@
"Default terminal": "Default terminal",
"No terminal servers available": "No terminal servers available",
"Add tools in the Tools workspace or connect a tool server.": "Add tools in the Tools workspace or connect a tool server.",
"Used in native function calling mode. Workspace settings and user permissions still apply.": "Used in native function calling mode. Workspace settings and user permissions still apply."
"Used in native function calling mode. Workspace settings and user permissions still apply.": "Used in native function calling mode. Workspace settings and user permissions still apply.",
"Prompt actions": "Prompt actions",
"Write a prompt…": "Write a prompt…",
"Display text": "Display text",
"Add prompt": "Add prompt",
"Using default prompt suggestions": "Using default prompt suggestions",
"Customize": "Customize"
}