feat: prompt history frontend

This commit is contained in:
Timothy Jaeryang Baek
2026-01-24 02:40:17 +04:00
parent 34773e795b
commit 0c79a566ac
7 changed files with 768 additions and 124 deletions

View File

@@ -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;
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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:')}&nbsp;<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:')}&nbsp;<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}

View File

@@ -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>

View File

@@ -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 {