| <script lang="ts"> |
| import { onMount, tick, getContext } from 'svelte'; |
| |
| import Textarea from '$lib/components/common/Textarea.svelte'; |
| import { toast } from 'svelte-sonner'; |
| import Tooltip from '$lib/components/common/Tooltip.svelte'; |
| import LockClosed from '$lib/components/icons/LockClosed.svelte'; |
| import Clipboard from '$lib/components/icons/Clipboard.svelte'; |
| import Check from '$lib/components/icons/Check.svelte'; |
| import AccessControlModal from '../common/AccessControlModal.svelte'; |
| import { user } from '$lib/stores'; |
| import { slugify, formatDate, copyToClipboard } 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, |
| setProductionPromptVersion, |
| deletePromptHistoryVersion, |
| updatePromptMetadata, |
| updatePromptAccessGrants, |
| getPromptTags |
| } 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'; |
| import Tags from '$lib/components/common/Tags.svelte'; |
| |
| dayjs.extend(localizedFormat); |
| |
| export let onSubmit: Function; |
| export let edit = false; |
| export let prompt = null; |
| export let clone = false; |
| export let disabled = false; |
| |
| const i18n = getContext('i18n'); |
| |
| let loading = false; |
| let showEditModal = false; |
| |
| let name = ''; |
| let command = ''; |
| let content = ''; |
| let tags = []; |
| let commitMessage = ''; |
| let isProduction = true; |
| |
| let accessGrants = []; |
| let showAccessControlModal = false; |
| let hasManualEdit = false; |
| |
| let history: any[] = []; |
| let historyLoading = false; |
| let selectedHistoryEntry: any = null; |
| let historyPage = 0; |
| let historyHasMore = true; |
| let contentCopied = false; |
| |
| |
| let originalName = ''; |
| let originalCommand = ''; |
| let originalTags = []; |
| let debounceTimer: ReturnType<typeof setTimeout> | null = null; |
| |
| let suggestionTags = []; |
| |
| $: if (!edit && !hasManualEdit) { |
| command = name !== '' ? slugify(name) : ''; |
| } |
| |
| function handleCommandInput(e: Event) { |
| hasManualEdit = true; |
| } |
| |
| const submitHandler = async () => { |
| if (disabled) { |
| toast.error($i18n.t('You do not have permission to edit this prompt.')); |
| return; |
| } |
| loading = true; |
| |
| if (validateCommandString(command)) { |
| await onSubmit({ |
| id: prompt?.id, |
| name, |
| command, |
| content, |
| tags: tags.map((tag) => tag.name), |
| access_grants: accessGrants, |
| commit_message: commitMessage || undefined, |
| is_production: isProduction |
| }); |
| showEditModal = false; |
| commitMessage = ''; |
| isProduction = true; |
| await loadHistory(true); |
| |
| if (history.length > 0) { |
| selectedHistoryEntry = history[0]; |
| } |
| } else { |
| toast.error( |
| $i18n.t('Only alphanumeric characters and hyphens are allowed in the command string.') |
| ); |
| } |
| |
| loading = false; |
| }; |
| |
| const validateCommandString = (inputString) => { |
| const regex = /^[a-zA-Z0-9-_]+$/; |
| return regex.test(inputString); |
| }; |
| |
| const loadHistory = async (reset = false) => { |
| if (!prompt?.id || !edit) return; |
| if (historyLoading) return; |
| if (!reset && !historyHasMore) return; |
| |
| historyLoading = true; |
| |
| if (reset) { |
| historyPage = 0; |
| historyHasMore = true; |
| } |
| |
| try { |
| const newEntries = await getPromptHistory(localStorage.token, prompt.id, historyPage); |
| |
| if (reset) { |
| history = newEntries; |
| } else { |
| history = [...history, ...newEntries]; |
| } |
| |
| historyHasMore = newEntries.length > 0; |
| historyPage = historyPage + 1; |
| } catch (error) { |
| console.error('Failed to load history:', error); |
| if (reset) { |
| history = []; |
| } |
| } |
| historyLoading = false; |
| }; |
| |
| const handleHistoryScroll = (e: Event) => { |
| const target = e.target as HTMLElement; |
| const nearBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 50; |
| if (nearBottom && historyHasMore && !historyLoading) { |
| loadHistory(false); |
| } |
| }; |
| |
| const copyContent = async () => { |
| const textToCopy = selectedHistoryEntry?.snapshot?.content || content; |
| const success = await copyToClipboard(textToCopy); |
| if (success) { |
| contentCopied = true; |
| setTimeout(() => { |
| contentCopied = false; |
| }, 2000); |
| } |
| }; |
| |
| 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.id, 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.id, historyId); |
| toast.success($i18n.t('Version deleted')); |
| // Reload history from scratch |
| await loadHistory(true); |
| // 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') |
| }); |
| }; |
| |
| const debouncedSaveMetadata = () => { |
| if (disabled || !edit) return; |
| |
| if (debounceTimer) { |
| clearTimeout(debounceTimer); |
| } |
| |
| debounceTimer = setTimeout(async () => { |
| if (!validateCommandString(command)) { |
| toast.error( |
| $i18n.t('Only alphanumeric characters and hyphens are allowed in the command string.') |
| ); |
| command = originalCommand; |
| return; |
| } |
| |
| try { |
| await updatePromptMetadata( |
| localStorage.token, |
| prompt?.id, |
| name, |
| command, |
| tags.map((tag) => tag.name) |
| ); |
| // Update originals on success |
| originalName = name; |
| originalCommand = command; |
| originalTags = tags; |
| toast.success($i18n.t('Saved')); |
| } catch (error) { |
| toast.error(`${error}`); |
| // Revert on error (collision) |
| name = originalName; |
| command = originalCommand; |
| tags = originalTags; |
| } |
| }, 500); |
| }; |
| |
| onMount(async () => { |
| if (prompt) { |
| name = prompt.name || ''; |
| await tick(); |
| command = prompt.command.at(0) === '/' ? prompt.command.slice(1) : prompt.command; |
| content = prompt.content; |
| tags = (prompt.tags || []).map((tag) => ({ name: tag })); |
| accessGrants = prompt?.access_grants === undefined ? [] : prompt?.access_grants; |
| |
| |
| originalName = name; |
| originalCommand = command; |
| originalTags = tags; |
| |
| 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]; |
| } |
| } |
| } |
| |
| const res = await getPromptTags(localStorage.token); |
| if (res) { |
| suggestionTags = res.map((tag) => ({ name: tag })); |
| } |
| }); |
| </script> |
| |
| <AccessControlModal |
| bind:show={showAccessControlModal} |
| bind:accessGrants |
| accessRoles={['read', 'write']} |
| share={$user?.permissions?.sharing?.prompts || $user?.role === 'admin'} |
| sharePublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin'} |
| onChange={async () => { |
| if (edit && prompt?.id) { |
| try { |
| await updatePromptAccessGrants(localStorage.token, prompt.id, accessGrants); |
| toast.success($i18n.t('Saved')); |
| } catch (error) { |
| toast.error(`${error}`); |
| } |
| } |
| }} |
| /> |
| |
| |
| <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)} |
| > |
| <XMark className="size-5" /> |
| </button> |
| </div> |
| |
| <form on:submit|preventDefault={submitHandler}> |
| <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 |
| /> |
| </div> |
| </div> |
| |
| <div class="my-2"> |
| <div class="text-gray-500 text-xs">{$i18n.t('Commit Message')} ({$i18n.t('optional')})</div> |
| <div class="mt-1"> |
| <input |
| class="text-sm w-full bg-transparent outline-hidden" |
| placeholder={$i18n.t('Describe what changed...')} |
| bind:value={commitMessage} |
| /> |
| </div> |
| </div> |
| |
| <div class="mt-4 flex items-center justify-between"> |
| <label class="flex items-center gap-2 cursor-pointer"> |
| <input |
| type="checkbox" |
| bind:checked={isProduction} |
| class="w-4 h-4 rounded border-gray-300 dark:border-gray-600" |
| /> |
| <span class="text-sm text-gray-700 dark:text-gray-300" |
| >{$i18n.t('Set as Production')}</span |
| > |
| </label> |
| <div> |
| <button |
| 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} |
| > |
| <div class="font-medium">{$i18n.t('Save')}</div> |
| {#if loading} |
| <div class="ml-1.5"> |
| <Spinner /> |
| </div> |
| {/if} |
| </button> |
| </div> |
| </div> |
| </form> |
| </div> |
| </Modal> |
| |
| {#if edit} |
| |
| <div class="flex flex-col w-full h-full max-h-[100dvh]"> |
| |
| <div class="flex items-start justify-between gap-4 shrink-0"> |
| <div class="min-w-0 flex-1"> |
| <input |
| class="text-2xl w-full bg-transparent outline-hidden" |
| placeholder={$i18n.t('Prompt Name')} |
| bind:value={name} |
| on:input={debouncedSaveMetadata} |
| {disabled} |
| /> |
| |
| <div class="flex items-center gap-0.5 text-sm text-gray-500 w-full flex-1"> |
| <span>/</span> |
| <input |
| class="bg-transparent outline-hidden" |
| placeholder={$i18n.t('command')} |
| bind:value={command} |
| on:input={debouncedSaveMetadata} |
| {disabled} |
| /> |
| </div> |
| </div> |
| |
| <div> |
| <div class="flex items-center gap-2 shrink-0 justify-end"> |
| {#if !disabled} |
| <button |
| class="px-4 py-1 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> |
| |
| <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 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> |
| {: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 class="mt-1.5"> |
| <Tooltip content={$i18n.t('Click to copy ID')}> |
| <button |
| class="text-xs text-gray-500 font-mono px-2 py-1 rounded-lg cursor-pointer hover:underline transition" |
| on:click={() => { |
| copyToClipboard(prompt.id); |
| toast.success($i18n.t('ID copied to clipboard')); |
| }} |
| > |
| {prompt.id} |
| </button> |
| </Tooltip> |
| </div> |
| </div> |
| </div> |
| |
| <div class="mb-2 flex justify-between items-center gap-2"> |
| <div class="flex-1 min-w-0"> |
| <Tags |
| {tags} |
| {disabled} |
| {suggestionTags} |
| on:add={(e) => { |
| tags = [...tags, { name: e.detail }]; |
| debouncedSaveMetadata(); |
| }} |
| on:delete={(e) => { |
| tags = tags.filter((tag) => tag.name !== e.detail); |
| debouncedSaveMetadata(); |
| }} |
| /> |
| </div> |
| </div> |
| |
| <div class="flex flex-col md:flex-row gap-4 flex-1 overflow-hidden pb-6"> |
| |
| <div class="hidden md:flex md:flex-col w-72 shrink-0 overflow-hidden"> |
| <div class="flex-1 overflow-y-auto"> |
| {@render historySection()} |
| </div> |
| </div> |
| |
| |
| <div class="flex-1 flex flex-col min-h-0 overflow-hidden"> |
| <div class="flex items-center justify-between mb-1 shrink-0"> |
| <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 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="relative flex-1 min-h-0"> |
| |
| <div class="absolute top-2 right-2 z-10"> |
| <button |
| class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition" |
| on:click={copyContent} |
| > |
| {#if contentCopied} |
| <Check className="size-4 text-green-500" /> |
| {:else} |
| <Clipboard className="size-4 text-gray-500" /> |
| {/if} |
| </button> |
| </div> |
| |
| <div |
| class="bg-gray-50 dark:bg-gray-900 rounded-xl px-4 py-3 border border-gray-100/50 dark:border-gray-850/50 h-full overflow-y-auto" |
| > |
| <pre class="text-xs whitespace-pre-wrap font-mono pr-8">{selectedHistoryEntry?.snapshot |
| ?.content || content}</pre> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| {:else} |
| |
| <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 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 class="mt-1"> |
| <Tags |
| {tags} |
| {suggestionTags} |
| on:add={(e) => { |
| tags = [...tags, { name: e.detail }]; |
| }} |
| on:delete={(e) => { |
| tags = tags.filter((tag) => tag.name !== e.detail); |
| }} |
| /> |
| </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 class="flex flex-col h-full"> |
| <div class="flex items-center justify-between mb-2 shrink-0"> |
| <div class="text-gray-500 text-xs">{$i18n.t('History')}</div> |
| </div> |
| |
| {#if history.length > 0} |
| <div class="space-y-0 flex-1 overflow-y-auto" on:scroll={handleHistoryScroll}> |
| {#each history as entry, index} |
| <div class="flex"> |
| |
| <button |
| class="flex-1 text-left px-3.5 py-2 mb-1 rounded-2xl transition group |
| {selectedHistoryEntry?.id === entry.id |
| ? 'bg-gray-100/50 dark:bg-gray-850/50' |
| : 'hover:bg-gray-100/50 dark:hover:bg-gray-850/50'}" |
| on:click={() => (selectedHistoryEntry = entry)} |
| > |
| |
| <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> |
| |
| |
| <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 mr-0.5" |
| 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} |
| |
| {#if historyLoading} |
| <div class="flex justify-center py-2"> |
| <Spinner className="size-3" /> |
| </div> |
| {/if} |
| </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} |
| |