This commit is contained in:
Timothy Jaeryang Baek 2026-10-09 15:54:44 +04:00
parent 178de36662
commit 37138282fb
11 changed files with 874 additions and 738 deletions

View file

@ -635,6 +635,50 @@ async def get_prompt_history(
return history
@router.get('/id/{prompt_id}/history/diff')
async def get_prompt_diff(
prompt_id: str,
from_id: str,
to_id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
"""Get diff between two versions."""
prompt = await Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Check read access
if not (
user.role == 'admin'
or prompt.user_id == user.id
or await AccessGrants.has_access(
user_id=user.id,
resource_type='prompt',
resource_id=prompt.id,
permission='read',
db=db,
)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
diff = await PromptHistories.compute_diff(from_id, to_id, prompt.id, db=db)
if not diff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
return diff
@router.get('/id/{prompt_id}/history/{history_id}', response_model=PromptHistoryModel)
async def get_prompt_history_entry(
prompt_id: str,
@ -726,47 +770,3 @@ async def delete_prompt_history_entry(
)
return success
@router.get('/id/{prompt_id}/history/diff')
async def get_prompt_diff(
prompt_id: str,
from_id: str,
to_id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
"""Get diff between two versions."""
prompt = await Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Check read access
if not (
user.role == 'admin'
or prompt.user_id == user.id
or await AccessGrants.has_access(
user_id=user.id,
resource_type='prompt',
resource_id=prompt.id,
permission='read',
db=db,
)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
diff = await PromptHistories.compute_diff(from_id, to_id, prompt.id, db=db)
if not diff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
return diff

View file

@ -664,7 +664,8 @@ async def diff_skill_file(
return {
'binary': False,
'diff': ''.join(
difflib.unified_diff(
line if line.endswith('\n') else line + '\n\\ No newline at end of file\n'
for line in difflib.unified_diff(
(files[0] or {}).get('content', '').splitlines(True),
(files[1] or {}).get('content', '').splitlines(True),
fromfile=f'{from_id[:7]}/{path}',

View file

@ -339,7 +339,7 @@
{entry.name}
</span>
{/if}
{#if !writable && !renaming}
{#if !writable && !renaming && variant !== 'workspace'}
<span class="text-[0.625rem] text-gray-400 shrink-0">{$i18n.t('Read-only')}</span>
{/if}
{#if entry.type === 'file' && entry.size !== undefined && !renaming}

View file

@ -41,7 +41,7 @@
if (elementId) {
tooltipContent = document.getElementById(elementId);
} else {
tooltipContent = DOMPurify.sanitize(content);
tooltipContent = allowHTML ? DOMPurify.sanitize(content) : content;
}
// After the element changes, the old instance must be destroyed, otherwise the detached tippy floating DOM will be left behind

View file

@ -258,10 +258,7 @@
};
const createPromptHandler = async (prompt: PromptDraft) => {
const res = await createNewPrompt(localStorage.token, prompt).catch((error) => {
toast.error(`${error}`);
return null;
});
const res = await createNewPrompt(localStorage.token, prompt);
if (res) {
toast.success($i18n.t('Prompt created successfully'));
@ -269,6 +266,7 @@
await getPromptList();
await closeCreateModal();
}
return res;
};
const cloneHandler = async (prompt) => {

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@
import AccessButton from '$lib/components/common/AccessButton.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import Check from '$lib/components/icons/Check.svelte';
import VersionMenuItem from '../common/VersionMenuItem.svelte';
import Download from '$lib/components/icons/Download.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
@ -21,6 +21,7 @@
import { updateSkillAccessGrants } from '$lib/apis/skills';
import { goto, beforeNavigate } from '$app/navigation';
import SkillFiles from './SkillFiles.svelte';
import VersionDiff from '../common/VersionDiff.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import {
getSkillHistory,
@ -55,16 +56,16 @@
let versionId: string | null = null;
let currentVersionId: string | null = null;
let history: any[] = [];
let currentHistoryEntry: any = null;
let historyPage = 1;
let showHistory = false;
let historyLoading = false;
let historyError = false;
let currentVersionName = '';
let commitMessage = '';
let conflict = false;
let baseline = '';
let historyDiff: any = null;
let fileDiff = '';
let comparing = false;
let discard = false;
let discardAction = () => {};
$: historical = edit && versionId !== currentVersionId;
@ -88,6 +89,11 @@
historyError = false;
try {
history = await getSkillHistory(localStorage.token, id, historyPage);
currentHistoryEntry =
history.find((entry) => entry.id === currentVersionId) ||
(currentHistoryEntry?.id === currentVersionId
? currentHistoryEntry
: await getSkillVersion(localStorage.token, id, currentVersionId!));
} catch (error) {
historyError = true;
toast.error(skillError(error));
@ -100,17 +106,23 @@
if (selected !== versionId) confirmDiscard(() => selectVersion(selected));
};
const compareToCurrent = async () => {
if (comparing || historyDiff) return;
const fromId = versionId!;
const toId = currentVersionId!;
comparing = true;
try {
historyDiff = await (
const result = await (
await skillRequest(
localStorage.token,
`/id/${id}/history/diff?${new URLSearchParams({ from_id: versionId!, to_id: currentVersionId! })}`
`/id/${id}/history/diff?${new URLSearchParams({ from_id: fromId, to_id: toId })}`
)
).json();
historyDiff.fromId = versionId;
fileDiff = '';
if (versionId === fromId && currentVersionId === toId)
historyDiff = { ...result, fromId, toId };
} catch (error) {
toast.error(skillError(error));
} finally {
comparing = false;
}
};
const selectVersion = async (selected: string) => {
@ -123,7 +135,6 @@
reloadKey++;
remember();
historyDiff = null;
fileDiff = '';
} catch (error) {
toast.error(skillError(error));
}
@ -132,7 +143,6 @@
try {
const latest = await getSkillById(localStorage.token, id);
currentVersionId = latest.version_id;
currentVersionName = latest.name;
await selectVersion(latest.version_id);
conflict = false;
} catch (error) {
@ -148,10 +158,8 @@
currentVersionId!
);
currentVersionId = restored.version_id;
currentVersionName = restored.name;
versionId = restored.version_id;
historyDiff = null;
fileDiff = '';
remember();
await loadHistory();
toast.success($i18n.t('Saved'));
@ -224,7 +232,6 @@
});
if (result) {
currentVersionId = result.version_id;
currentVersionName = result.name;
versionId = result.version_id;
commitMessage = '';
conflict = false;
@ -249,7 +256,6 @@
description = skill.description || '';
content = skill.content || '';
currentVersionId = skill.version_id || null;
currentVersionName = skill.name;
versionId = currentVersionId;
accessGrants = skill?.access_grants === undefined ? [] : skill?.access_grants;
}
@ -373,57 +379,6 @@
</div>
</div>
{#if historical}
<div class="flex w-full flex-wrap items-center gap-3 px-1 py-2 text-xs text-gray-500">
<span>{$i18n.t('Read Only')}</span>
<button
type="button"
class="hover:text-gray-900 dark:hover:text-gray-100"
on:click={compareToCurrent}>{$i18n.t('Compare to current')}</button
>
{#if !disabled}<button
type="button"
class="hover:text-gray-900 dark:hover:text-gray-100"
on:click={restore}>{$i18n.t('Restore as new version')}</button
>{/if}
</div>
{/if}
{#if historyDiff}
<div
class="max-h-56 shrink-0 overflow-auto rounded-lg border border-gray-100 p-2 text-xs dark:border-gray-800"
>
<button
type="button"
class="mb-2 text-gray-500"
on:click={() => {
historyDiff = null;
fileDiff = '';
}}>{$i18n.t('Close')}</button
>
{#each Object.entries(historyDiff.metadata) as [key, values]}<p>
{key}: {JSON.stringify(values)}
</p>{/each}
{#each historyDiff.files as file}<button
type="button"
class="block py-1"
on:click={async () => {
try {
const result = await (
await skillRequest(
localStorage.token,
`/id/${id}/history/diff/file?${new URLSearchParams({ from_id: historyDiff.fromId, to_id: currentVersionId!, path: file.path })}`
)
).json();
fileDiff = result.binary ? 'Binary file changed' : result.diff;
} catch (error) {
toast.error(skillError(error));
}
}}>{file.status}: {file.path}</button
>{/each}
<pre class="whitespace-pre-wrap">{fileDiff}</pre>
</div>
{/if}
{#if conflict}<div
role="alert"
class="flex flex-wrap gap-3 bg-amber-50 p-3 text-xs text-amber-900 dark:bg-amber-950 dark:text-amber-100"
@ -439,7 +394,24 @@
<div
class="min-h-0 flex-1 overflow-hidden rounded-2xl border border-gray-100 bg-gray-50/60 dark:border-white/5 dark:bg-white/[0.03]"
>
{#if ready}<SkillFiles
{#if historyDiff}
<VersionDiff
diff={historyDiff}
onClose={() => (historyDiff = null)}
loadFileDiff={async (path) => {
try {
return await (
await skillRequest(
localStorage.token,
`/id/${id}/history/diff/file?${new URLSearchParams({ from_id: historyDiff.fromId, to_id: historyDiff.toId, path })}`
)
).json();
} catch (error) {
throw new Error(skillError(error));
}
}}
/>
{:else if ready}<SkillFiles
bind:this={fileEditor}
skillId={id}
{versionId}
@ -477,21 +449,17 @@
<ChevronDown className="size-3 shrink-0" />
</button>
<div slot="content">
<DropdownMenu className="w-56 max-w-[calc(100vw-2rem)]">
<button
type="button"
role="menuitemradio"
aria-checked={versionId === currentVersionId}
on:click={() => chooseVersion(currentVersionId!)}
title={currentVersionId || ''}
>
<span class="min-w-0 flex-1 truncate text-left"
>{currentVersionName} · {$i18n.t('Current')}</span
>
{#if versionId === currentVersionId}<Check
className="size-3.5 text-blue-500"
/>{/if}
</button>
<DropdownMenu
className="w-56 max-w-[calc(100vw-2rem)] max-h-80 overflow-y-auto"
>
<VersionMenuItem
entry={currentHistoryEntry?.id === currentVersionId
? currentHistoryEntry
: null}
status={$i18n.t('Current')}
selected={versionId === currentVersionId}
onSelect={() => chooseVersion(currentVersionId!)}
/>
{#if historyLoading || historyError || history.some((entry) => entry.id !== currentVersionId)}
<hr class="border-gray-100 dark:border-gray-800" />
{/if}
@ -503,20 +471,11 @@
<button type="button" on:click={loadHistory}>{$i18n.t('Retry')}</button>
{:else}
{#each history.filter((entry) => entry.id !== currentVersionId) as entry}
<button
type="button"
role="menuitemradio"
aria-checked={entry.id === versionId}
title={`${entry.id} · ${new Date(entry.created_at * 1000).toLocaleString()} · ${entry.user?.name || entry.user_id}`}
on:click={() => chooseVersion(entry.id)}
>
<span class="min-w-0 flex-1 truncate text-left"
>{entry.commit_message || entry.id.slice(0, 7)}</span
>
{#if entry.id === versionId}<Check
className="size-3.5 text-blue-500"
/>{/if}
</button>
<VersionMenuItem
{entry}
selected={entry.id === versionId}
onSelect={() => chooseVersion(entry.id)}
/>
{:else}
{#if historyPage > 1}
<div class="px-2 py-2 text-xs text-gray-500">
@ -565,6 +524,30 @@
</SkillFiles>{/if}
</div>
{#if historical}
<div
class="flex w-full shrink-0 flex-wrap items-center gap-2 px-1 py-2 text-xs text-gray-500"
>
<span class="mr-auto">{$i18n.t('Read Only')}</span>
<div class="ml-auto flex flex-wrap justify-end gap-2">
<button
type="button"
class="flex h-7 items-center rounded-lg bg-gray-100 px-2.5 text-xs text-gray-700 disabled:opacity-60 dark:bg-gray-850 dark:text-gray-200"
disabled={comparing || !!historyDiff}
on:click={compareToCurrent}
>{#if comparing}<Spinner className="mr-1.5 size-3" />{/if}{$i18n.t(
'Compare to current'
)}</button
>
{#if !disabled}<button
type="button"
class="flex h-7 items-center rounded-lg bg-gray-900 px-2.5 text-xs text-white transition hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
on:click={restore}>{$i18n.t('Restore as new version')}</button
>{/if}
</div>
</div>
{/if}
{#if !readOnly}
<div class="flex shrink-0 justify-end gap-3 py-2">
<input

View file

@ -17,6 +17,8 @@
import Folder from '$lib/components/icons/Folder.svelte';
import ArrowUpTray from '$lib/components/icons/ArrowUpTray.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import LockClosed from '$lib/components/icons/LockClosed.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import FileCodeEditor from '$lib/components/chat/FileNav/FileCodeEditor.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import {
@ -651,6 +653,15 @@
<div class="h-full min-h-0 w-full overflow-auto p-1.5">
<div class="mb-1 flex w-full items-center gap-1">
<div class="min-w-0 flex-1">{@render version()}</div>
{#if readOnly}
<Tooltip content={$i18n.t('Read Only')}>
<span
class="flex items-center px-0.5 text-gray-400"
role="img"
aria-label={$i18n.t('Read Only')}><LockClosed className="size-3" /></span
>
</Tooltip>
{/if}
{#if !readOnly || canExport}
<Dropdown closeOnSelect align="end">
<button

View file

@ -0,0 +1,184 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
import DiffBlock from '$lib/components/chat/Messages/DiffBlock.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let currentLabel = 'Current';
export let loadFileDiff: (path: string) => Promise<{ binary?: boolean; diff?: string }>;
export let diff: {
fromId: string;
toId: string;
metadata: Record<string, { before: unknown; after: unknown }>;
files: { path: string; status: 'added' | 'deleted' | 'modified'; binary: boolean }[];
};
export let onClose: () => void;
const i18n = getContext<any>('i18n');
let selectedPath: string | null = null;
let results: Record<
string,
{ loading?: boolean; error?: string; binary?: boolean; diff?: string }
> = {};
const isObject = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
const displayValue = (value: unknown): string => {
if (value == null) return '';
if (Array.isArray(value)) return value.map(displayValue).join(', ');
if (isObject(value))
return Object.entries(value)
.map(([key, item]) => `${key}: ${displayValue(item)}`)
.join(', ');
return String(value);
};
function changes(
path: string[],
before: unknown,
after: unknown
): { path: string[]; before: string; after: string }[] {
if (
(isObject(before) || isObject(after)) &&
(before == null || isObject(before)) &&
(after == null || isObject(after))
) {
const a = isObject(before) ? before : {};
const b = isObject(after) ? after : {};
return [...new Set([...Object.keys(a), ...Object.keys(b)])].flatMap((key) =>
changes([...path, key], a[key], b[key])
);
}
const a = displayValue(before);
const b = displayValue(after);
return a === b ? [] : [{ path, before: a, after: b }];
}
$: metadata = Object.entries(diff.metadata).flatMap(([key, value]) =>
changes(key === 'meta' ? [] : [key], value.before, value.after)
);
const labels: Record<string, string> = {
name: 'Name',
description: 'Description',
tags: 'Tags',
i18n: 'Translations'
};
const statuses = { added: 'Added', deleted: 'Deleted', modified: 'Modified' };
async function loadFile(path: string) {
if (results[path]?.loading || results[path]?.diff !== undefined || results[path]?.binary)
return;
results = { ...results, [path]: { loading: true } };
try {
results = { ...results, [path]: await loadFileDiff(path) };
} catch (error) {
results = {
...results,
[path]: { error: error instanceof Error ? error.message : String(error) }
};
}
}
function selectFile(path: string) {
selectedPath = selectedPath === path ? null : path;
if (selectedPath) loadFile(path);
}
onMount(() => {
if (diff.files.length) selectFile(diff.files[0].path);
});
</script>
<section
class="flex h-full min-h-0 flex-col bg-white text-xs dark:bg-gray-900"
aria-label={$i18n.t('Compare to current')}
>
<div class="flex shrink-0 items-center gap-2 bg-gray-50/60 px-3 py-1.5 dark:bg-black">
<span class="min-w-0 flex-1 font-medium">{$i18n.t('Compare to current')}</span>
<button
type="button"
class="p-1 text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
aria-label={$i18n.t('Close')}
on:click={onClose}><XMark className="size-3.5" /></button
>
</div>
<div class="grid shrink-0 grid-cols-2 border-b border-gray-100 dark:border-white/5">
<div class="min-w-0 px-3 py-2 text-gray-500">
<span class="text-red-600 dark:text-red-400">−</span>
{$i18n.t('Selected version')} <span class="font-mono">{diff.fromId.slice(0, 7)}</span>
</div>
<div class="min-w-0 px-3 py-2 text-gray-500">
<span class="text-green-600 dark:text-green-400">+</span>
{$i18n.t(currentLabel)} <span class="font-mono">{diff.toId.slice(0, 7)}</span>
</div>
</div>
<div class="min-h-0 flex-1 overflow-auto">
{#each metadata as change}
<div class="border-b border-gray-100 dark:border-white/5">
<div class="px-3 py-1.5 font-medium text-gray-600 dark:text-gray-300">
{change.path.map((key) => $i18n.t(labels[key] || key)).join(' / ')}
</div>
<div class="grid grid-cols-2">
<div
class="min-w-0 whitespace-pre-wrap break-words bg-red-50 px-3 py-2 text-red-800 dark:bg-red-950/30 dark:text-red-200"
>
{change.before || '—'}
</div>
<div
class="min-w-0 whitespace-pre-wrap break-words bg-green-50 px-3 py-2 text-green-800 dark:bg-green-950/30 dark:text-green-200"
>
{change.after || '—'}
</div>
</div>
</div>
{/each}
{#each diff.files as file, index}
<div class="border-b border-gray-100 dark:border-white/5">
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left"
aria-expanded={selectedPath === file.path}
aria-controls={`version-diff-file-${index}`}
on:click={() => selectFile(file.path)}
>
<span class:rotate-[-90deg]={selectedPath !== file.path}
><ChevronDown className="size-3 shrink-0 text-gray-400" /></span
>
<span class="min-w-0 flex-1 truncate font-mono" title={file.path}>{file.path}</span>
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[0.625rem] {file.status === 'added'
? 'bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-400'
: file.status === 'deleted'
? 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400'
: 'bg-gray-100 text-gray-500 dark:bg-gray-850 dark:text-gray-400'}"
>{$i18n.t(statuses[file.status])}</span
>
</button>
{#if selectedPath === file.path}
<div id={`version-diff-file-${index}`} class="min-w-0 [&_.diff-block]:text-xs">
{#if results[file.path]?.loading}
<div class="flex items-center gap-2 px-3 py-3 text-gray-500" role="status">
<Spinner className="size-3" />{$i18n.t('Loading...')}
</div>
{:else if results[file.path]?.error}
<div class="px-3 py-3 text-gray-500" role="alert">
{results[file.path].error}<button
type="button"
class="ml-2 underline"
on:click={() => loadFile(file.path)}>{$i18n.t('Retry')}</button
>
</div>
{:else if results[file.path]?.binary}
<p class="px-3 py-3 text-gray-500">
{$i18n.t('Binary file — text comparison is unavailable.')}
</p>
{:else if results[file.path]?.diff}
<DiffBlock code={results[file.path].diff} />
{:else}
<p class="px-3 py-3 text-gray-500">{$i18n.t('No text differences')}</p>
{/if}
</div>
{/if}
</div>
{/each}
{#if !metadata.length && !diff.files.length}
<p class="px-3 py-4 text-gray-500">{$i18n.t('No differences')}</p>
{/if}
</div>
</section>

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { getContext } from 'svelte';
import { user } from '$lib/stores';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
import Check from '$lib/components/icons/Check.svelte';
export let entry: {
id: string;
commit_message?: string | null;
created_at?: number;
user_id?: string;
user?: { id?: string; name?: string } | null;
} | null = null;
export let status = '';
export let selected = false;
export let onSelect: () => void;
const i18n = getContext<any>('i18n');
$: authorId = entry?.user?.id || entry?.user_id;
$: message = entry?.commit_message || entry?.id.slice(0, 7) || status;
$: author =
entry?.user?.name ||
($user?.id === authorId ? $user.name : authorId ? $i18n.t('Unknown user') : '');
$: date = entry?.created_at ? new Date(entry.created_at * 1000) : null;
</script>
<button
type="button"
role="menuitemradio"
aria-label={status || message}
aria-checked={selected}
class="text-left"
title={entry
? `${entry.id}${date ? ` · ${date.toLocaleString($i18n.language)}` : ''}`
: undefined}
on:click={onSelect}
>
<Tooltip as="span" className="flex shrink-0" content={author} allowHTML={false}>
<ProfileImage
src={authorId ? `/api/v1/users/${authorId}/profile/image` : '/user.png'}
className="size-4 shrink-0 rounded-full!"
/>
</Tooltip>
<Tooltip as="span" className="block min-w-0 flex-1" content={message} allowHTML={false}>
<span class="block truncate text-xs">{message}</span>
</Tooltip>
{#if status && entry}<span class="shrink-0 text-[0.625rem] text-gray-400">{status}</span>{/if}
{#if selected}<Check className="size-3.5 shrink-0 text-blue-500" />{/if}
</button>

View file

@ -17,12 +17,7 @@
$: promptId = $page.params.id;
const onSubmit = async (_prompt) => {
console.log(_prompt);
const updatedPrompt = await updatePromptById(localStorage.token, _prompt).catch((error) => {
toast.error(`${error}`);
return null;
});
const updatedPrompt = await updatePromptById(localStorage.token, _prompt);
if (updatedPrompt) {
toast.success($i18n.t('Prompt updated successfully'));
// Update local prompt state to reflect the new version
@ -37,6 +32,7 @@
updatedPrompt?.access_grants === undefined ? [] : updatedPrompt?.access_grants
};
}
return updatedPrompt;
};
onMount(async () => {