feat: add Ghostwriter "Start over" reset with confirmation dialog (#289)
This commit is contained in:
parent
0b55cb260a
commit
4894711396
@ -671,6 +671,18 @@ export async function cancelJobChatRun(
|
||||
);
|
||||
}
|
||||
|
||||
export async function resetJobGhostwriterConversation(
|
||||
jobId: string,
|
||||
): Promise<{ deletedMessages: number; deletedRuns: number }> {
|
||||
return fetchApi<{ deletedMessages: number; deletedRuns: number }>(
|
||||
`/jobs/${jobId}/chat/reset`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelJobGhostwriterRun(
|
||||
jobId: string,
|
||||
runId: string,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { getMetaShortcutLabel, isMetaKeyPressed } from "@client/lib/meta-key";
|
||||
import { RefreshCcw, Send, Square } from "lucide-react";
|
||||
import { Eraser, RefreshCcw, Send, Square } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -9,18 +9,22 @@ type ComposerProps = {
|
||||
disabled?: boolean;
|
||||
isStreaming: boolean;
|
||||
canRegenerate: boolean;
|
||||
canReset: boolean;
|
||||
onRegenerate: () => Promise<void>;
|
||||
onStop: () => Promise<void>;
|
||||
onSend: (content: string) => Promise<void>;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
export const Composer: React.FC<ComposerProps> = ({
|
||||
disabled,
|
||||
isStreaming,
|
||||
canRegenerate,
|
||||
canReset,
|
||||
onRegenerate,
|
||||
onStop,
|
||||
onSend,
|
||||
onReset,
|
||||
}) => {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
@ -51,6 +55,18 @@ export const Composer: React.FC<ComposerProps> = ({
|
||||
{getMetaShortcutLabel("Enter")} to send
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={onReset}
|
||||
disabled={disabled || !canReset}
|
||||
aria-label="Start over"
|
||||
title="Start over"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Eraser className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
{isStreaming ? (
|
||||
<Button
|
||||
size="icon"
|
||||
|
||||
@ -3,6 +3,16 @@ import type { Job, JobChatMessage, JobChatStreamEvent } from "@shared/types";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Composer } from "./Composer";
|
||||
import { MessageList } from "./MessageList";
|
||||
|
||||
@ -19,6 +29,8 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
||||
);
|
||||
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
||||
|
||||
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
|
||||
|
||||
const messageListRef = useRef<HTMLDivElement | null>(null);
|
||||
const streamAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
@ -235,6 +247,22 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
||||
}
|
||||
}, [isStreaming, job.id, loadMessages, messages, onStreamEvent]);
|
||||
|
||||
const canReset = useMemo(() => {
|
||||
return !isStreaming && messages.length > 0;
|
||||
}, [isStreaming, messages]);
|
||||
|
||||
const resetConversation = useCallback(async () => {
|
||||
try {
|
||||
await api.resetJobGhostwriterConversation(job.id);
|
||||
setMessages([]);
|
||||
toast.success("Conversation cleared");
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to reset conversation";
|
||||
toast.error(message);
|
||||
}
|
||||
}, [job.id]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
@ -266,11 +294,34 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
||||
disabled={isLoading || isStreaming}
|
||||
isStreaming={isStreaming}
|
||||
canRegenerate={canRegenerate}
|
||||
canReset={canReset}
|
||||
onRegenerate={regenerate}
|
||||
onStop={stopStreaming}
|
||||
onSend={sendMessage}
|
||||
onReset={() => setIsResetDialogOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={isResetDialogOpen} onOpenChange={setIsResetDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Start over?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently erase the entire conversation. This action
|
||||
cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => void resetConversation()}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Erase conversation
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -259,6 +259,21 @@ ghostwriterRouter.post(
|
||||
}),
|
||||
);
|
||||
|
||||
ghostwriterRouter.post(
|
||||
"/reset",
|
||||
asyncRoute(async (req, res) => {
|
||||
const jobId = getJobId(req);
|
||||
|
||||
await runWithRequestContext({ jobId }, async () => {
|
||||
const result = await ghostwriterService.resetConversationForJob({
|
||||
jobId,
|
||||
});
|
||||
|
||||
ok(res, result);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
ghostwriterRouter.get(
|
||||
"/threads",
|
||||
asyncRoute(async (req, res) => {
|
||||
|
||||
@ -342,6 +342,26 @@ export async function completeRun(
|
||||
return getRunById(runId);
|
||||
}
|
||||
|
||||
export async function deleteAllMessagesForThread(
|
||||
threadId: string,
|
||||
): Promise<number> {
|
||||
const result = await db
|
||||
.delete(jobChatMessages)
|
||||
.where(eq(jobChatMessages.threadId, threadId));
|
||||
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
export async function deleteAllRunsForThread(
|
||||
threadId: string,
|
||||
): Promise<number> {
|
||||
const result = await db
|
||||
.delete(jobChatRuns)
|
||||
.where(eq(jobChatRuns.threadId, threadId));
|
||||
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
export async function completeRunIfRunning(
|
||||
runId: string,
|
||||
input: {
|
||||
|
||||
@ -581,6 +581,39 @@ export async function cancelRun(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function resetConversationForJob(input: {
|
||||
jobId: string;
|
||||
}): Promise<{ deletedMessages: number; deletedRuns: number }> {
|
||||
const thread = await ensureJobThread(input.jobId);
|
||||
|
||||
const activeRun = await jobChatRepo.getActiveRunForThread(thread.id);
|
||||
if (activeRun) {
|
||||
const controller = abortControllers.get(activeRun.id);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
}
|
||||
await jobChatRepo.completeRunIfRunning(activeRun.id, {
|
||||
status: "cancelled",
|
||||
errorCode: "REQUEST_TIMEOUT",
|
||||
errorMessage: "Conversation reset by user",
|
||||
});
|
||||
}
|
||||
|
||||
const deletedMessages = await jobChatRepo.deleteAllMessagesForThread(
|
||||
thread.id,
|
||||
);
|
||||
const deletedRuns = await jobChatRepo.deleteAllRunsForThread(thread.id);
|
||||
|
||||
logger.info("Ghostwriter conversation reset", {
|
||||
jobId: input.jobId,
|
||||
threadId: thread.id,
|
||||
deletedMessages,
|
||||
deletedRuns,
|
||||
});
|
||||
|
||||
return { deletedMessages, deletedRuns };
|
||||
}
|
||||
|
||||
export async function cancelRunForJob(input: {
|
||||
jobId: string;
|
||||
runId: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user