Skip to content

Commit dab40ed

Browse files
authored
branching wip (#98)
1 parent 7419732 commit dab40ed

File tree

4 files changed

+167
-9
lines changed

4 files changed

+167
-9
lines changed

src/lib/components/inference-playground/message.svelte

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
import { parseThinkingTokens } from "$lib/utils/thinking.js";
2323
import IconChevronDown from "~icons/carbon/chevron-down";
2424
import IconChevronRight from "~icons/carbon/chevron-right";
25+
import ArrowSplitRounded from "~icons/material-symbols/arrow-split-rounded";
26+
import { addToast } from "$lib/components/toaster.svelte.js";
27+
import { projects } from "$lib/state/projects.svelte";
2528
2629
type Props = {
2730
conversation: ConversationClass;
@@ -341,6 +344,34 @@
341344
</div>
342345
</Tooltip>
343346

347+
<Tooltip>
348+
{#snippet trigger(tooltip)}
349+
<button
350+
tabindex="0"
351+
onclick={async () => {
352+
try {
353+
await projects.branch(projects.activeId, index);
354+
} catch (error) {
355+
addToast({
356+
title: "Error",
357+
description: error instanceof Error ? error.message : "Failed to create branch",
358+
variant: "error",
359+
});
360+
}
361+
}}
362+
type="button"
363+
class="grid size-7 place-items-center border border-gray-200 bg-white text-xs font-medium text-gray-900 hover:bg-gray-100
364+
hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100
365+
focus:outline-hidden dark:border-gray-600 dark:bg-gray-800
366+
dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
367+
{...tooltip.trigger}
368+
>
369+
<ArrowSplitRounded />
370+
</button>
371+
{/snippet}
372+
Branch from here
373+
</Tooltip>
374+
344375
<Tooltip>
345376
{#snippet trigger(tooltip)}
346377
<button
@@ -399,3 +430,23 @@
399430
{/each}
400431
</div>
401432
</div>
433+
434+
{#if projects.current?.branchedFromId && projects.current?.branchedFromMessageIndex === index}
435+
<div class="mt-4 flex items-center justify-center">
436+
<div
437+
class="flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1.5 text-sm text-gray-600 dark:bg-gray-800 dark:text-gray-400"
438+
>
439+
<ArrowSplitRounded class="mr-1 size-4" />
440+
<span>Branched from</span>
441+
<button
442+
onclick={() => {
443+
if (!projects.current?.branchedFromId) return;
444+
projects.activeId = projects.current.branchedFromId;
445+
}}
446+
class="font-medium text-blue-600 underline hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
447+
>
448+
{projects.getBranchedFromProject(projects.current.id)?.name || "original project"}
449+
</button>
450+
</div>
451+
</div>
452+
{/if}

src/lib/components/inference-playground/project-select.svelte

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import IconHistory from "~icons/carbon/recently-viewed";
1111
import IconSave from "~icons/carbon/save";
1212
import IconDelete from "~icons/carbon/trash-can";
13+
import ArrowSplitRounded from "~icons/material-symbols/arrow-split-rounded";
1314
import Dialog from "../dialog.svelte";
1415
import { prompt } from "../prompts.svelte";
1516
import Tooltip from "../tooltip.svelte";
@@ -114,7 +115,21 @@
114115
>
115116
<div class="flex items-center gap-2">
116117
{name}
117-
{#if hasCheckpoints}
118+
{#if projects.all.find(p => p.id === id)?.branchedFromId}
119+
{@const originalProject = projects.getBranchedFromProject(id)}
120+
<Tooltip>
121+
{#snippet trigger(tooltip)}
122+
<div
123+
class="text-3xs grid aspect-square place-items-center rounded bg-blue-300 p-0.5 text-blue-700 dark:bg-blue-400/25 dark:text-blue-400"
124+
aria-label="Branched project"
125+
{...tooltip.trigger}
126+
>
127+
<ArrowSplitRounded />
128+
</div>
129+
{/snippet}
130+
Branched from {originalProject?.name || "unknown project"}
131+
</Tooltip>
132+
{:else if hasCheckpoints}
118133
<div
119134
class="text-3xs grid aspect-square place-items-center rounded bg-yellow-300 p-0.5 text-yellow-700 dark:bg-yellow-400/25 dark:text-yellow-400"
120135
aria-label="Project has checkpoints"

src/lib/state/conversations.svelte.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,24 @@ export class ConversationClass {
157157
});
158158
};
159159

160+
checkAndClearBranchStatus = async (deletionIndex: number) => {
161+
const currentProject = projects.current;
162+
163+
if (!currentProject?.branchedFromId || typeof currentProject?.branchedFromMessageIndex !== "number") return;
164+
165+
// If we're deleting messages at or before the branch point, clear branch status
166+
if (deletionIndex <= currentProject.branchedFromMessageIndex) {
167+
await projects.clearBranchStatus(currentProject.id);
168+
}
169+
};
170+
160171
deleteMessage = async (idx: number) => {
161172
if (!this.data.messages) return;
162173
const imgKeys = this.data.messages.flatMap(m => m.images).filter(isString);
174+
175+
// Check if we need to clear branch status
176+
await this.checkAndClearBranchStatus(idx);
177+
163178
await Promise.all([
164179
...imgKeys.map(k => images.delete(k)),
165180
this.update({
@@ -174,6 +189,9 @@ export class ConversationClass {
174189
const sliced = this.data.messages.slice(0, from);
175190
const notSliced = this.data.messages.slice(from);
176191

192+
// Check if we need to clear branch status
193+
await this.checkAndClearBranchStatus(from);
194+
177195
const imgKeys = notSliced.flatMap(m => m.images).filter(isString);
178196
await Promise.all([
179197
...imgKeys.map(k => images.delete(k)),
@@ -380,6 +398,42 @@ class Conversations {
380398
);
381399
};
382400

401+
duplicateUpToMessage = async (from: ProjectEntity["id"], to: ProjectEntity["id"], messageIndex: number) => {
402+
const fromArr = this.#conversations[from] ?? [];
403+
404+
// Clear any existing conversations for the target project first
405+
this.#conversations[to] = [];
406+
407+
// Delete any existing conversations in the database for this project
408+
const existingConversations = await conversationsRepo.find({ where: { projectId: to } });
409+
await Promise.all(existingConversations.map(c => conversationsRepo.delete(c.id)));
410+
411+
const newConversations: ConversationClass[] = [];
412+
413+
for (const c of fromArr) {
414+
// Copy only messages up to the specified index with deep clone
415+
const truncatedMessages =
416+
c.data.messages?.slice(0, messageIndex + 1).map(msg => ({
417+
...msg,
418+
images: msg.images ? [...msg.images] : undefined,
419+
})) || [];
420+
421+
const conversationData = {
422+
...snapshot(c.data),
423+
projectId: to,
424+
messages: truncatedMessages,
425+
id: undefined, // Let the database generate a new ID
426+
};
427+
428+
// Use conversationsRepo directly to avoid default conversation merging
429+
const saved = await conversationsRepo.save(conversationData);
430+
newConversations.push(new ConversationClass(saved));
431+
}
432+
433+
// Update the in-memory cache
434+
this.#conversations[to] = newConversations;
435+
};
436+
383437
genNextMessages = async (conv: "left" | "right" | "both" | ConversationClass = "both") => {
384438
if (!token.value) {
385439
token.showModal = true;

src/lib/state/projects.svelte.ts

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ export class ProjectEntity {
1515

1616
@Fields.string()
1717
systemMessage?: string;
18+
19+
@Fields.string()
20+
branchedFromId?: string | null;
21+
22+
@Fields.number()
23+
branchedFromMessageIndex?: number | null;
1824
}
1925

2026
export type ProjectEntityMembers = MembersOnly<ProjectEntity>;
@@ -72,14 +78,6 @@ class Projects {
7278
return id;
7379
};
7480

75-
setCurrent = async (id: string) => {
76-
await checkpoints.migrate(id, this.activeId);
77-
conversations.migrate(this.activeId, id).then(() => {
78-
this.#activeId.current = id;
79-
});
80-
this.activeId = id;
81-
};
82-
8381
get current() {
8482
return this.#projects[this.activeId];
8583
}
@@ -105,6 +103,46 @@ class Projects {
105103
this.activeId = DEFAULT_PROJECT_ID;
106104
}
107105
}
106+
107+
branch = async (fromProjectId: string, messageIndex: number): Promise<string> => {
108+
const fromProject = this.#projects[fromProjectId];
109+
if (!fromProject) throw new Error("Source project not found");
110+
111+
// Create new project with branching info
112+
const newProjectId = await this.create({
113+
name: `${fromProject.name} (branch)`,
114+
systemMessage: fromProject.systemMessage,
115+
branchedFromId: fromProjectId,
116+
branchedFromMessageIndex: messageIndex,
117+
});
118+
119+
// Copy conversations up to the specified message index
120+
await conversations.duplicateUpToMessage(fromProjectId, newProjectId, messageIndex);
121+
122+
// Switch to the new project
123+
this.activeId = newProjectId;
124+
125+
return newProjectId;
126+
};
127+
128+
getBranchedFromProject = (projectId: string) => {
129+
const project = this.#projects[projectId];
130+
if (!project?.branchedFromId) return null;
131+
132+
const originalProject = this.#projects[project.branchedFromId];
133+
return originalProject;
134+
};
135+
136+
clearBranchStatus = async (projectId: string) => {
137+
const project = this.#projects[projectId];
138+
if (!project?.branchedFromId) return;
139+
140+
await this.update({
141+
...project,
142+
branchedFromId: null,
143+
branchedFromMessageIndex: null,
144+
});
145+
};
108146
}
109147

110148
export const projects = new Projects();

0 commit comments

Comments
 (0)