feat(conversations,api,ui): rewind takes the last turns of a thread back, the cut session stays in the store
This commit is contained in:
1 parent
b0e5aaf599
commit
2a47c6771d
14 files changed
+583
-9
No files matched your search
@@ -16,6 +16,7 @@ import type {
|
||||
SearchResponse,
|
||||
SessionsResponse,
|
||||
TokenRow,
|
||||
TurnSummary,
|
||||
UsageGroup,
|
||||
UsageResponse,
|
||||
VaultGraph,
|
||||
@@ -187,6 +188,14 @@ export class ApiClient {
|
||||
return this.get(`/api/conversations/${id}/history`);
|
||||
}
|
||||
|
||||
turns(id: string, limit: number): Promise<{ turns: TurnSummary[] }> {
|
||||
return this.get(`/api/conversations/${id}/turns`, { limit });
|
||||
}
|
||||
|
||||
rewind(id: string, turn: string): Promise<{ turns: number }> {
|
||||
return this.post(`/api/conversations/${id}/rewind`, { turn });
|
||||
}
|
||||
|
||||
context(id: string, prompt = false): Promise<ContextResponse> {
|
||||
return this.get(`/api/conversations/${id}/context`, {
|
||||
prompt: prompt ? 1 : undefined,
|
||||
|
||||
@@ -113,6 +113,14 @@ export interface HistoryMessage {
|
||||
ts?: string | null;
|
||||
}
|
||||
|
||||
export interface TurnSummary {
|
||||
failed: boolean;
|
||||
id: string;
|
||||
reply: string;
|
||||
text: string;
|
||||
ts: string | null;
|
||||
}
|
||||
|
||||
export interface EntriesPage {
|
||||
entries: Record<string, unknown>[];
|
||||
id: string;
|
||||
|
||||
@@ -109,6 +109,13 @@ export function fmtTime(iso: string | null | undefined): string {
|
||||
: "–";
|
||||
}
|
||||
|
||||
export function fmtMinute(iso: string | null | undefined): string {
|
||||
const date = parseDate(iso);
|
||||
return date
|
||||
? date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
|
||||
: "–";
|
||||
}
|
||||
|
||||
export function fmtDateTime(iso: string | null | undefined): string {
|
||||
const date = parseDate(iso);
|
||||
return date
|
||||
|
||||
@@ -9,6 +9,7 @@ import FileTextIcon from "@lucide/svelte/icons/file-text";
|
||||
import GitBranchIcon from "@lucide/svelte/icons/git-branch";
|
||||
import GitMergeIcon from "@lucide/svelte/icons/git-merge";
|
||||
import PencilIcon from "@lucide/svelte/icons/pencil";
|
||||
import RewindIcon from "@lucide/svelte/icons/rewind";
|
||||
import RotateCcwIcon from "@lucide/svelte/icons/rotate-ccw";
|
||||
import SendIcon from "@lucide/svelte/icons/send";
|
||||
import type { Component } from "svelte";
|
||||
@@ -38,6 +39,7 @@ export interface ActionScope {
|
||||
onChanged: () => void;
|
||||
onOpen: (id: string) => void;
|
||||
onRename: () => void;
|
||||
onRewind: () => void;
|
||||
}
|
||||
|
||||
const TELEGRAM = "telegram";
|
||||
@@ -103,6 +105,12 @@ export function conversationActions(
|
||||
label: "Branch…",
|
||||
run: scope.onBranch,
|
||||
});
|
||||
actions.push({
|
||||
disabled: !info.session_id,
|
||||
icon: RewindIcon,
|
||||
label: "Rewind…",
|
||||
run: scope.onRewind,
|
||||
});
|
||||
actions.push({
|
||||
checked: remembers,
|
||||
gap: true,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { usePanelHost } from "./host";
|
||||
import MenuItems, { type MenuKit } from "./menu-items.svelte";
|
||||
import RenameDialog from "./rename-dialog.svelte";
|
||||
import RewindDialog from "./rewind-dialog.svelte";
|
||||
|
||||
let {
|
||||
client,
|
||||
@@ -47,6 +48,7 @@
|
||||
let failure = $state<string | null>(null);
|
||||
let branchOpen = $state(false);
|
||||
let renameOpen = $state(false);
|
||||
let rewindOpen = $state(false);
|
||||
|
||||
const loaded = $derived(info ?? fetched);
|
||||
|
||||
@@ -77,6 +79,9 @@
|
||||
onRename: () => {
|
||||
renameOpen = true;
|
||||
},
|
||||
onRewind: () => {
|
||||
rewindOpen = true;
|
||||
},
|
||||
});
|
||||
const actions = $derived(loaded ? conversationActions(loaded, scope) : []);
|
||||
</script>
|
||||
@@ -141,4 +146,5 @@
|
||||
bind:open={branchOpen}
|
||||
/>
|
||||
<RenameDialog {client} info={loaded} {onChanged} bind:open={renameOpen} />
|
||||
<RewindDialog {client} info={loaded} {onChanged} bind:open={rewindOpen} />
|
||||
{/if}
|
||||
@@ -0,0 +1,210 @@
|
||||
<script lang="ts">
|
||||
import { tick, untrack } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import type { ConversationSummary, TurnSummary } from "$lib/api/types";
|
||||
import EmptyState from "$lib/components/empty-state.svelte";
|
||||
import ErrorNote from "$lib/components/error-note.svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
||||
import { clip, fmtDateTime, fmtMinute, shortId } from "$lib/format";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
let {
|
||||
client,
|
||||
info,
|
||||
open = $bindable(false),
|
||||
onChanged,
|
||||
}: {
|
||||
client: ApiClient;
|
||||
info: Pick<ConversationSummary, "id" | "kind" | "running_turn" | "title">;
|
||||
open?: boolean;
|
||||
onChanged: () => void;
|
||||
} = $props();
|
||||
|
||||
const LIMIT = 30;
|
||||
const LINE = 160;
|
||||
|
||||
let turns = $state<TurnSummary[] | null>(null);
|
||||
let failure = $state<string | null>(null);
|
||||
let picked = $state<number | null>(null);
|
||||
let busy = $state(false);
|
||||
let list = $state<HTMLDivElement | null>(null);
|
||||
|
||||
const name = $derived(info.title || `${info.kind} ${shortId(info.id)}`);
|
||||
const count = $derived(
|
||||
turns === null || picked === null ? 0 : turns.length - picked
|
||||
);
|
||||
|
||||
function plural(n: number): string {
|
||||
return n === 1 ? "1 turn" : `${n} turns`;
|
||||
}
|
||||
|
||||
function brokenTail(rows: TurnSummary[]): number | null {
|
||||
let at = rows.length;
|
||||
while (at > 0 && rows[at - 1].failed) {
|
||||
at -= 1;
|
||||
}
|
||||
return at < rows.length ? at : null;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
turns = null;
|
||||
failure = null;
|
||||
picked = null;
|
||||
try {
|
||||
({ turns } = await client.turns(info.id, LIMIT));
|
||||
picked = brokenTail(turns);
|
||||
await tick();
|
||||
if (list) {
|
||||
list.scrollTop = list.scrollHeight;
|
||||
list
|
||||
.querySelector(":checked")
|
||||
?.closest("label")
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
} catch (error) {
|
||||
failure = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function rewind() {
|
||||
const turn = turns?.[picked ?? -1];
|
||||
if (!turn) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
const result = await client.rewind(info.id, turn.id);
|
||||
open = false;
|
||||
toast.success(`Cut ${plural(result.turns)}`);
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
untrack(() => load());
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Rewind {name}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Pick the turn to cut from. The model forgets it and every turn after it.
|
||||
The old session stays in the database.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="flex min-w-0 flex-col gap-3">
|
||||
{#if failure}
|
||||
<ErrorNote message={failure} retry={load} />
|
||||
{:else if turns === null}
|
||||
<div class="flex flex-col gap-3 py-2">
|
||||
{#each [0, 1, 2] as row (row)}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Skeleton class="h-3.5 w-3/5" />
|
||||
<Skeleton class="h-3 w-4/5" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if turns.length === 0}
|
||||
<EmptyState
|
||||
hint="The first turn of a thread stays, and this thread has no other."
|
||||
title="Nothing to cut"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
aria-label="Turn to cut from"
|
||||
class="-mx-2 grid max-h-[min(22rem,50dvh)] grid-cols-[auto_minmax(0,1fr)] gap-x-3 overflow-y-auto rounded-md pt-2"
|
||||
role="radiogroup"
|
||||
bind:this={list}
|
||||
>
|
||||
{#each turns as turn, index (turn.id)}
|
||||
{@const gone = picked !== null && index >= picked}
|
||||
<label
|
||||
class={cn(
|
||||
"relative col-span-2 grid cursor-pointer grid-cols-subgrid items-baseline border-t px-2 py-2 first:border-t-transparent has-focus-visible:ring-1 has-focus-visible:ring-ring has-focus-visible:ring-inset",
|
||||
gone ? "bg-destructive/5" : "row-hover",
|
||||
index === picked &&
|
||||
"border-t-destructive/40 first:border-t-destructive/40"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
checked={index === picked}
|
||||
class="sr-only"
|
||||
name="rewind-turn"
|
||||
onchange={() => {
|
||||
picked = index;
|
||||
}}
|
||||
type="radio"
|
||||
>
|
||||
<span
|
||||
class="tabular text-muted-foreground text-xs"
|
||||
title={fmtDateTime(turn.ts)}
|
||||
>
|
||||
{fmtMinute(turn.ts)}
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span
|
||||
class={cn(
|
||||
"truncate font-medium",
|
||||
gone && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{clip(turn.text, LINE) || "empty prompt"}
|
||||
</span>
|
||||
<span
|
||||
class={cn(
|
||||
"truncate text-xs",
|
||||
turn.failed ? "text-destructive" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{clip(turn.reply, LINE) ||
|
||||
(turn.failed ? "failed" : "no reply")}
|
||||
</span>
|
||||
</span>
|
||||
{#if index === picked}
|
||||
<span
|
||||
class="-top-2 absolute right-2 rounded-md bg-[color-mix(in_oklab,var(--destructive)_12%,var(--popover))] px-1.5 font-medium text-destructive text-xs leading-4"
|
||||
>
|
||||
cut from here
|
||||
</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if info.running_turn}
|
||||
<p class="text-warn text-xs">A turn is running. Interrupt it first.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
onclick={() => {
|
||||
// biome-ignore lint/suspicious/noGlobalAssign: bindable prop, not window.open
|
||||
open = false;
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{#if turns?.length}
|
||||
<Button
|
||||
disabled={busy || count === 0 || Boolean(info.running_turn)}
|
||||
onclick={rewind}
|
||||
variant="destructive"
|
||||
>
|
||||
{count === 0 ? "Cut" : `Cut ${plural(count)}`}
|
||||
</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
Reference in new issue
Block a user