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