Spaces:
Build error
Build error
| <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; | |
| // For debounced auto-save of name/command | |
| 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); // Reset and reload | |
| // Select the newest version after saving | |
| 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; | |
| // Store originals for revert on collision | |
| 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}`); | |
| } | |
| } | |
| }} | |
| /> | |
| <!-- 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)} | |
| > | |
| <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} | |
| <!-- Edit mode: Read-only view with history --> | |
| <div class="flex flex-col w-full h-full max-h-[100dvh]"> | |
| <!-- Header --> | |
| <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"> | |
| <!-- Desktop History Sidebar --> | |
| <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> | |
| <!-- Prompt Content --> | |
| <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> | |
| <!-- Content container with copy button --> | |
| <div class="relative flex-1 min-h-0"> | |
| <!-- Copy button - outside scroll area --> | |
| <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> | |
| <!-- Scrollable content --> | |
| <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} | |
| <!-- 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 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"> | |
| <!-- Content --> | |
| <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)} | |
| > | |
| <!-- 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 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} | |