feat: prompt history frontend
This commit is contained in:
@@ -2,9 +2,45 @@ import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
type PromptItem = {
|
||||
command: string;
|
||||
title: string;
|
||||
name: string; // Changed from title
|
||||
content: string;
|
||||
data?: object | null;
|
||||
meta?: object | null;
|
||||
access_control?: null | object;
|
||||
version_id?: string | null; // Active version
|
||||
commit_message?: string | null; // For history tracking
|
||||
};
|
||||
|
||||
type PromptHistoryItem = {
|
||||
id: string;
|
||||
prompt_id: string;
|
||||
parent_id: string | null;
|
||||
snapshot: {
|
||||
name: string;
|
||||
content: string;
|
||||
command: string;
|
||||
data: object;
|
||||
meta: object;
|
||||
access_control: object | null;
|
||||
};
|
||||
user_id: string;
|
||||
commit_message: string | null;
|
||||
created_at: number;
|
||||
user?: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
|
||||
type PromptDiff = {
|
||||
from_id: string;
|
||||
to_id: string;
|
||||
from_snapshot: object;
|
||||
to_snapshot: object;
|
||||
content_diff: string[];
|
||||
name_changed: boolean;
|
||||
access_control_changed: boolean;
|
||||
};
|
||||
|
||||
export const createNewPrompt = async (token: string, prompt: PromptItem) => {
|
||||
@@ -19,7 +55,7 @@ export const createNewPrompt = async (token: string, prompt: PromptItem) => {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...prompt,
|
||||
command: `/${prompt.command}`
|
||||
command: prompt.command.startsWith('/') ? prompt.command.slice(1) : prompt.command
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
@@ -104,6 +140,8 @@ export const getPromptList = async (token: string = '') => {
|
||||
export const getPromptByCommand = async (token: string, command: string) => {
|
||||
let error = null;
|
||||
|
||||
command = command.charAt(0) === '/' ? command.slice(1) : command;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/command/${command}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -136,7 +174,9 @@ export const getPromptByCommand = async (token: string, command: string) => {
|
||||
export const updatePromptByCommand = async (token: string, prompt: PromptItem) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/command/${prompt.command}/update`, {
|
||||
const command = prompt.command.charAt(0) === '/' ? prompt.command.slice(1) : prompt.command;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/command/${command}/update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -145,7 +185,7 @@ export const updatePromptByCommand = async (token: string, prompt: PromptItem) =
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...prompt,
|
||||
command: `/${prompt.command}`
|
||||
command: command
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
@@ -169,6 +209,41 @@ export const updatePromptByCommand = async (token: string, prompt: PromptItem) =
|
||||
return res;
|
||||
};
|
||||
|
||||
export const setProductionPromptVersion = async (
|
||||
token: string,
|
||||
command: string,
|
||||
version_id: string
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/command/${command}/set/version`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
version_id: version_id
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const deletePromptByCommand = async (token: string, command: string) => {
|
||||
let error = null;
|
||||
|
||||
@@ -202,3 +277,200 @@ export const deletePromptByCommand = async (token: string, command: string) => {
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
////////////////////////////
|
||||
// Prompt History APIs
|
||||
////////////////////////////
|
||||
|
||||
export const getPromptHistory = async (
|
||||
token: string,
|
||||
command: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<PromptHistoryItem[]> => {
|
||||
let error = null;
|
||||
|
||||
command = command.charAt(0) === '/' ? command.slice(1) : command;
|
||||
|
||||
const res = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/prompts/command/${command}/history?limit=${limit}&offset=${offset}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const deletePromptHistoryVersion = async (
|
||||
token: string,
|
||||
command: string,
|
||||
historyId: string
|
||||
): Promise<boolean> => {
|
||||
let error = null;
|
||||
|
||||
command = command.charAt(0) === '/' ? command.slice(1) : command;
|
||||
|
||||
const res = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/prompts/command/${command}/history/${historyId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.error(err);
|
||||
return false;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getPromptHistoryEntry = async (
|
||||
token: string,
|
||||
command: string,
|
||||
historyId: string
|
||||
): Promise<PromptHistoryItem> => {
|
||||
let error = null;
|
||||
|
||||
command = command.charAt(0) === '/' ? command.slice(1) : command;
|
||||
|
||||
const res = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/prompts/command/${command}/history/${historyId}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const restorePromptFromHistory = async (
|
||||
token: string,
|
||||
command: string,
|
||||
historyId: string,
|
||||
commitMessage?: string
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
command = command.charAt(0) === '/' ? command.slice(1) : command;
|
||||
|
||||
const res = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/prompts/command/${command}/history/${historyId}/restore`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
commit_message: commitMessage
|
||||
})
|
||||
}
|
||||
)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getPromptDiff = async (
|
||||
token: string,
|
||||
command: string,
|
||||
fromId: string,
|
||||
toId: string
|
||||
): Promise<PromptDiff> => {
|
||||
let error = null;
|
||||
|
||||
command = command.charAt(0) === '/' ? command.slice(1) : command;
|
||||
|
||||
const res = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/prompts/command/${command}/history/diff?from_id=${fromId}&to_id=${toId}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
$: filteredItems = prompts
|
||||
.filter((p) => p.command.toLowerCase().includes(query.toLowerCase()))
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
$: if (query) {
|
||||
selectedPromptIdx = 0;
|
||||
@@ -42,7 +42,7 @@
|
||||
{#if filteredItems.length > 0}
|
||||
<div class=" space-y-0.5 scrollbar-hidden">
|
||||
{#each filteredItems as promptItem, promptIdx}
|
||||
<Tooltip content={promptItem.title} placement="top-start">
|
||||
<Tooltip content={promptItem.name} placement="top-start">
|
||||
<button
|
||||
class=" px-3 py-1 rounded-xl w-full text-left {promptIdx === selectedPromptIdx
|
||||
? ' bg-gray-50 dark:bg-gray-800 selected-command-option-button'
|
||||
@@ -62,7 +62,7 @@
|
||||
</span>
|
||||
|
||||
<span class=" text-xs text-gray-600 dark:text-gray-100">
|
||||
{promptItem.title}
|
||||
{promptItem.name}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class=" text-xs font-semibold {classNames[type] ??
|
||||
classNames['info']} w-fit px-2 rounded-sm uppercase line-clamp-1 mr-0.5"
|
||||
class=" text-xs font-medium {classNames[type] ??
|
||||
classNames['info']} w-fit px-1.5 py-[1px] rounded-lg uppercase line-clamp-1 mr-0.5"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
|
||||
@@ -343,7 +343,7 @@
|
||||
<div class=" flex flex-col flex-1 space-x-4 cursor-pointer w-full pl-1">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-medium line-clamp-1 capitalize">{prompt.title}</div>
|
||||
<div class="font-medium line-clamp-1 capitalize">{prompt.name}</div>
|
||||
<div class="text-xs overflow-hidden text-ellipsis line-clamp-1 text-gray-500">
|
||||
{prompt.command}
|
||||
</div>
|
||||
|
||||
@@ -4,12 +4,25 @@
|
||||
import Textarea from '$lib/components/common/Textarea.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import AccessControl from '../common/AccessControl.svelte';
|
||||
import LockClosed from '$lib/components/icons/LockClosed.svelte';
|
||||
import AccessControlModal from '../common/AccessControlModal.svelte';
|
||||
import { user } from '$lib/stores';
|
||||
import { slugify } from '$lib/utils';
|
||||
import { slugify, formatDate } from '$lib/utils';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import {
|
||||
getPromptHistory,
|
||||
updatePromptByCommand,
|
||||
setProductionPromptVersion,
|
||||
deletePromptHistoryVersion
|
||||
} from '$lib/apis/prompts';
|
||||
import dayjs from 'dayjs';
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
import PromptHistoryMenu from './PromptHistoryMenu.svelte';
|
||||
import Badge from '$lib/components/common/Badge.svelte';
|
||||
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
export let onSubmit: Function;
|
||||
export let edit = false;
|
||||
@@ -20,22 +33,25 @@
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
let loading = false;
|
||||
let showEditModal = false;
|
||||
|
||||
let title = '';
|
||||
let name = '';
|
||||
let command = '';
|
||||
let content = '';
|
||||
let commitMessage = '';
|
||||
|
||||
let accessControl = {};
|
||||
|
||||
let showAccessControlModal = false;
|
||||
|
||||
let hasManualEdit = false;
|
||||
|
||||
let history: any[] = [];
|
||||
let historyLoading = false;
|
||||
let selectedHistoryEntry: any = null;
|
||||
|
||||
$: if (!edit && !hasManualEdit) {
|
||||
command = title !== '' ? slugify(title) : '';
|
||||
command = name !== '' ? slugify(name) : '';
|
||||
}
|
||||
|
||||
// Track manual edits
|
||||
function handleCommandInput(e: Event) {
|
||||
hasManualEdit = true;
|
||||
}
|
||||
@@ -49,11 +65,15 @@
|
||||
|
||||
if (validateCommandString(command)) {
|
||||
await onSubmit({
|
||||
title,
|
||||
name,
|
||||
command,
|
||||
content,
|
||||
access_control: accessControl
|
||||
access_control: accessControl,
|
||||
commit_message: commitMessage || undefined
|
||||
});
|
||||
showEditModal = false;
|
||||
commitMessage = '';
|
||||
await loadHistory();
|
||||
} else {
|
||||
toast.error(
|
||||
$i18n.t('Only alphanumeric characters and hyphens are allowed in the command string.')
|
||||
@@ -64,22 +84,79 @@
|
||||
};
|
||||
|
||||
const validateCommandString = (inputString) => {
|
||||
// Regular expression to match only alphanumeric characters, hyphen, and underscore
|
||||
const regex = /^[a-zA-Z0-9-_]+$/;
|
||||
|
||||
// Test the input string against the regular expression
|
||||
return regex.test(inputString);
|
||||
};
|
||||
|
||||
const loadHistory = async () => {
|
||||
if (!prompt?.command || !edit) return;
|
||||
historyLoading = true;
|
||||
try {
|
||||
history = await getPromptHistory(localStorage.token, prompt.command);
|
||||
} catch (error) {
|
||||
console.error('Failed to load history:', error);
|
||||
history = [];
|
||||
}
|
||||
historyLoading = false;
|
||||
};
|
||||
|
||||
const setAsProduction = async (historyEntry: any) => {
|
||||
if (disabled) {
|
||||
toast.error($i18n.t('You do not have permission to edit this prompt.'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await setProductionPromptVersion(localStorage.token, prompt.command, historyEntry.id);
|
||||
// Update local prompt object to trigger reactivity
|
||||
prompt = { ...prompt, version_id: historyEntry.id };
|
||||
toast.success($i18n.t('Production version updated'));
|
||||
} catch (error) {
|
||||
toast.error(`${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteHistory = async (historyId: string) => {
|
||||
if (disabled) return;
|
||||
|
||||
try {
|
||||
await deletePromptHistoryVersion(localStorage.token, prompt.command, historyId);
|
||||
toast.success($i18n.t('Version deleted'));
|
||||
// Reload history
|
||||
await loadHistory();
|
||||
// Reset selection if deleted entry was selected
|
||||
if (selectedHistoryEntry?.id === historyId) {
|
||||
selectedHistoryEntry = history.length > 0 ? history[0] : null;
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const renderDate = (timestamp: number) => {
|
||||
const dateVal = timestamp * 1000;
|
||||
return $i18n.t(formatDate(dateVal), {
|
||||
LOCALIZED_TIME: dayjs(dateVal).format('LT'),
|
||||
LOCALIZED_DATE: dayjs(dateVal).format('L')
|
||||
});
|
||||
};
|
||||
onMount(async () => {
|
||||
if (prompt) {
|
||||
title = prompt.title;
|
||||
name = prompt.name || '';
|
||||
await tick();
|
||||
|
||||
command = prompt.command.at(0) === '/' ? prompt.command.slice(1) : prompt.command;
|
||||
content = prompt.content;
|
||||
|
||||
accessControl = prompt?.access_control === undefined ? {} : prompt?.access_control;
|
||||
|
||||
if (edit) {
|
||||
await loadHistory();
|
||||
// Auto-select production version
|
||||
if (prompt.version_id && history.length > 0) {
|
||||
selectedHistoryEntry = history.find((h) => h.id === prompt.version_id) || history[0];
|
||||
} else if (history.length > 0) {
|
||||
selectedHistoryEntry = history[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -92,124 +169,328 @@
|
||||
sharePublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin'}
|
||||
/>
|
||||
|
||||
<div class="w-full max-h-full flex justify-center">
|
||||
<form
|
||||
class="flex flex-col w-full mb-10"
|
||||
on:submit|preventDefault={() => {
|
||||
submitHandler();
|
||||
}}
|
||||
>
|
||||
<div class="my-2">
|
||||
<Tooltip
|
||||
content={`${$i18n.t('Only alphanumeric characters and hyphens are allowed')} - ${$i18n.t(
|
||||
'Activate this command by typing "/{{COMMAND}}" to chat input.',
|
||||
{
|
||||
COMMAND: command
|
||||
}
|
||||
)}`}
|
||||
placement="bottom-start"
|
||||
<!-- Edit Modal -->
|
||||
<Modal size="lg" bind:show={showEditModal}>
|
||||
<div class="px-5 pt-4 pb-5">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<div class="text-lg font-medium">{$i18n.t('Edit Prompt')}</div>
|
||||
<button
|
||||
class="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg"
|
||||
on:click={() => (showEditModal = false)}
|
||||
>
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
class="text-2xl font-medium w-full bg-transparent outline-hidden"
|
||||
placeholder={$i18n.t('Title')}
|
||||
bind:value={title}
|
||||
required
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
{#if disabled}
|
||||
<div class="text-xs shrink-0 text-gray-500">
|
||||
{$i18n.t('Read Only')}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="self-center shrink-0">
|
||||
<button
|
||||
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 rounded-full flex gap-1 items-center"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showAccessControlModal = true;
|
||||
}}
|
||||
>
|
||||
<LockClosed strokeWidth="2.5" className="size-3.5" />
|
||||
|
||||
<div class="text-sm font-medium shrink-0">
|
||||
{$i18n.t('Access')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-0.5 items-center text-xs text-gray-500">
|
||||
<div class="">/</div>
|
||||
<input
|
||||
class=" w-full bg-transparent outline-hidden"
|
||||
placeholder={$i18n.t('Command')}
|
||||
bind:value={command}
|
||||
on:input={handleCommandInput}
|
||||
required
|
||||
disabled={edit || disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<XMark className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="my-2">
|
||||
<div class="flex w-full justify-between">
|
||||
<div class=" self-center text-sm font-medium">{$i18n.t('Prompt Content')}</div>
|
||||
<form on:submit|preventDefault={submitHandler}>
|
||||
<div class="my-2">
|
||||
<Tooltip
|
||||
content={`${$i18n.t('Only alphanumeric characters and hyphens are allowed')} - ${$i18n.t('Activate this command by typing "/{{COMMAND}}" to chat input.', { COMMAND: command })}`}
|
||||
placement="bottom-start"
|
||||
>
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
class="text-2xl font-medium w-full bg-transparent outline-hidden"
|
||||
placeholder={$i18n.t('Name')}
|
||||
bind:value={name}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-0.5 items-center text-xs text-gray-500">
|
||||
<div>/</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden"
|
||||
placeholder={$i18n.t('Command')}
|
||||
bind:value={command}
|
||||
on:input={handleCommandInput}
|
||||
required
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<div>
|
||||
<div class="my-2">
|
||||
<div class="flex w-full justify-between">
|
||||
<div class="text-gray-500 text-xs">{$i18n.t('Prompt Content')}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-1">
|
||||
<Textarea
|
||||
className="text-sm w-full bg-transparent outline-hidden overflow-y-hidden resize-none"
|
||||
placeholder={$i18n.t('Write a summary in 50 words that summarizes {{topic}}.')}
|
||||
bind:value={content}
|
||||
rows={6}
|
||||
required
|
||||
readonly={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">
|
||||
ⓘ {$i18n.t('Format your variables using brackets like this:')} <span
|
||||
class=" text-gray-600 dark:text-gray-300 font-medium"
|
||||
>{'{{'}{$i18n.t('variable')}{'}}'}</span
|
||||
>.
|
||||
{$i18n.t('Make sure to enclose them with')}
|
||||
<span class=" text-gray-600 dark:text-gray-300 font-medium">{'{{'}</span>
|
||||
{$i18n.t('and')}
|
||||
<span class=" text-gray-600 dark:text-gray-300 font-medium">{'}}'}</span>.
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">
|
||||
ⓘ {$i18n.t('Format your variables using brackets like this:')} <span
|
||||
class="text-gray-600 dark:text-gray-300 font-medium"
|
||||
>{'{{'}{$i18n.t('variable')}{'}}'}</span
|
||||
>.
|
||||
{$i18n.t('Make sure to enclose them with')}
|
||||
<span class="text-gray-600 dark:text-gray-300 font-medium">{'{{'}</span>
|
||||
{$i18n.t('and')}
|
||||
<span class="text-gray-600 dark:text-gray-300 font-medium">{'}}'}</span>.
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500 underline">
|
||||
<a href="https://docs.openwebui.com/features/workspace/prompts" target="_blank">
|
||||
{$i18n.t('To learn more about powerful prompt variables, click here')}
|
||||
</a>
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500 underline">
|
||||
<a href="https://docs.openwebui.com/features/workspace/prompts" target="_blank">
|
||||
{$i18n.t('To learn more about powerful prompt variables, click here')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-4 flex justify-end pb-20">
|
||||
<Tooltip content={disabled ? $i18n.t('You do not have permission to save this prompt.') : ''}>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button
|
||||
class=" text-sm w-full lg:w-fit px-4 py-2 transition rounded-xl {loading || disabled
|
||||
? ' cursor-not-allowed bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400'
|
||||
: 'bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'} flex w-full justify-center"
|
||||
class="text-sm px-4 py-2 transition rounded-full {loading
|
||||
? 'cursor-not-allowed bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400'
|
||||
: 'bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'} flex justify-center"
|
||||
type="submit"
|
||||
disabled={loading || disabled}
|
||||
disabled={loading}
|
||||
>
|
||||
<div class=" self-center font-medium">{$i18n.t('Save & Create')}</div>
|
||||
<div class="font-medium">{$i18n.t('Save')}</div>
|
||||
{#if loading}
|
||||
<div class="ml-1.5 self-center">
|
||||
<div class="ml-1.5">
|
||||
<Spinner />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{#if edit}
|
||||
<!-- Edit mode: Read-only view with history -->
|
||||
<div class="w-full max-h-full">
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-2xl font-medium truncate">{name}</h1>
|
||||
<div class="text-sm text-gray-500 mt-0.5">/{command}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
{#if !disabled}
|
||||
<button
|
||||
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2.5 py-1.5 rounded-full flex gap-1.5 items-center text-sm border border-gray-100 dark:border-gray-800"
|
||||
on:click={() => (showAccessControlModal = true)}
|
||||
>
|
||||
<LockClosed strokeWidth="2.5" className="size-3.5" />
|
||||
{$i18n.t('Access')}
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-1.5 text-sm font-medium bg-black text-white dark:bg-white dark:text-black rounded-full hover:opacity-90 transition shadow-xs"
|
||||
on:click={() => (showEditModal = true)}
|
||||
>
|
||||
{$i18n.t('Edit')}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-500 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded-full"
|
||||
>{$i18n.t('Read Only')}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-6">
|
||||
<!-- Desktop History Sidebar -->
|
||||
<div class="hidden lg:block w-72 shrink-0 mt-1 mb-2">
|
||||
<div class="sticky">
|
||||
{@render historySection()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prompt Content -->
|
||||
<div class="mb-6 flex-1">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-gray-500 text-xs">
|
||||
{$i18n.t('Prompt Content')}
|
||||
</div>
|
||||
{#if selectedHistoryEntry}
|
||||
<span
|
||||
class="text-xs text-gray-500 font-mono bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded"
|
||||
>
|
||||
{selectedHistoryEntry.id.slice(0, 7)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedHistoryEntry && !disabled}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if selectedHistoryEntry.id === prompt?.version_id}
|
||||
<Badge type="success" content={$i18n.t('Live')} />
|
||||
{:else}
|
||||
<button
|
||||
class="text-xs text-gray-500 hover:text-gray-900 dark:hover:text-gray-300 hover:underline transition"
|
||||
on:click={() => setAsProduction(selectedHistoryEntry)}
|
||||
>
|
||||
{$i18n.t('Set as Production')}
|
||||
</button>
|
||||
{/if}
|
||||
<PromptHistoryMenu
|
||||
isProduction={selectedHistoryEntry.id === prompt?.version_id}
|
||||
onDelete={() => handleDeleteHistory(selectedHistoryEntry.id)}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-900 rounded-xl px-4 py-3 border border-gray-100 dark:border-gray-800"
|
||||
>
|
||||
<pre class="text-sm whitespace-pre-wrap font-mono">{selectedHistoryEntry?.snapshot
|
||||
?.content || content}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile History -->
|
||||
<div class="lg:hidden pb-20">
|
||||
{@render historySection()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Create mode: Form -->
|
||||
<div class="w-full max-h-full flex justify-center">
|
||||
<form class="flex flex-col w-full mb-10" on:submit|preventDefault={submitHandler}>
|
||||
<div class="mb-2">
|
||||
<Tooltip
|
||||
content={`${$i18n.t('Only alphanumeric characters and hyphens are allowed')} - ${$i18n.t('Activate this command by typing "/{{COMMAND}}" to chat input.', { COMMAND: command })}`}
|
||||
placement="bottom-start"
|
||||
>
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
class="text-2xl font-medium w-full bg-transparent outline-hidden"
|
||||
placeholder={$i18n.t('Name')}
|
||||
bind:value={name}
|
||||
required
|
||||
/>
|
||||
<div class="self-center shrink-0">
|
||||
<button
|
||||
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 rounded-full flex gap-1 items-center"
|
||||
type="button"
|
||||
on:click={() => (showAccessControlModal = true)}
|
||||
>
|
||||
<LockClosed strokeWidth="2.5" className="size-3.5" />
|
||||
<div class="text-sm font-medium shrink-0">{$i18n.t('Access')}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-0.5 items-center text-xs text-gray-500">
|
||||
<div>/</div>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden"
|
||||
placeholder={$i18n.t('Command')}
|
||||
bind:value={command}
|
||||
on:input={handleCommandInput}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="my-2">
|
||||
<div class="text-gray-500 text-xs">{$i18n.t('Prompt Content')}</div>
|
||||
<div class="mt-1">
|
||||
<Textarea
|
||||
className="text-sm w-full bg-transparent outline-hidden overflow-y-hidden resize-none"
|
||||
placeholder={$i18n.t('Write a summary in 50 words that summarizes {{topic}}.')}
|
||||
bind:value={content}
|
||||
rows={6}
|
||||
required
|
||||
/>
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">
|
||||
ⓘ {$i18n.t('Use')}
|
||||
<span class="font-medium text-gray-600 dark:text-gray-300"
|
||||
>{'{{'}{$i18n.t('variable')}{'}}'}</span
|
||||
>
|
||||
{$i18n.t('for placeholders')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-4 flex justify-end pb-20">
|
||||
<button
|
||||
class="text-sm w-full lg:w-fit px-4 py-2 transition rounded-xl bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black flex w-full justify-center"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
<div class="font-medium">{$i18n.t('Save & Create')}</div>
|
||||
{#if loading}
|
||||
<div class="ml-1.5">
|
||||
<Spinner />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#snippet historySection()}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="text-gray-500 text-xs">{$i18n.t('History')}</div>
|
||||
{#if historyLoading}
|
||||
<Spinner className="size-3" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if history.length > 0}
|
||||
<div class="space-y-0">
|
||||
{#each history as entry, index}
|
||||
<div class="flex">
|
||||
<!-- Content -->
|
||||
<button
|
||||
class="flex-1 text-left px-3.5 py-2 mb-1 rounded-2xl transition group
|
||||
{selectedHistoryEntry?.id === entry.id
|
||||
? 'bg-gray-50 dark:bg-gray-850'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-850'}"
|
||||
on:click={() => (selectedHistoryEntry = entry)}
|
||||
>
|
||||
<!-- Commit Message -->
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="text-xs text-gray-900 dark:text-white truncate">
|
||||
{entry.commit_message || $i18n.t('Update')}
|
||||
</div>
|
||||
{#if entry.id === prompt?.version_id}
|
||||
<Badge type="success" content={$i18n.t('Live')} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- User + Time -->
|
||||
<div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{#if entry.user}
|
||||
<img
|
||||
src={`/api/v1/users/${entry.user.id}/profile/image`}
|
||||
alt={entry.user.name}
|
||||
class="size-3 rounded-full"
|
||||
on:error={(e) => (e.target.src = '/user.png')}
|
||||
/>
|
||||
<span class="truncate">{entry.user.name}</span>
|
||||
<span>•</span>
|
||||
{/if}
|
||||
<span class="shrink-0">{renderDate(entry.created_at)}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !historyLoading}
|
||||
<div class="text-xs text-gray-400 text-center py-6 italic">
|
||||
{$i18n.t('No history available')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu } from 'bits-ui';
|
||||
import { flyAndScale } from '$lib/utils/transitions';
|
||||
import { getContext, createEventDispatcher } from 'svelte';
|
||||
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let isProduction = false;
|
||||
export let onDelete: Function;
|
||||
export let onClose: Function;
|
||||
|
||||
let show = false;
|
||||
let showDeleteConfirmDialog = false;
|
||||
</script>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteConfirmDialog}
|
||||
title={$i18n.t('Delete Version')}
|
||||
message={$i18n.t(
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent."
|
||||
)}
|
||||
confirmLabel={$i18n.t('Delete')}
|
||||
onConfirm={() => {
|
||||
onDelete();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
bind:show
|
||||
on:change={(e) => {
|
||||
if (e.detail === false) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip content={$i18n.t('More')}>
|
||||
<slot>
|
||||
<button
|
||||
class="p-1 rounded-lg text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
|
||||
>
|
||||
<EllipsisHorizontal className="size-5" />
|
||||
</button>
|
||||
</slot>
|
||||
</Tooltip>
|
||||
|
||||
<div slot="content">
|
||||
<DropdownMenu.Content
|
||||
class="w-full max-w-[170px] rounded-2xl p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
|
||||
sideOffset={-2}
|
||||
side="bottom"
|
||||
align="end"
|
||||
transition={flyAndScale}
|
||||
>
|
||||
{#if isProduction}
|
||||
<Tooltip content={$i18n.t('Cannot delete the production version')} placement="top">
|
||||
<div
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm rounded-xl opacity-40 cursor-not-allowed"
|
||||
>
|
||||
<GarbageBin />
|
||||
<div class="flex items-center">{$i18n.t('Delete')}</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{:else}
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
showDeleteConfirmDialog = true;
|
||||
}}
|
||||
>
|
||||
<GarbageBin />
|
||||
<div class="flex items-center">{$i18n.t('Delete')}</div>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</div>
|
||||
</Dropdown>
|
||||
@@ -16,15 +16,22 @@
|
||||
|
||||
const onSubmit = async (_prompt) => {
|
||||
console.log(_prompt);
|
||||
const prompt = await updatePromptByCommand(localStorage.token, _prompt).catch((error) => {
|
||||
const updatedPrompt = await updatePromptByCommand(localStorage.token, _prompt).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (prompt) {
|
||||
if (updatedPrompt) {
|
||||
toast.success($i18n.t('Prompt updated successfully'));
|
||||
await prompts.set(await getPrompts(localStorage.token));
|
||||
await goto('/workspace/prompts');
|
||||
// Update local prompt state to reflect the new version
|
||||
prompt = {
|
||||
name: updatedPrompt.name,
|
||||
command: updatedPrompt.command,
|
||||
content: updatedPrompt.content,
|
||||
version_id: updatedPrompt.version_id,
|
||||
access_control: updatedPrompt?.access_control === undefined ? {} : updatedPrompt?.access_control
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,9 +49,10 @@
|
||||
if (_prompt) {
|
||||
disabled = !_prompt.write_access ?? true;
|
||||
prompt = {
|
||||
title: _prompt.title,
|
||||
name: _prompt.name,
|
||||
command: _prompt.command,
|
||||
content: _prompt.content,
|
||||
version_id: _prompt.version_id,
|
||||
access_control: _prompt?.access_control === undefined ? {} : _prompt?.access_control
|
||||
};
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user