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(
|
export async function cancelJobGhostwriterRun(
|
||||||
jobId: string,
|
jobId: string,
|
||||||
runId: string,
|
runId: string,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { getMetaShortcutLabel, isMetaKeyPressed } from "@client/lib/meta-key";
|
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 type React from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -9,18 +9,22 @@ type ComposerProps = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
canRegenerate: boolean;
|
canRegenerate: boolean;
|
||||||
|
canReset: boolean;
|
||||||
onRegenerate: () => Promise<void>;
|
onRegenerate: () => Promise<void>;
|
||||||
onStop: () => Promise<void>;
|
onStop: () => Promise<void>;
|
||||||
onSend: (content: string) => Promise<void>;
|
onSend: (content: string) => Promise<void>;
|
||||||
|
onReset: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Composer: React.FC<ComposerProps> = ({
|
export const Composer: React.FC<ComposerProps> = ({
|
||||||
disabled,
|
disabled,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
canRegenerate,
|
canRegenerate,
|
||||||
|
canReset,
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
onStop,
|
onStop,
|
||||||
onSend,
|
onSend,
|
||||||
|
onReset,
|
||||||
}) => {
|
}) => {
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
|
|
||||||
@ -51,6 +55,18 @@ export const Composer: React.FC<ComposerProps> = ({
|
|||||||
{getMetaShortcutLabel("Enter")} to send
|
{getMetaShortcutLabel("Enter")} to send
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<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 ? (
|
{isStreaming ? (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@ -3,6 +3,16 @@ import type { Job, JobChatMessage, JobChatStreamEvent } from "@shared/types";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Composer } from "./Composer";
|
import { Composer } from "./Composer";
|
||||||
import { MessageList } from "./MessageList";
|
import { MessageList } from "./MessageList";
|
||||||
|
|
||||||
@ -19,6 +29,8 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
);
|
);
|
||||||
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
|
||||||
|
|
||||||
const messageListRef = useRef<HTMLDivElement | null>(null);
|
const messageListRef = useRef<HTMLDivElement | null>(null);
|
||||||
const streamAbortRef = useRef<AbortController | 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]);
|
}, [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 (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-col">
|
<div className="flex h-full min-h-0 flex-1 flex-col">
|
||||||
<div
|
<div
|
||||||
@ -266,11 +294,34 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
disabled={isLoading || isStreaming}
|
disabled={isLoading || isStreaming}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
canRegenerate={canRegenerate}
|
canRegenerate={canRegenerate}
|
||||||
|
canReset={canReset}
|
||||||
onRegenerate={regenerate}
|
onRegenerate={regenerate}
|
||||||
onStop={stopStreaming}
|
onStop={stopStreaming}
|
||||||
onSend={sendMessage}
|
onSend={sendMessage}
|
||||||
|
onReset={() => setIsResetDialogOpen(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</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(
|
ghostwriterRouter.get(
|
||||||
"/threads",
|
"/threads",
|
||||||
asyncRoute(async (req, res) => {
|
asyncRoute(async (req, res) => {
|
||||||
|
|||||||
@ -342,6 +342,26 @@ export async function completeRun(
|
|||||||
return getRunById(runId);
|
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(
|
export async function completeRunIfRunning(
|
||||||
runId: string,
|
runId: string,
|
||||||
input: {
|
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: {
|
export async function cancelRunForJob(input: {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
runId: string;
|
runId: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user