File size: 6,096 Bytes
7450ebd
fbef5e8
7450ebd
1778c9e
f36471e
1778c9e
7450ebd
 
 
f36471e
7450ebd
 
 
f36471e
893b0be
7450ebd
 
 
 
 
f36471e
 
 
 
7450ebd
f36471e
7450ebd
1778c9e
7450ebd
 
893b0be
7450ebd
 
 
 
 
 
f36471e
 
 
fbef5e8
7450ebd
893b0be
7450ebd
f36471e
 
 
 
7450ebd
 
 
 
 
1778c9e
7450ebd
 
 
 
 
 
1778c9e
 
7450ebd
 
 
 
 
 
 
 
 
 
 
 
 
 
1778c9e
7450ebd
893b0be
7450ebd
 
1778c9e
 
 
 
 
7450ebd
1778c9e
7450ebd
 
 
 
 
1778c9e
28faefd
f9963a5
7450ebd
 
 
 
 
 
 
 
 
 
 
 
1778c9e
7450ebd
 
 
 
 
 
 
 
 
 
 
 
1778c9e
7450ebd
 
 
 
 
 
 
 
28faefd
 
 
7450ebd
 
 
f36471e
 
 
 
1778c9e
f9963a5
7450ebd
 
 
 
 
 
 
 
 
 
d7cd63b
 
 
 
 
 
7450ebd
f36471e
7450ebd
 
28faefd
 
 
 
 
7450ebd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f36471e
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
<script lang="ts">
	import { clickOutside } from "$lib/attachments/click-outside.js";
	import { checkpoints } from "$lib/state/checkpoints.svelte";
	import { projects } from "$lib/state/projects.svelte";
	import { iterate } from "$lib/utils/array.js";
	import { formatDateTime } from "$lib/utils/date.js";
	import { Popover } from "melt/builders";
	import { Tooltip } from "melt/components";
	import { fly } from "svelte/transition";
	import IconCompare from "~icons/carbon/compare";
	import IconHistory from "~icons/carbon/recently-viewed";
	import IconStar from "~icons/carbon/star";
	import IconStarFilled from "~icons/carbon/star-filled";
	import IconDelete from "~icons/carbon/trash-can";
	import { TEST_IDS } from "$lib/constants.js";

	const popover = new Popover({
		floatingConfig: {
			offset: { crossAxis: -12 },
		},
		onOpenChange: open => {
			if (open) dialog?.showModal();
			else dialog?.close();
		},
	});
	let dialog = $state<HTMLDialogElement>();

	const projCheckpoints = $derived(checkpoints.for(projects.activeId));
</script>

<button class="btn relative size-[32px] p-0" {...popover.trigger} data-test-id={TEST_IDS.checkpoints_trigger}>
	<IconHistory />
	{#if projCheckpoints.length > 0}
		<div class="absolute -top-1 -right-1 size-2.5 rounded-full bg-amber-500" aria-label="Project has checkpoints"></div>
	{/if}
</button>

<dialog
	bind:this={dialog}
	class="mb-2 !overflow-visible rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
	{@attach clickOutside(() => (popover.open = false))}
	{...popover.content}
	data-test-id={TEST_IDS.checkpoints_menu}
>
	<div
		class="size-4 translate-x-3 rounded-tl border-t border-l border-gray-200 dark:border-gray-700"
		{...popover.arrow}
	></div>
	<div class="max-h-120 w-80 overflow-x-clip overflow-y-auto p-3 pb-1">
		<div class="mb-2 flex items-center justify-between px-1">
			<h3 class="text-sm font-medium dark:text-white">Checkpoints</h3>
			<button
				class="rounded-lg bg-blue-600 px-2 py-1 text-xs font-medium text-white transition-colors hover:bg-blue-700"
				onclick={() => checkpoints.commit(projects.activeId)}
			>
				Create new
			</button>
		</div>

		{#each projCheckpoints as checkpoint (checkpoint.id)}
			{@const conversations = checkpoint.conversations}
			{@const multiple = conversations.length > 1}
			<Tooltip
				openDelay={0}
				floatingConfig={{
					computePosition: {
						placement: "right",
					},
					offset: {
						mainAxis: 16,
					},
				}}
				forceVisible
			>
				{#snippet children(tooltip)}
					<div
						class="mb-2 flex w-full items-center rounded-md px-3 hover:bg-gray-100 dark:hover:bg-gray-700"
						{...tooltip.trigger}
						data-test-id={TEST_IDS.checkpoint}
					>
						<button
							class="flex flex-1 flex-col py-2 text-left text-sm transition-colors"
							onclick={e => {
								e.stopPropagation();
								checkpoints.restore(checkpoint);
							}}
						>
							<span class="font-medium text-gray-400">{formatDateTime(checkpoint.timestamp)}</span>

							<p class="mt-0.5 flex items-center gap-2 text-sm">
								{#if multiple}
									<IconCompare class="text-xs text-gray-400" />
								{/if}
								{#each conversations as { messages }, i}
									<span class={["text-gray-800 dark:text-gray-200"]}>
										{messages?.length || 0} message{(messages?.length || 0) === 1 ? "" : "s"}
									</span>
									{#if multiple && i === 0}
										<span class="text-gray-500">|</span>
									{/if}
								{/each}
							</p>
						</button>

						<button
							class="mr-0.5 grid place-items-center rounded-md p-1 text-xs hover:bg-gray-300 dark:hover:bg-gray-600"
							onclick={e => {
								e.stopPropagation();
								checkpoints.toggleFavorite(checkpoint);
							}}
						>
							{#if checkpoint.favorite}
								<IconStarFilled class="text-yellow-500" />
							{:else}
								<IconStar />
							{/if}
						</button>
						<button
							class="grid place-items-center rounded-md p-1 text-xs hover:bg-gray-300 dark:hover:bg-gray-600"
							onclick={e => {
								e.stopPropagation();
								checkpoints.delete(checkpoint);
							}}
						>
							<IconDelete />
						</button>
					</div>

					{#if tooltip.open}
						<div
							class={[
								"flex rounded-xl border border-gray-100 bg-gray-50 p-2 shadow dark:border-gray-700 dark:bg-gray-800",
							]}
							{...tooltip.content}
							transition:fly={{ x: -2 }}
						>
							<div
								class="size-4 rounded-tl border-t border-l border-gray-200 dark:border-gray-700"
								{...tooltip.arrow}
							></div>
							{#each conversations as conversation, i}
								{@const msgs = conversation.messages || []}
								{@const sliced = msgs.slice(0, 4)}
								<div
									class={[
										"p-2",
										multiple ? "w-52" : "w-72",
										i === 0 && multiple && "border-r border-gray-200 dark:border-gray-700",
									]}
								>
									<p class="text-2xs pl-1.5 font-mono font-medium text-gray-500 uppercase">
										temp: {conversation.config.temperature}
										{#if conversation.config.max_tokens}
											| max tokens: {conversation.config.max_tokens}
										{/if}
										{#if conversation.structuredOutput?.enabled}
											| structured output
										{/if}
									</p>
									{#each iterate(sliced) as [msg, isLast]}
										<div class="flex flex-col gap-1 p-2">
											<p class="font-mono text-xs font-medium text-gray-400 uppercase">{msg.role}</p>
											{#if msg.content?.trim()}
												<p class="line-clamp-2 text-sm">{msg.content.trim()}</p>
											{:else}
												<p class="text-sm text-gray-500 italic">No content</p>
											{/if}
										</div>
										{#if !isLast}
											<div class="my-2 h-px w-full bg-gray-200 dark:bg-gray-700"></div>
										{/if}
									{/each}
								</div>
							{/each}
						</div>
					{/if}
				{/snippet}
			</Tooltip>
		{:else}
			<div class="flex flex-col items-center gap-2 py-3">
				<span class="text-gray-500 text-sm">No checkpoints available</span>
			</div>
		{/each}
	</div>
</dialog>