feat(ghostwriter): branching conversations with edit and per-message regenerate (#290)
Turn Ghostwriter's flat message list into a tree structure, enabling Claude.ai/ChatGPT-style branching conversations. **Data model**: Add `parentMessageId` and `activeChildId` to messages, `activeRootMessageId` to threads. Migration backfills existing messages into a linear chain and links regenerated messages as siblings. **Backend**: Tree-walking queries (getActivePathFromRoot, getAncestorPath, getSiblingsOf), rewritten history builder that follows the ancestor path, new editMessage and switchBranch services, regenerate now works on any assistant message (not just the latest). **Frontend**: BranchNavigator component (← 2/3 → arrows), inline edit on user messages, per-message regenerate on assistant messages, regenerate button removed from composer (now per-message). **Infra**: Pin Node 22 via Volta to prevent ABI mismatches with better-sqlite3 across environments.
This commit is contained in:
parent
f19471ab58
commit
4787f4d151
@ -9,6 +9,7 @@ import type {
|
|||||||
ApplicationTask,
|
ApplicationTask,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
BackupInfo,
|
BackupInfo,
|
||||||
|
BranchInfo,
|
||||||
DemoInfoResponse,
|
DemoInfoResponse,
|
||||||
Job,
|
Job,
|
||||||
JobActionRequest,
|
JobActionRequest,
|
||||||
@ -559,7 +560,7 @@ export async function listJobChatThreads(jobId: string): Promise<{
|
|||||||
export async function listJobGhostwriterMessages(
|
export async function listJobGhostwriterMessages(
|
||||||
jobId: string,
|
jobId: string,
|
||||||
options?: { limit?: number; offset?: number },
|
options?: { limit?: number; offset?: number },
|
||||||
): Promise<{ messages: JobChatMessage[] }> {
|
): Promise<{ messages: JobChatMessage[]; branches: BranchInfo[] }> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (typeof options?.limit === "number") {
|
if (typeof options?.limit === "number") {
|
||||||
params.set("limit", String(options.limit));
|
params.set("limit", String(options.limit));
|
||||||
@ -568,7 +569,7 @@ export async function listJobGhostwriterMessages(
|
|||||||
params.set("offset", String(options.offset));
|
params.set("offset", String(options.offset));
|
||||||
}
|
}
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
return fetchApi<{ messages: JobChatMessage[] }>(
|
return fetchApi<{ messages: JobChatMessage[]; branches: BranchInfo[] }>(
|
||||||
`/jobs/${jobId}/chat/messages${query ? `?${query}` : ""}`,
|
`/jobs/${jobId}/chat/messages${query ? `?${query}` : ""}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -747,6 +748,37 @@ export async function streamRegenerateJobGhostwriterMessage(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function editJobGhostwriterMessage(
|
||||||
|
jobId: string,
|
||||||
|
messageId: string,
|
||||||
|
input: { content: string; signal?: AbortSignal },
|
||||||
|
handlers: {
|
||||||
|
onEvent: (event: JobChatStreamEvent) => void;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
return streamSseEvents(
|
||||||
|
`/jobs/${jobId}/chat/messages/${messageId}/edit`,
|
||||||
|
{ content: input.content, stream: true },
|
||||||
|
{
|
||||||
|
onEvent: handlers.onEvent,
|
||||||
|
signal: input.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function switchJobGhostwriterBranch(
|
||||||
|
jobId: string,
|
||||||
|
messageId: string,
|
||||||
|
): Promise<{ messages: JobChatMessage[]; branches: BranchInfo[] }> {
|
||||||
|
return fetchApi<{ messages: JobChatMessage[]; branches: BranchInfo[] }>(
|
||||||
|
`/jobs/${jobId}/chat/messages/${messageId}/switch-branch`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function toJobIdList(idOrIds: string | string[]): string[] {
|
function toJobIdList(idOrIds: string | string[]): string[] {
|
||||||
return Array.isArray(idOrIds) ? idOrIds : [idOrIds];
|
return Array.isArray(idOrIds) ? idOrIds : [idOrIds];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,44 @@
|
|||||||
|
import type { BranchInfo } from "@shared/types";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
|
||||||
|
type BranchNavigatorProps = {
|
||||||
|
branchInfo: BranchInfo;
|
||||||
|
onSwitch: (messageId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BranchNavigator: React.FC<BranchNavigatorProps> = ({
|
||||||
|
branchInfo,
|
||||||
|
onSwitch,
|
||||||
|
}) => {
|
||||||
|
const { siblingIds, activeIndex } = branchInfo;
|
||||||
|
const total = siblingIds.length;
|
||||||
|
const canGoLeft = activeIndex > 0;
|
||||||
|
const canGoRight = activeIndex < total - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-0.5 text-[10px] text-muted-foreground">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canGoLeft}
|
||||||
|
onClick={() => canGoLeft && onSwitch(siblingIds[activeIndex - 1])}
|
||||||
|
className="rounded p-0.5 hover:bg-muted/60 disabled:opacity-30 disabled:cursor-default"
|
||||||
|
aria-label="Previous variant"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{activeIndex + 1}/{total}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canGoRight}
|
||||||
|
onClick={() => canGoRight && onSwitch(siblingIds[activeIndex + 1])}
|
||||||
|
className="rounded p-0.5 hover:bg-muted/60 disabled:opacity-30 disabled:cursor-default"
|
||||||
|
aria-label="Next variant"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { getMetaShortcutLabel, isMetaKeyPressed } from "@client/lib/meta-key";
|
import { getMetaShortcutLabel, isMetaKeyPressed } from "@client/lib/meta-key";
|
||||||
import { Eraser, RefreshCcw, Send, Square } from "lucide-react";
|
import { Eraser, 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";
|
||||||
@ -8,9 +8,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
type ComposerProps = {
|
type ComposerProps = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
canRegenerate: boolean;
|
|
||||||
canReset: boolean;
|
canReset: boolean;
|
||||||
onRegenerate: () => Promise<void>;
|
|
||||||
onStop: () => Promise<void>;
|
onStop: () => Promise<void>;
|
||||||
onSend: (content: string) => Promise<void>;
|
onSend: (content: string) => Promise<void>;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
@ -19,9 +17,7 @@ type ComposerProps = {
|
|||||||
export const Composer: React.FC<ComposerProps> = ({
|
export const Composer: React.FC<ComposerProps> = ({
|
||||||
disabled,
|
disabled,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
canRegenerate,
|
|
||||||
canReset,
|
canReset,
|
||||||
onRegenerate,
|
|
||||||
onStop,
|
onStop,
|
||||||
onSend,
|
onSend,
|
||||||
onReset,
|
onReset,
|
||||||
@ -67,7 +63,7 @@ export const Composer: React.FC<ComposerProps> = ({
|
|||||||
<Eraser className="h-3.5 w-3.5" />
|
<Eraser className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isStreaming ? (
|
{isStreaming && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -77,17 +73,6 @@ export const Composer: React.FC<ComposerProps> = ({
|
|||||||
>
|
>
|
||||||
<Square className="h-3.5 w-3.5" />
|
<Square className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => void onRegenerate()}
|
|
||||||
disabled={disabled || !canRegenerate}
|
|
||||||
aria-label="Regenerate response"
|
|
||||||
title="Regenerate response"
|
|
||||||
>
|
|
||||||
<RefreshCcw className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import * as api from "@client/api";
|
import * as api from "@client/api";
|
||||||
import type { Job, JobChatMessage, JobChatStreamEvent } from "@shared/types";
|
import type {
|
||||||
|
BranchInfo,
|
||||||
|
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";
|
||||||
@ -22,6 +27,7 @@ type GhostwriterPanelProps = {
|
|||||||
|
|
||||||
export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
||||||
const [messages, setMessages] = useState<JobChatMessage[]>([]);
|
const [messages, setMessages] = useState<JobChatMessage[]>([]);
|
||||||
|
const [branches, setBranches] = useState<BranchInfo[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
const [streamingMessageId, setStreamingMessageId] = useState<string | null>(
|
const [streamingMessageId, setStreamingMessageId] = useState<string | null>(
|
||||||
@ -49,6 +55,7 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
limit: 300,
|
limit: 300,
|
||||||
});
|
});
|
||||||
setMessages(data.messages);
|
setMessages(data.messages);
|
||||||
|
setBranches(data.branches);
|
||||||
}, [job.id]);
|
}, [job.id]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@ -94,6 +101,8 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
tokensOut: null,
|
tokensOut: null,
|
||||||
version: 1,
|
version: 1,
|
||||||
replacesMessageId: null,
|
replacesMessageId: null,
|
||||||
|
parentMessageId: null,
|
||||||
|
activeChildId: null,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
@ -158,6 +167,8 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
tokensOut: null,
|
tokensOut: null,
|
||||||
version: 1,
|
version: 1,
|
||||||
replacesMessageId: null,
|
replacesMessageId: null,
|
||||||
|
parentMessageId: null,
|
||||||
|
activeChildId: null,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@ -209,16 +220,18 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
}
|
}
|
||||||
}, [activeRunId, job.id, loadMessages]);
|
}, [activeRunId, job.id, loadMessages]);
|
||||||
|
|
||||||
const canRegenerate = useMemo(() => {
|
const regenerate = useCallback(
|
||||||
if (isStreaming || messages.length === 0) return false;
|
async (assistantMessageId: string) => {
|
||||||
const last = messages[messages.length - 1];
|
if (isStreaming) return;
|
||||||
return last.role === "assistant";
|
|
||||||
}, [isStreaming, messages]);
|
|
||||||
|
|
||||||
const regenerate = useCallback(async () => {
|
// Remove messages below the branch point (everything after the regenerated message disappears)
|
||||||
if (isStreaming || messages.length === 0) return;
|
setMessages((current) => {
|
||||||
const last = messages[messages.length - 1];
|
const targetIndex = current.findIndex(
|
||||||
if (last.role !== "assistant") return;
|
(m) => m.id === assistantMessageId,
|
||||||
|
);
|
||||||
|
if (targetIndex === -1) return current;
|
||||||
|
return current.slice(0, targetIndex);
|
||||||
|
});
|
||||||
|
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@ -227,7 +240,7 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
try {
|
try {
|
||||||
await api.streamRegenerateJobGhostwriterMessage(
|
await api.streamRegenerateJobGhostwriterMessage(
|
||||||
job.id,
|
job.id,
|
||||||
last.id,
|
assistantMessageId,
|
||||||
{ signal: controller.signal },
|
{ signal: controller.signal },
|
||||||
{ onEvent: onStreamEvent },
|
{ onEvent: onStreamEvent },
|
||||||
);
|
);
|
||||||
@ -245,7 +258,82 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
streamAbortRef.current = null;
|
streamAbortRef.current = null;
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
}, [isStreaming, job.id, loadMessages, messages, onStreamEvent]);
|
},
|
||||||
|
[isStreaming, job.id, loadMessages, onStreamEvent],
|
||||||
|
);
|
||||||
|
|
||||||
|
const editMessage = useCallback(
|
||||||
|
async (messageId: string, content: string) => {
|
||||||
|
if (isStreaming) return;
|
||||||
|
|
||||||
|
// Remove the edited message and everything below it (old branch disappears)
|
||||||
|
setMessages((current) => {
|
||||||
|
const targetIndex = current.findIndex((m) => m.id === messageId);
|
||||||
|
if (targetIndex === -1) return current;
|
||||||
|
// Keep everything before the edited message, add an optimistic new user message
|
||||||
|
const before = current.slice(0, targetIndex);
|
||||||
|
return [
|
||||||
|
...before,
|
||||||
|
{
|
||||||
|
id: `tmp-edit-${Date.now()}`,
|
||||||
|
threadId: current[0]?.threadId || "pending-thread",
|
||||||
|
jobId: job.id,
|
||||||
|
role: "user" as const,
|
||||||
|
content,
|
||||||
|
status: "complete" as const,
|
||||||
|
tokensIn: null,
|
||||||
|
tokensOut: null,
|
||||||
|
version: 1,
|
||||||
|
replacesMessageId: null,
|
||||||
|
parentMessageId: null,
|
||||||
|
activeChildId: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsStreaming(true);
|
||||||
|
const controller = new AbortController();
|
||||||
|
streamAbortRef.current = controller;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.editJobGhostwriterMessage(
|
||||||
|
job.id,
|
||||||
|
messageId,
|
||||||
|
{ content, signal: controller.signal },
|
||||||
|
{ onEvent: onStreamEvent },
|
||||||
|
);
|
||||||
|
await loadMessages();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to edit message";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
streamAbortRef.current = null;
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isStreaming, job.id, loadMessages, onStreamEvent],
|
||||||
|
);
|
||||||
|
|
||||||
|
const switchBranch = useCallback(
|
||||||
|
async (messageId: string) => {
|
||||||
|
try {
|
||||||
|
const result = await api.switchJobGhostwriterBranch(job.id, messageId);
|
||||||
|
setMessages(result.messages);
|
||||||
|
setBranches(result.branches);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to switch branch";
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[job.id],
|
||||||
|
);
|
||||||
|
|
||||||
const canReset = useMemo(() => {
|
const canReset = useMemo(() => {
|
||||||
return !isStreaming && messages.length > 0;
|
return !isStreaming && messages.length > 0;
|
||||||
@ -255,6 +343,7 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
try {
|
try {
|
||||||
await api.resetJobGhostwriterConversation(job.id);
|
await api.resetJobGhostwriterConversation(job.id);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setBranches([]);
|
||||||
toast.success("Conversation cleared");
|
toast.success("Conversation cleared");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
@ -283,8 +372,12 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
) : (
|
) : (
|
||||||
<MessageList
|
<MessageList
|
||||||
messages={messages}
|
messages={messages}
|
||||||
|
branches={branches}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
streamingMessageId={streamingMessageId}
|
streamingMessageId={streamingMessageId}
|
||||||
|
onRegenerate={regenerate}
|
||||||
|
onEdit={editMessage}
|
||||||
|
onSwitchBranch={switchBranch}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -293,9 +386,7 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
<Composer
|
<Composer
|
||||||
disabled={isLoading || isStreaming}
|
disabled={isLoading || isStreaming}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
canRegenerate={canRegenerate}
|
|
||||||
canReset={canReset}
|
canReset={canReset}
|
||||||
onRegenerate={regenerate}
|
|
||||||
onStop={stopStreaming}
|
onStop={stopStreaming}
|
||||||
onSend={sendMessage}
|
onSend={sendMessage}
|
||||||
onReset={() => setIsResetDialogOpen(true)}
|
onReset={() => setIsResetDialogOpen(true)}
|
||||||
|
|||||||
@ -1,20 +1,59 @@
|
|||||||
import type { JobChatMessage } from "@shared/types";
|
import type { BranchInfo, JobChatMessage } from "@shared/types";
|
||||||
|
import { Pencil, RefreshCcw } from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { BranchNavigator } from "./BranchNavigator";
|
||||||
import { StreamingMessage } from "./StreamingMessage";
|
import { StreamingMessage } from "./StreamingMessage";
|
||||||
|
|
||||||
type MessageListProps = {
|
type MessageListProps = {
|
||||||
messages: JobChatMessage[];
|
messages: JobChatMessage[];
|
||||||
|
branches: BranchInfo[];
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
streamingMessageId: string | null;
|
streamingMessageId: string | null;
|
||||||
|
onRegenerate: (messageId: string) => void;
|
||||||
|
onEdit: (messageId: string, content: string) => void;
|
||||||
|
onSwitchBranch: (messageId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MessageList: React.FC<MessageListProps> = ({
|
export const MessageList: React.FC<MessageListProps> = ({
|
||||||
messages,
|
messages,
|
||||||
|
branches,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
streamingMessageId,
|
streamingMessageId,
|
||||||
|
onRegenerate,
|
||||||
|
onEdit,
|
||||||
|
onSwitchBranch,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
||||||
|
const [editContent, setEditContent] = useState("");
|
||||||
|
|
||||||
|
const branchMap = new Map<string, BranchInfo>();
|
||||||
|
for (const branch of branches) {
|
||||||
|
branchMap.set(branch.messageId, branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startEditing = (message: JobChatMessage) => {
|
||||||
|
setEditingMessageId(message.id);
|
||||||
|
setEditContent(message.content);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEditing = () => {
|
||||||
|
setEditingMessageId(null);
|
||||||
|
setEditContent("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitEdit = (messageId: string) => {
|
||||||
|
const content = editContent.trim();
|
||||||
|
if (!content) return;
|
||||||
|
onEdit(messageId, content);
|
||||||
|
setEditingMessageId(null);
|
||||||
|
setEditContent("");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{messages.length > 0 &&
|
{messages.length > 0 &&
|
||||||
@ -24,22 +63,81 @@ export const MessageList: React.FC<MessageListProps> = ({
|
|||||||
isStreaming &&
|
isStreaming &&
|
||||||
message.role === "assistant" &&
|
message.role === "assistant" &&
|
||||||
streamingMessageId === message.id;
|
streamingMessageId === message.id;
|
||||||
|
const isEditing = editingMessageId === message.id;
|
||||||
|
const branch = branchMap.get(message.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
className={`rounded-lg border p-3 ${
|
className={`group rounded-lg border p-3 ${
|
||||||
isUser
|
isUser
|
||||||
? "border-primary/30 bg-primary/5"
|
? "border-primary/30 bg-primary/5"
|
||||||
: "border-border/60 bg-background"
|
: "border-border/60 bg-background"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
<div className="mb-1 flex items-center gap-2">
|
||||||
{isUser
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
? "You"
|
{isUser ? "You" : "Ghostwriter"}
|
||||||
: `Ghostwriter${message.version > 1 ? ` v${message.version}` : ""}`}
|
</span>
|
||||||
|
{branch && (
|
||||||
|
<BranchNavigator
|
||||||
|
branchInfo={branch}
|
||||||
|
onSwitch={onSwitchBranch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
{isUser && !isStreaming && !isEditing && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startEditing(message)}
|
||||||
|
className="rounded p-1 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||||
|
aria-label="Edit message"
|
||||||
|
title="Edit message"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isUser && !isStreaming && !isActiveStreaming && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRegenerate(message.id)}
|
||||||
|
className="rounded p-1 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||||
|
aria-label="Regenerate response"
|
||||||
|
title="Regenerate response"
|
||||||
|
>
|
||||||
|
<RefreshCcw className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isActiveStreaming ? (
|
</div>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Textarea
|
||||||
|
value={editContent}
|
||||||
|
onChange={(e) => setEditContent(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
cancelEditing();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="min-h-[60px]"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button size="sm" variant="ghost" onClick={cancelEditing}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => submitEdit(message.id)}
|
||||||
|
disabled={!editContent.trim()}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isActiveStreaming ? (
|
||||||
<StreamingMessage content={message.content} />
|
<StreamingMessage content={message.content} />
|
||||||
) : message.role === "assistant" ? (
|
) : message.role === "assistant" ? (
|
||||||
<div className="text-sm leading-relaxed text-foreground [&_a]:text-primary [&_a]:underline [&_blockquote]:border-l [&_blockquote]:border-border [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-muted/40 [&_code]:px-1 [&_h1]:mt-4 [&_h1]:text-base [&_h1]:font-semibold [&_h2]:mt-4 [&_h2]:text-sm [&_h2]:font-semibold [&_h3]:mt-3 [&_h3]:text-sm [&_h3]:font-semibold [&_li]:my-1 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:my-2 [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-muted/40 [&_pre]:p-3 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5">
|
<div className="text-sm leading-relaxed text-foreground [&_a]:text-primary [&_a]:underline [&_blockquote]:border-l [&_blockquote]:border-border [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-muted/40 [&_code]:px-1 [&_h1]:mt-4 [&_h1]:text-base [&_h1]:font-semibold [&_h2]:mt-4 [&_h2]:text-sm [&_h2]:font-semibold [&_h3]:mt-3 [&_h3]:text-sm [&_h3]:font-semibold [&_li]:my-1 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:my-2 [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-muted/40 [&_pre]:p-3 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5">
|
||||||
|
|||||||
@ -2,6 +2,19 @@ import type { Server } from "node:http";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { startServer, stopServer } from "./test-utils";
|
import { startServer, stopServer } from "./test-utils";
|
||||||
|
|
||||||
|
const baseMsgFields = {
|
||||||
|
threadId: "thread-1",
|
||||||
|
jobId: "job-1",
|
||||||
|
tokensIn: 1,
|
||||||
|
tokensOut: null,
|
||||||
|
version: 1,
|
||||||
|
replacesMessageId: null,
|
||||||
|
parentMessageId: null,
|
||||||
|
activeChildId: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
vi.mock("@server/services/ghostwriter", () => ({
|
vi.mock("@server/services/ghostwriter", () => ({
|
||||||
listThreads: vi.fn(async () => [
|
listThreads: vi.fn(async () => [
|
||||||
{
|
{
|
||||||
@ -23,97 +36,63 @@ vi.mock("@server/services/ghostwriter", () => ({
|
|||||||
lastMessageAt: null,
|
lastMessageAt: null,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
listMessages: vi.fn(async () => [
|
listMessages: vi.fn(async () => ({
|
||||||
|
messages: [
|
||||||
{
|
{
|
||||||
id: "message-1",
|
id: "message-1",
|
||||||
threadId: "thread-1",
|
...baseMsgFields,
|
||||||
jobId: "job-1",
|
|
||||||
role: "user",
|
role: "user",
|
||||||
content: "hello",
|
content: "hello",
|
||||||
status: "complete",
|
status: "complete",
|
||||||
tokensIn: 1,
|
|
||||||
tokensOut: null,
|
|
||||||
version: 1,
|
|
||||||
replacesMessageId: null,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
},
|
||||||
]),
|
],
|
||||||
listMessagesForJob: vi.fn(async () => [
|
branches: [],
|
||||||
|
})),
|
||||||
|
listMessagesForJob: vi.fn(async () => ({
|
||||||
|
messages: [
|
||||||
{
|
{
|
||||||
id: "message-1",
|
id: "message-1",
|
||||||
threadId: "thread-1",
|
...baseMsgFields,
|
||||||
jobId: "job-1",
|
|
||||||
role: "user",
|
role: "user",
|
||||||
content: "hello",
|
content: "hello",
|
||||||
status: "complete",
|
status: "complete",
|
||||||
tokensIn: 1,
|
|
||||||
tokensOut: null,
|
|
||||||
version: 1,
|
|
||||||
replacesMessageId: null,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
},
|
||||||
]),
|
],
|
||||||
|
branches: [],
|
||||||
|
})),
|
||||||
sendMessage: vi.fn(async () => ({
|
sendMessage: vi.fn(async () => ({
|
||||||
userMessage: {
|
userMessage: {
|
||||||
id: "user-1",
|
id: "user-1",
|
||||||
threadId: "thread-1",
|
...baseMsgFields,
|
||||||
jobId: "job-1",
|
|
||||||
role: "user",
|
role: "user",
|
||||||
content: "hello",
|
content: "hello",
|
||||||
status: "complete",
|
status: "complete",
|
||||||
tokensIn: 1,
|
|
||||||
tokensOut: null,
|
|
||||||
version: 1,
|
|
||||||
replacesMessageId: null,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
},
|
||||||
assistantMessage: {
|
assistantMessage: {
|
||||||
id: "assistant-1",
|
id: "assistant-1",
|
||||||
threadId: "thread-1",
|
...baseMsgFields,
|
||||||
jobId: "job-1",
|
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "hi",
|
content: "hi",
|
||||||
status: "complete",
|
status: "complete",
|
||||||
tokensIn: 1,
|
|
||||||
tokensOut: 1,
|
tokensOut: 1,
|
||||||
version: 1,
|
|
||||||
replacesMessageId: null,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
},
|
||||||
runId: "run-1",
|
runId: "run-1",
|
||||||
})),
|
})),
|
||||||
sendMessageForJob: vi.fn(async () => ({
|
sendMessageForJob: vi.fn(async () => ({
|
||||||
userMessage: {
|
userMessage: {
|
||||||
id: "user-1",
|
id: "user-1",
|
||||||
threadId: "thread-1",
|
...baseMsgFields,
|
||||||
jobId: "job-1",
|
|
||||||
role: "user",
|
role: "user",
|
||||||
content: "hello",
|
content: "hello",
|
||||||
status: "complete",
|
status: "complete",
|
||||||
tokensIn: 1,
|
|
||||||
tokensOut: null,
|
|
||||||
version: 1,
|
|
||||||
replacesMessageId: null,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
},
|
||||||
assistantMessage: {
|
assistantMessage: {
|
||||||
id: "assistant-1",
|
id: "assistant-1",
|
||||||
threadId: "thread-1",
|
...baseMsgFields,
|
||||||
jobId: "job-1",
|
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "hi",
|
content: "hi",
|
||||||
status: "complete",
|
status: "complete",
|
||||||
tokensIn: 1,
|
|
||||||
tokensOut: 1,
|
tokensOut: 1,
|
||||||
version: 1,
|
|
||||||
replacesMessageId: null,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
},
|
||||||
runId: "run-1",
|
runId: "run-1",
|
||||||
})),
|
})),
|
||||||
@ -122,19 +101,47 @@ vi.mock("@server/services/ghostwriter", () => ({
|
|||||||
runId: "run-2",
|
runId: "run-2",
|
||||||
assistantMessage: {
|
assistantMessage: {
|
||||||
id: "assistant-2",
|
id: "assistant-2",
|
||||||
threadId: "thread-1",
|
...baseMsgFields,
|
||||||
jobId: "job-1",
|
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "updated",
|
content: "updated",
|
||||||
status: "complete",
|
status: "complete",
|
||||||
tokensIn: 1,
|
|
||||||
tokensOut: 1,
|
tokensOut: 1,
|
||||||
version: 2,
|
version: 2,
|
||||||
replacesMessageId: "assistant-1",
|
replacesMessageId: "assistant-1",
|
||||||
createdAt: new Date().toISOString(),
|
parentMessageId: "user-1",
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
editMessageForJob: vi.fn(async () => ({
|
||||||
|
userMessage: {
|
||||||
|
id: "user-2",
|
||||||
|
...baseMsgFields,
|
||||||
|
role: "user",
|
||||||
|
content: "edited",
|
||||||
|
status: "complete",
|
||||||
|
},
|
||||||
|
assistantMessage: {
|
||||||
|
id: "assistant-3",
|
||||||
|
...baseMsgFields,
|
||||||
|
role: "assistant",
|
||||||
|
content: "reply to edit",
|
||||||
|
status: "complete",
|
||||||
|
tokensOut: 3,
|
||||||
|
parentMessageId: "user-2",
|
||||||
|
},
|
||||||
|
runId: "run-3",
|
||||||
|
})),
|
||||||
|
switchBranchForJob: vi.fn(async () => ({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "message-1",
|
||||||
|
...baseMsgFields,
|
||||||
|
role: "user",
|
||||||
|
content: "hello",
|
||||||
|
status: "complete",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
branches: [],
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe.sequential("Ghostwriter API", () => {
|
describe.sequential("Ghostwriter API", () => {
|
||||||
@ -151,7 +158,7 @@ describe.sequential("Ghostwriter API", () => {
|
|||||||
await stopServer({ server, closeDb, tempDir });
|
await stopServer({ server, closeDb, tempDir });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists messages with request id metadata", async () => {
|
it("lists messages with request id metadata and branch info", async () => {
|
||||||
const res = await fetch(`${baseUrl}/api/jobs/job-1/chat/messages`, {
|
const res = await fetch(`${baseUrl}/api/jobs/job-1/chat/messages`, {
|
||||||
headers: {
|
headers: {
|
||||||
"x-request-id": "chat-req-1",
|
"x-request-id": "chat-req-1",
|
||||||
@ -163,6 +170,7 @@ describe.sequential("Ghostwriter API", () => {
|
|||||||
expect(res.headers.get("x-request-id")).toBe("chat-req-1");
|
expect(res.headers.get("x-request-id")).toBe("chat-req-1");
|
||||||
expect(body.ok).toBe(true);
|
expect(body.ok).toBe(true);
|
||||||
expect(body.data.messages.length).toBe(1);
|
expect(body.data.messages.length).toBe(1);
|
||||||
|
expect(body.data.branches).toEqual([]);
|
||||||
expect(body.meta.requestId).toBe("chat-req-1");
|
expect(body.meta.requestId).toBe("chat-req-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -182,4 +190,38 @@ describe.sequential("Ghostwriter API", () => {
|
|||||||
expect(messageBody.data.assistantMessage.role).toBe("assistant");
|
expect(messageBody.data.assistantMessage.role).toBe("assistant");
|
||||||
expect(typeof messageBody.meta.requestId).toBe("string");
|
expect(typeof messageBody.meta.requestId).toBe("string");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("edits a user message", async () => {
|
||||||
|
const res = await fetch(
|
||||||
|
`${baseUrl}/api/jobs/job-1/chat/messages/user-1/edit`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ content: "edited content" }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(body.data.runId).toBe("run-3");
|
||||||
|
expect(body.data.userMessage.content).toBe("edited");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches branch", async () => {
|
||||||
|
const res = await fetch(
|
||||||
|
`${baseUrl}/api/jobs/job-1/chat/messages/message-1/switch-branch`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(body.data.messages.length).toBe(1);
|
||||||
|
expect(body.data.branches).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -26,6 +26,11 @@ const regenerateSchema = z.object({
|
|||||||
stream: z.boolean().optional(),
|
stream: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const editMessageSchema = z.object({
|
||||||
|
content: z.string().trim().min(1).max(20000),
|
||||||
|
stream: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
function getJobId(req: Request): string {
|
function getJobId(req: Request): string {
|
||||||
const jobId = req.params.id;
|
const jobId = req.params.id;
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
@ -47,12 +52,12 @@ ghostwriterRouter.get(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await runWithRequestContext({ jobId }, async () => {
|
await runWithRequestContext({ jobId }, async () => {
|
||||||
const messages = await ghostwriterService.listMessagesForJob({
|
const result = await ghostwriterService.listMessagesForJob({
|
||||||
jobId,
|
jobId,
|
||||||
limit: parsed.data.limit,
|
limit: parsed.data.limit,
|
||||||
offset: parsed.data.offset,
|
offset: parsed.data.offset,
|
||||||
});
|
});
|
||||||
ok(res, { messages });
|
ok(res, { messages: result.messages, branches: result.branches });
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -259,6 +264,123 @@ ghostwriterRouter.post(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ghostwriterRouter.post(
|
||||||
|
"/messages/:messageId/edit",
|
||||||
|
asyncRoute(async (req, res) => {
|
||||||
|
const jobId = getJobId(req);
|
||||||
|
const messageId = req.params.messageId;
|
||||||
|
if (!messageId) {
|
||||||
|
return fail(res, badRequest("Missing message id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = editMessageSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return fail(
|
||||||
|
res,
|
||||||
|
badRequest(parsed.error.message, parsed.error.flatten()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await runWithRequestContext({ jobId }, async () => {
|
||||||
|
if (parsed.data.stream) {
|
||||||
|
setupSse(res, {
|
||||||
|
cacheControl: "no-cache, no-transform",
|
||||||
|
flushHeaders: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ghostwriterService.editMessageForJob({
|
||||||
|
jobId,
|
||||||
|
messageId,
|
||||||
|
content: parsed.data.content,
|
||||||
|
stream: {
|
||||||
|
onReady: ({ runId, threadId, messageId, requestId }) =>
|
||||||
|
writeSseData(res, {
|
||||||
|
type: "ready",
|
||||||
|
runId,
|
||||||
|
threadId,
|
||||||
|
messageId,
|
||||||
|
requestId,
|
||||||
|
}),
|
||||||
|
onDelta: ({ runId, messageId, delta }) =>
|
||||||
|
writeSseData(res, {
|
||||||
|
type: "delta",
|
||||||
|
runId,
|
||||||
|
messageId,
|
||||||
|
delta,
|
||||||
|
}),
|
||||||
|
onCompleted: ({ runId, message }) =>
|
||||||
|
writeSseData(res, {
|
||||||
|
type: "completed",
|
||||||
|
runId,
|
||||||
|
message,
|
||||||
|
}),
|
||||||
|
onCancelled: ({ runId, message }) =>
|
||||||
|
writeSseData(res, {
|
||||||
|
type: "cancelled",
|
||||||
|
runId,
|
||||||
|
message,
|
||||||
|
}),
|
||||||
|
onError: ({ runId, code, message, requestId }) =>
|
||||||
|
writeSseData(res, {
|
||||||
|
type: "error",
|
||||||
|
runId,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
requestId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const appError = toAppError(error);
|
||||||
|
writeSseData(res, {
|
||||||
|
type: "error",
|
||||||
|
code: appError.code,
|
||||||
|
message: appError.message,
|
||||||
|
requestId: res.getHeader("x-request-id") || "unknown",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ghostwriterService.editMessageForJob({
|
||||||
|
jobId,
|
||||||
|
messageId,
|
||||||
|
content: parsed.data.content,
|
||||||
|
});
|
||||||
|
|
||||||
|
ok(res, {
|
||||||
|
userMessage: result.userMessage,
|
||||||
|
assistantMessage: result.assistantMessage,
|
||||||
|
runId: result.runId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
ghostwriterRouter.post(
|
||||||
|
"/messages/:messageId/switch-branch",
|
||||||
|
asyncRoute(async (req, res) => {
|
||||||
|
const jobId = getJobId(req);
|
||||||
|
const messageId = req.params.messageId;
|
||||||
|
if (!messageId) {
|
||||||
|
return fail(res, badRequest("Missing message id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
await runWithRequestContext({ jobId }, async () => {
|
||||||
|
const result = await ghostwriterService.switchBranchForJob({
|
||||||
|
jobId,
|
||||||
|
messageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
ok(res, { messages: result.messages, branches: result.branches });
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
ghostwriterRouter.post(
|
ghostwriterRouter.post(
|
||||||
"/reset",
|
"/reset",
|
||||||
asyncRoute(async (req, res) => {
|
asyncRoute(async (req, res) => {
|
||||||
@ -326,13 +448,13 @@ ghostwriterRouter.get(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await runWithRequestContext({ jobId }, async () => {
|
await runWithRequestContext({ jobId }, async () => {
|
||||||
const messages = await ghostwriterService.listMessages({
|
const result = await ghostwriterService.listMessages({
|
||||||
jobId,
|
jobId,
|
||||||
threadId,
|
threadId,
|
||||||
limit: parsed.data.limit,
|
limit: parsed.data.limit,
|
||||||
offset: parsed.data.offset,
|
offset: parsed.data.offset,
|
||||||
});
|
});
|
||||||
ok(res, { messages });
|
ok(res, { messages: result.messages, branches: result.branches });
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -103,6 +103,7 @@ const migrations = [
|
|||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
last_message_at TEXT,
|
last_message_at TEXT,
|
||||||
|
active_root_message_id TEXT,
|
||||||
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
@ -117,6 +118,8 @@ const migrations = [
|
|||||||
tokens_out INTEGER,
|
tokens_out INTEGER,
|
||||||
version INTEGER NOT NULL DEFAULT 1,
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
replaces_message_id TEXT,
|
replaces_message_id TEXT,
|
||||||
|
parent_message_id TEXT,
|
||||||
|
active_child_id TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY (thread_id) REFERENCES job_chat_threads(id) ON DELETE CASCADE,
|
FOREIGN KEY (thread_id) REFERENCES job_chat_threads(id) ON DELETE CASCADE,
|
||||||
@ -576,6 +579,45 @@ const migrations = [
|
|||||||
'closed'
|
'closed'
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
|
// Branching conversations: add parent_message_id and active_child_id to job_chat_messages
|
||||||
|
`ALTER TABLE job_chat_messages ADD COLUMN parent_message_id TEXT`,
|
||||||
|
`ALTER TABLE job_chat_messages ADD COLUMN active_child_id TEXT`,
|
||||||
|
`ALTER TABLE job_chat_threads ADD COLUMN active_root_message_id TEXT`,
|
||||||
|
|
||||||
|
// Backfill: link existing messages into a linear chain (each message's parent = its predecessor)
|
||||||
|
`UPDATE job_chat_messages
|
||||||
|
SET parent_message_id = (
|
||||||
|
SELECT prev.id
|
||||||
|
FROM job_chat_messages prev
|
||||||
|
WHERE prev.thread_id = job_chat_messages.thread_id
|
||||||
|
AND prev.created_at < job_chat_messages.created_at
|
||||||
|
ORDER BY prev.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE parent_message_id IS NULL`,
|
||||||
|
|
||||||
|
// Backfill: for regenerated messages, re-link as siblings (same parent as the message they replaced)
|
||||||
|
`UPDATE job_chat_messages
|
||||||
|
SET parent_message_id = (
|
||||||
|
SELECT orig.parent_message_id
|
||||||
|
FROM job_chat_messages orig
|
||||||
|
WHERE orig.id = job_chat_messages.replaces_message_id
|
||||||
|
)
|
||||||
|
WHERE replaces_message_id IS NOT NULL`,
|
||||||
|
|
||||||
|
// Backfill: set active_child_id on every parent to its newest child
|
||||||
|
`UPDATE job_chat_messages
|
||||||
|
SET active_child_id = (
|
||||||
|
SELECT child.id
|
||||||
|
FROM job_chat_messages child
|
||||||
|
WHERE child.parent_message_id = job_chat_messages.id
|
||||||
|
ORDER BY child.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE id IN (SELECT DISTINCT parent_message_id FROM job_chat_messages WHERE parent_message_id IS NOT NULL)`,
|
||||||
|
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_job_chat_messages_parent ON job_chat_messages(parent_message_id)`,
|
||||||
|
|
||||||
// Backfill: Mark closed applications from latest stage event.
|
// Backfill: Mark closed applications from latest stage event.
|
||||||
`UPDATE jobs
|
`UPDATE jobs
|
||||||
SET
|
SET
|
||||||
@ -621,7 +663,13 @@ for (const migration of migrations) {
|
|||||||
.includes("alter table post_application_messages add column") ||
|
.includes("alter table post_application_messages add column") ||
|
||||||
migration
|
migration
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes("alter table stage_events add column")) &&
|
.includes("alter table stage_events add column") ||
|
||||||
|
migration
|
||||||
|
.toLowerCase()
|
||||||
|
.includes("alter table job_chat_messages add column") ||
|
||||||
|
migration
|
||||||
|
.toLowerCase()
|
||||||
|
.includes("alter table job_chat_threads add column")) &&
|
||||||
message.toLowerCase().includes("duplicate column name");
|
message.toLowerCase().includes("duplicate column name");
|
||||||
|
|
||||||
if (isDuplicateColumn) {
|
if (isDuplicateColumn) {
|
||||||
|
|||||||
@ -176,6 +176,7 @@ export const jobChatThreads = sqliteTable(
|
|||||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||||
lastMessageAt: text("last_message_at"),
|
lastMessageAt: text("last_message_at"),
|
||||||
|
activeRootMessageId: text("active_root_message_id"),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
jobUpdatedIndex: index("idx_job_chat_threads_job_updated").on(
|
jobUpdatedIndex: index("idx_job_chat_threads_job_updated").on(
|
||||||
@ -204,6 +205,8 @@ export const jobChatMessages = sqliteTable(
|
|||||||
tokensOut: integer("tokens_out"),
|
tokensOut: integer("tokens_out"),
|
||||||
version: integer("version").notNull().default(1),
|
version: integer("version").notNull().default(1),
|
||||||
replacesMessageId: text("replaces_message_id"),
|
replacesMessageId: text("replaces_message_id"),
|
||||||
|
parentMessageId: text("parent_message_id"),
|
||||||
|
activeChildId: text("active_child_id"),
|
||||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -20,6 +20,7 @@ function mapThread(row: typeof jobChatThreads.$inferSelect): JobChatThread {
|
|||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
lastMessageAt: row.lastMessageAt,
|
lastMessageAt: row.lastMessageAt,
|
||||||
|
activeRootMessageId: row.activeRootMessageId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,6 +36,8 @@ function mapMessage(row: typeof jobChatMessages.$inferSelect): JobChatMessage {
|
|||||||
tokensOut: row.tokensOut,
|
tokensOut: row.tokensOut,
|
||||||
version: row.version,
|
version: row.version,
|
||||||
replacesMessageId: row.replacesMessageId,
|
replacesMessageId: row.replacesMessageId,
|
||||||
|
parentMessageId: row.parentMessageId,
|
||||||
|
activeChildId: row.activeChildId,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
};
|
};
|
||||||
@ -182,6 +185,7 @@ export async function createMessage(input: {
|
|||||||
tokensOut?: number | null;
|
tokensOut?: number | null;
|
||||||
version?: number;
|
version?: number;
|
||||||
replacesMessageId?: string | null;
|
replacesMessageId?: string | null;
|
||||||
|
parentMessageId?: string | null;
|
||||||
}): Promise<JobChatMessage> {
|
}): Promise<JobChatMessage> {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@ -197,6 +201,7 @@ export async function createMessage(input: {
|
|||||||
tokensOut: input.tokensOut ?? null,
|
tokensOut: input.tokensOut ?? null,
|
||||||
version: input.version ?? 1,
|
version: input.version ?? 1,
|
||||||
replacesMessageId: input.replacesMessageId ?? null,
|
replacesMessageId: input.replacesMessageId ?? null,
|
||||||
|
parentMessageId: input.parentMessageId ?? null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
@ -362,6 +367,188 @@ export async function deleteAllRunsForThread(
|
|||||||
return result.changes;
|
return result.changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the active root message for a thread (for branch navigation of root messages).
|
||||||
|
*/
|
||||||
|
export async function setActiveRoot(
|
||||||
|
threadId: string,
|
||||||
|
messageId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await db
|
||||||
|
.update(jobChatThreads)
|
||||||
|
.set({ activeRootMessageId: messageId, updatedAt: now })
|
||||||
|
.where(eq(jobChatThreads.id, threadId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the active child pointer on a parent message (for branch navigation).
|
||||||
|
*/
|
||||||
|
export async function setActiveChild(
|
||||||
|
messageId: string,
|
||||||
|
activeChildId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await db
|
||||||
|
.update(jobChatMessages)
|
||||||
|
.set({ activeChildId, updatedAt: now })
|
||||||
|
.where(eq(jobChatMessages.id, messageId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all children of a message, ordered by createdAt.
|
||||||
|
*/
|
||||||
|
export async function getChildrenOfMessage(
|
||||||
|
parentMessageId: string,
|
||||||
|
): Promise<JobChatMessage[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(jobChatMessages)
|
||||||
|
.where(eq(jobChatMessages.parentMessageId, parentMessageId))
|
||||||
|
.orderBy(jobChatMessages.createdAt);
|
||||||
|
return rows.map(mapMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get siblings of a message (all children of the same parent) and which index is active.
|
||||||
|
*/
|
||||||
|
export async function getSiblingsOf(
|
||||||
|
messageId: string,
|
||||||
|
): Promise<{ siblings: JobChatMessage[]; activeIndex: number }> {
|
||||||
|
const message = await getMessageById(messageId);
|
||||||
|
if (!message) {
|
||||||
|
return { siblings: [], activeIndex: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root messages: siblings are all root messages in the same thread
|
||||||
|
if (!message.parentMessageId) {
|
||||||
|
const allInThread = await db
|
||||||
|
.select()
|
||||||
|
.from(jobChatMessages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(jobChatMessages.threadId, message.threadId),
|
||||||
|
eq(jobChatMessages.role, message.role),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(jobChatMessages.createdAt);
|
||||||
|
const rootSiblings = allInThread
|
||||||
|
.map(mapMessage)
|
||||||
|
.filter((m) => !m.parentMessageId);
|
||||||
|
|
||||||
|
if (rootSiblings.length <= 1) {
|
||||||
|
return { siblings: rootSiblings, activeIndex: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active root determined by thread's activeRootMessageId
|
||||||
|
const thread = await getThreadById(message.threadId);
|
||||||
|
const activeId = thread?.activeRootMessageId ?? messageId;
|
||||||
|
const activeIndex = Math.max(
|
||||||
|
0,
|
||||||
|
rootSiblings.findIndex((s) => s.id === activeId),
|
||||||
|
);
|
||||||
|
return { siblings: rootSiblings, activeIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = await getMessageById(message.parentMessageId);
|
||||||
|
const siblings = await getChildrenOfMessage(message.parentMessageId);
|
||||||
|
|
||||||
|
// The active child is determined by the parent's activeChildId pointer
|
||||||
|
const activeId = parent?.activeChildId ?? messageId;
|
||||||
|
const activeIndex = Math.max(
|
||||||
|
0,
|
||||||
|
siblings.findIndex((s) => s.id === activeId),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { siblings, activeIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk the tree from root to leaf following activeChildId pointers.
|
||||||
|
* Returns the "active path" — the conversation the user currently sees.
|
||||||
|
*/
|
||||||
|
export async function getActivePathFromRoot(
|
||||||
|
threadId: string,
|
||||||
|
): Promise<JobChatMessage[]> {
|
||||||
|
// Load all messages for this thread into memory (fine for typical sizes)
|
||||||
|
const allRows = await db
|
||||||
|
.select()
|
||||||
|
.from(jobChatMessages)
|
||||||
|
.where(eq(jobChatMessages.threadId, threadId))
|
||||||
|
.orderBy(jobChatMessages.createdAt);
|
||||||
|
const all = allRows.map(mapMessage);
|
||||||
|
|
||||||
|
if (all.length === 0) return [];
|
||||||
|
|
||||||
|
// Build lookup maps
|
||||||
|
const byId = new Map<string, JobChatMessage>();
|
||||||
|
const childrenOf = new Map<string, JobChatMessage[]>();
|
||||||
|
|
||||||
|
for (const msg of all) {
|
||||||
|
byId.set(msg.id, msg);
|
||||||
|
const parentId = msg.parentMessageId;
|
||||||
|
if (parentId) {
|
||||||
|
const existing = childrenOf.get(parentId) ?? [];
|
||||||
|
existing.push(msg);
|
||||||
|
childrenOf.set(parentId, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find root(s) — messages with no parent
|
||||||
|
const roots = all.filter((m) => !m.parentMessageId);
|
||||||
|
if (roots.length === 0) {
|
||||||
|
// Fallback for legacy data without parentMessageId backfill
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick the active root: use thread's activeRootMessageId, fall back to newest
|
||||||
|
const thread = await getThreadById(threadId);
|
||||||
|
const preferredRootId = thread?.activeRootMessageId;
|
||||||
|
const activeRoot = preferredRootId
|
||||||
|
? roots.find((r) => r.id === preferredRootId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Walk from root following activeChildId, falling back to newest child
|
||||||
|
const path: JobChatMessage[] = [];
|
||||||
|
let currentMsg: JobChatMessage | undefined =
|
||||||
|
activeRoot ?? roots[roots.length - 1];
|
||||||
|
|
||||||
|
while (currentMsg) {
|
||||||
|
path.push(currentMsg);
|
||||||
|
const children = childrenOf.get(currentMsg.id);
|
||||||
|
if (!children || children.length === 0) break;
|
||||||
|
|
||||||
|
// Follow activeChildId if set, otherwise pick newest
|
||||||
|
const wantId: string | null = currentMsg.activeChildId;
|
||||||
|
const next: JobChatMessage | undefined = wantId
|
||||||
|
? children.find((c) => c.id === wantId)
|
||||||
|
: undefined;
|
||||||
|
currentMsg = next ?? children[children.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk from a message up to the root via parentMessageId.
|
||||||
|
* Returns messages in chronological order (root first).
|
||||||
|
*/
|
||||||
|
export async function getAncestorPath(
|
||||||
|
messageId: string,
|
||||||
|
): Promise<JobChatMessage[]> {
|
||||||
|
const path: JobChatMessage[] = [];
|
||||||
|
let currentId: string | null = messageId;
|
||||||
|
|
||||||
|
while (currentId) {
|
||||||
|
const msg = await getMessageById(currentId);
|
||||||
|
if (!msg) break;
|
||||||
|
path.unshift(msg); // prepend — we're walking backwards
|
||||||
|
currentId = msg.parentMessageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
export async function completeRunIfRunning(
|
export async function completeRunIfRunning(
|
||||||
runId: string,
|
runId: string,
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
@ -18,6 +18,12 @@ const mocks = vi.hoisted(() => ({
|
|||||||
getMessageById: vi.fn(),
|
getMessageById: vi.fn(),
|
||||||
getLatestAssistantMessage: vi.fn(),
|
getLatestAssistantMessage: vi.fn(),
|
||||||
getRunById: vi.fn(),
|
getRunById: vi.fn(),
|
||||||
|
getActivePathFromRoot: vi.fn(),
|
||||||
|
getAncestorPath: vi.fn(),
|
||||||
|
setActiveChild: vi.fn(),
|
||||||
|
setActiveRoot: vi.fn(),
|
||||||
|
getSiblingsOf: vi.fn(),
|
||||||
|
getChildrenOfMessage: vi.fn(),
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
getAllSettings: vi.fn(),
|
getAllSettings: vi.fn(),
|
||||||
@ -57,6 +63,12 @@ vi.mock("../repositories/ghostwriter", () => ({
|
|||||||
getMessageById: mocks.repo.getMessageById,
|
getMessageById: mocks.repo.getMessageById,
|
||||||
getLatestAssistantMessage: mocks.repo.getLatestAssistantMessage,
|
getLatestAssistantMessage: mocks.repo.getLatestAssistantMessage,
|
||||||
getRunById: mocks.repo.getRunById,
|
getRunById: mocks.repo.getRunById,
|
||||||
|
getActivePathFromRoot: mocks.repo.getActivePathFromRoot,
|
||||||
|
getAncestorPath: mocks.repo.getAncestorPath,
|
||||||
|
setActiveChild: mocks.repo.setActiveChild,
|
||||||
|
getSiblingsOf: mocks.repo.getSiblingsOf,
|
||||||
|
getChildrenOfMessage: mocks.repo.getChildrenOfMessage,
|
||||||
|
setActiveRoot: mocks.repo.setActiveRoot,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./llm/service", () => ({
|
vi.mock("./llm/service", () => ({
|
||||||
@ -80,6 +92,7 @@ const thread = {
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
lastMessageAt: null,
|
lastMessageAt: null,
|
||||||
|
activeRootMessageId: "user-1",
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseUserMessage: JobChatMessage = {
|
const baseUserMessage: JobChatMessage = {
|
||||||
@ -93,6 +106,8 @@ const baseUserMessage: JobChatMessage = {
|
|||||||
tokensOut: null,
|
tokensOut: null,
|
||||||
version: 1,
|
version: 1,
|
||||||
replacesMessageId: null,
|
replacesMessageId: null,
|
||||||
|
parentMessageId: null,
|
||||||
|
activeChildId: "assistant-1",
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@ -108,6 +123,8 @@ const baseAssistantMessage: JobChatMessage = {
|
|||||||
tokensOut: 4,
|
tokensOut: 4,
|
||||||
version: 1,
|
version: 1,
|
||||||
replacesMessageId: null,
|
replacesMessageId: null,
|
||||||
|
parentMessageId: "user-1",
|
||||||
|
activeChildId: null,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@ -182,6 +199,20 @@ describe("ghostwriter service", () => {
|
|||||||
status: "failed",
|
status: "failed",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
mocks.repo.getActivePathFromRoot.mockResolvedValue([
|
||||||
|
baseUserMessage,
|
||||||
|
baseAssistantMessage,
|
||||||
|
]);
|
||||||
|
mocks.repo.getAncestorPath.mockResolvedValue([
|
||||||
|
baseUserMessage,
|
||||||
|
baseAssistantMessage,
|
||||||
|
]);
|
||||||
|
mocks.repo.setActiveChild.mockResolvedValue(undefined);
|
||||||
|
mocks.repo.setActiveRoot.mockResolvedValue(undefined);
|
||||||
|
mocks.repo.getSiblingsOf.mockResolvedValue({
|
||||||
|
siblings: [baseAssistantMessage],
|
||||||
|
activeIndex: 0,
|
||||||
|
});
|
||||||
mocks.llmCallJson.mockResolvedValue({
|
mocks.llmCallJson.mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
data: { response: "Thanks for your question." },
|
data: { response: "Thanks for your question." },
|
||||||
@ -328,23 +359,43 @@ describe("ghostwriter service", () => {
|
|||||||
expect(result.assistantMessage?.status).toBe("cancelled");
|
expect(result.assistantMessage?.status).toBe("cancelled");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enforces regenerate only on latest assistant message", async () => {
|
it("regenerates any assistant message, not just the latest", async () => {
|
||||||
mocks.repo.getMessageById.mockResolvedValue(baseAssistantMessage);
|
const assistantPartial: JobChatMessage = {
|
||||||
mocks.repo.getLatestAssistantMessage.mockResolvedValue({
|
|
||||||
...baseAssistantMessage,
|
...baseAssistantMessage,
|
||||||
id: "assistant-latest",
|
id: "assistant-regen",
|
||||||
});
|
content: "",
|
||||||
|
status: "partial",
|
||||||
|
parentMessageId: "user-1",
|
||||||
|
};
|
||||||
|
const assistantComplete: JobChatMessage = {
|
||||||
|
...baseAssistantMessage,
|
||||||
|
id: "assistant-regen",
|
||||||
|
content: "Thanks for your question.",
|
||||||
|
status: "complete",
|
||||||
|
parentMessageId: "user-1",
|
||||||
|
};
|
||||||
|
|
||||||
await expect(
|
mocks.repo.getMessageById
|
||||||
regenerateMessage({
|
.mockResolvedValueOnce(baseAssistantMessage) // target lookup
|
||||||
|
.mockResolvedValueOnce(baseUserMessage) // parent user lookup
|
||||||
|
.mockResolvedValueOnce(assistantComplete); // final lookup after run
|
||||||
|
|
||||||
|
mocks.repo.getAncestorPath.mockResolvedValue([baseUserMessage]);
|
||||||
|
mocks.repo.createMessage.mockResolvedValueOnce(assistantPartial);
|
||||||
|
mocks.repo.updateMessage.mockResolvedValue(assistantComplete);
|
||||||
|
|
||||||
|
const result = await regenerateMessage({
|
||||||
jobId: "job-1",
|
jobId: "job-1",
|
||||||
threadId: "thread-1",
|
threadId: "thread-1",
|
||||||
assistantMessageId: "assistant-1",
|
assistantMessageId: "assistant-1",
|
||||||
}),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
code: "INVALID_REQUEST",
|
|
||||||
status: 400,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(result.runId).toBe("run-1");
|
||||||
|
expect(result.assistantMessage?.id).toBe("assistant-regen");
|
||||||
|
expect(mocks.repo.setActiveChild).toHaveBeenCalledWith(
|
||||||
|
"user-1",
|
||||||
|
"assistant-regen",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns alreadyFinished when cancelling non-running run", async () => {
|
it("returns alreadyFinished when cancelling non-running run", async () => {
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
} from "@infra/errors";
|
} from "@infra/errors";
|
||||||
import { logger } from "@infra/logger";
|
import { logger } from "@infra/logger";
|
||||||
import { getRequestId } from "@infra/request-context";
|
import { getRequestId } from "@infra/request-context";
|
||||||
import type { JobChatMessage, JobChatRun } from "@shared/types";
|
import type { BranchInfo, JobChatMessage, JobChatRun } from "@shared/types";
|
||||||
import * as jobChatRepo from "../repositories/ghostwriter";
|
import * as jobChatRepo from "../repositories/ghostwriter";
|
||||||
import * as settingsRepo from "../repositories/settings";
|
import * as settingsRepo from "../repositories/settings";
|
||||||
import { buildJobChatPromptContext } from "./ghostwriter-context";
|
import { buildJobChatPromptContext } from "./ghostwriter-context";
|
||||||
@ -87,10 +87,13 @@ async function resolveLlmRuntimeSettings(): Promise<LlmRuntimeSettings> {
|
|||||||
|
|
||||||
async function buildConversationMessages(
|
async function buildConversationMessages(
|
||||||
threadId: string,
|
threadId: string,
|
||||||
|
targetMessageId?: string,
|
||||||
): Promise<Array<{ role: "user" | "assistant"; content: string }>> {
|
): Promise<Array<{ role: "user" | "assistant"; content: string }>> {
|
||||||
const messages = await jobChatRepo.listMessagesForThread(threadId, {
|
// If a target message is given, walk its ancestor path (branch-aware).
|
||||||
limit: 40,
|
// Otherwise, fall back to the active path from root.
|
||||||
});
|
const messages = targetMessageId
|
||||||
|
? await jobChatRepo.getAncestorPath(targetMessageId)
|
||||||
|
: await jobChatRepo.getActivePathFromRoot(threadId);
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
.filter(
|
.filter(
|
||||||
@ -98,6 +101,7 @@ async function buildConversationMessages(
|
|||||||
message.role === "user" || message.role === "assistant",
|
message.role === "user" || message.role === "assistant",
|
||||||
)
|
)
|
||||||
.filter((message) => message.status !== "failed")
|
.filter((message) => message.status !== "failed")
|
||||||
|
.slice(-40)
|
||||||
.map((message) => ({
|
.map((message) => ({
|
||||||
role: message.role,
|
role: message.role,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
@ -110,6 +114,8 @@ type GenerateReplyOptions = {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
replaceMessageId?: string;
|
replaceMessageId?: string;
|
||||||
version?: number;
|
version?: number;
|
||||||
|
/** Parent message ID for the assistant reply (i.e. the user message that triggered it). */
|
||||||
|
parentMessageId?: string;
|
||||||
stream?: {
|
stream?: {
|
||||||
onReady: (payload: {
|
onReady: (payload: {
|
||||||
runId: string;
|
runId: string;
|
||||||
@ -158,33 +164,50 @@ export async function listThreads(jobId: string) {
|
|||||||
return [thread];
|
return [thread];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildBranchInfoForPath(
|
||||||
|
messages: JobChatMessage[],
|
||||||
|
): Promise<BranchInfo[]> {
|
||||||
|
const branches: BranchInfo[] = [];
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
const { siblings, activeIndex } = await jobChatRepo.getSiblingsOf(msg.id);
|
||||||
|
if (siblings.length > 1) {
|
||||||
|
branches.push({
|
||||||
|
messageId: msg.id,
|
||||||
|
siblingIds: siblings.map((s) => s.id),
|
||||||
|
activeIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return branches;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listMessages(input: {
|
export async function listMessages(input: {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
}) {
|
}): Promise<{ messages: JobChatMessage[]; branches: BranchInfo[] }> {
|
||||||
const thread = await jobChatRepo.getThreadForJob(input.jobId, input.threadId);
|
const thread = await jobChatRepo.getThreadForJob(input.jobId, input.threadId);
|
||||||
if (!thread) {
|
if (!thread) {
|
||||||
throw notFound("Thread not found for this job");
|
throw notFound("Thread not found for this job");
|
||||||
}
|
}
|
||||||
|
|
||||||
return jobChatRepo.listMessagesForThread(input.threadId, {
|
const messages = await jobChatRepo.getActivePathFromRoot(input.threadId);
|
||||||
limit: input.limit,
|
const branches = await buildBranchInfoForPath(messages);
|
||||||
offset: input.offset,
|
return { messages, branches };
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listMessagesForJob(input: {
|
export async function listMessagesForJob(input: {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
}) {
|
}): Promise<{ messages: JobChatMessage[]; branches: BranchInfo[] }> {
|
||||||
const thread = await ensureJobThread(input.jobId);
|
const thread = await ensureJobThread(input.jobId);
|
||||||
return jobChatRepo.listMessagesForThread(thread.id, {
|
const messages = await jobChatRepo.getActivePathFromRoot(thread.id);
|
||||||
limit: input.limit,
|
const branches = await buildBranchInfoForPath(messages);
|
||||||
offset: input.offset,
|
return { messages, branches };
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAssistantReply(
|
async function runAssistantReply(
|
||||||
@ -206,7 +229,7 @@ async function runAssistantReply(
|
|||||||
const [context, llmConfig, history] = await Promise.all([
|
const [context, llmConfig, history] = await Promise.all([
|
||||||
buildJobChatPromptContext(options.jobId),
|
buildJobChatPromptContext(options.jobId),
|
||||||
resolveLlmRuntimeSettings(),
|
resolveLlmRuntimeSettings(),
|
||||||
buildConversationMessages(options.threadId),
|
buildConversationMessages(options.threadId, options.parentMessageId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const requestId = getRequestId() ?? "unknown";
|
const requestId = getRequestId() ?? "unknown";
|
||||||
@ -237,6 +260,7 @@ async function runAssistantReply(
|
|||||||
status: "partial",
|
status: "partial",
|
||||||
version: options.version ?? 1,
|
version: options.version ?? 1,
|
||||||
replacesMessageId: options.replaceMessageId ?? null,
|
replacesMessageId: options.replaceMessageId ?? null,
|
||||||
|
parentMessageId: options.parentMessageId ?? null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await jobChatRepo.completeRun(run.id, {
|
await jobChatRepo.completeRun(run.id, {
|
||||||
@ -424,6 +448,11 @@ export async function sendMessage(input: {
|
|||||||
throw notFound("Thread not found for this job");
|
throw notFound("Thread not found for this job");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine parent: last message on the current active path
|
||||||
|
const activePath = await jobChatRepo.getActivePathFromRoot(input.threadId);
|
||||||
|
const parentId =
|
||||||
|
activePath.length > 0 ? activePath[activePath.length - 1].id : null;
|
||||||
|
|
||||||
const userMessage = await jobChatRepo.createMessage({
|
const userMessage = await jobChatRepo.createMessage({
|
||||||
threadId: input.threadId,
|
threadId: input.threadId,
|
||||||
jobId: input.jobId,
|
jobId: input.jobId,
|
||||||
@ -432,15 +461,28 @@ export async function sendMessage(input: {
|
|||||||
status: "complete",
|
status: "complete",
|
||||||
tokensIn: estimateTokenCount(content),
|
tokensIn: estimateTokenCount(content),
|
||||||
tokensOut: null,
|
tokensOut: null,
|
||||||
|
parentMessageId: parentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update parent's activeChildId to point to this new user message
|
||||||
|
if (parentId) {
|
||||||
|
await jobChatRepo.setActiveChild(parentId, userMessage.id);
|
||||||
|
} else {
|
||||||
|
// First message in thread — set as active root
|
||||||
|
await jobChatRepo.setActiveRoot(input.threadId, userMessage.id);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await runAssistantReply({
|
const result = await runAssistantReply({
|
||||||
jobId: input.jobId,
|
jobId: input.jobId,
|
||||||
threadId: input.threadId,
|
threadId: input.threadId,
|
||||||
prompt: content,
|
prompt: content,
|
||||||
|
parentMessageId: userMessage.id,
|
||||||
stream: input.stream,
|
stream: input.stream,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update user message's activeChildId to point to the assistant reply
|
||||||
|
await jobChatRepo.setActiveChild(userMessage.id, result.messageId);
|
||||||
|
|
||||||
const assistantMessage = await jobChatRepo.getMessageById(result.messageId);
|
const assistantMessage = await jobChatRepo.getMessageById(result.messageId);
|
||||||
return {
|
return {
|
||||||
userMessage,
|
userMessage,
|
||||||
@ -487,37 +529,49 @@ export async function regenerateMessage(input: {
|
|||||||
throw badRequest("Only assistant messages can be regenerated");
|
throw badRequest("Only assistant messages can be regenerated");
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestAssistant = await jobChatRepo.getLatestAssistantMessage(
|
// Find the parent user message (the user message that prompted this assistant reply).
|
||||||
input.threadId,
|
// With branching, the parent is stored directly in parentMessageId.
|
||||||
|
let parentUserMessage: JobChatMessage | null = null;
|
||||||
|
if (target.parentMessageId) {
|
||||||
|
parentUserMessage = await jobChatRepo.getMessageById(
|
||||||
|
target.parentMessageId,
|
||||||
);
|
);
|
||||||
if (!latestAssistant || latestAssistant.id !== target.id) {
|
|
||||||
throw badRequest("Only the latest assistant message can be regenerated");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback for legacy messages without parentMessageId: walk backwards in time
|
||||||
|
if (!parentUserMessage || parentUserMessage.role !== "user") {
|
||||||
const messages = await jobChatRepo.listMessagesForThread(input.threadId, {
|
const messages = await jobChatRepo.listMessagesForThread(input.threadId, {
|
||||||
limit: 200,
|
limit: 200,
|
||||||
});
|
});
|
||||||
const targetIndex = messages.findIndex((message) => message.id === target.id);
|
const targetIndex = messages.findIndex(
|
||||||
const priorUser =
|
(message) => message.id === target.id,
|
||||||
|
);
|
||||||
|
parentUserMessage =
|
||||||
targetIndex > 0
|
targetIndex > 0
|
||||||
? [...messages.slice(0, targetIndex)]
|
? ([...messages.slice(0, targetIndex)]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((message) => message.role === "user")
|
.find((message) => message.role === "user") ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!priorUser) {
|
if (!parentUserMessage) {
|
||||||
throw badRequest("Could not find a user message to regenerate from");
|
throw badRequest("Could not find a user message to regenerate from");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a new sibling assistant message with the same parent (the user message)
|
||||||
const result = await runAssistantReply({
|
const result = await runAssistantReply({
|
||||||
jobId: input.jobId,
|
jobId: input.jobId,
|
||||||
threadId: input.threadId,
|
threadId: input.threadId,
|
||||||
prompt: priorUser.content,
|
prompt: parentUserMessage.content,
|
||||||
replaceMessageId: target.id,
|
replaceMessageId: target.id,
|
||||||
version: (target.version || 1) + 1,
|
version: (target.version || 1) + 1,
|
||||||
|
parentMessageId: parentUserMessage.id,
|
||||||
stream: input.stream,
|
stream: input.stream,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update parent's activeChildId to the new assistant message (switch to new branch)
|
||||||
|
await jobChatRepo.setActiveChild(parentUserMessage.id, result.messageId);
|
||||||
|
|
||||||
const assistantMessage = await jobChatRepo.getMessageById(result.messageId);
|
const assistantMessage = await jobChatRepo.getMessageById(result.messageId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -540,6 +594,138 @@ export async function regenerateMessageForJob(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function editMessage(input: {
|
||||||
|
jobId: string;
|
||||||
|
threadId: string;
|
||||||
|
messageId: string;
|
||||||
|
content: string;
|
||||||
|
stream?: GenerateReplyOptions["stream"];
|
||||||
|
}) {
|
||||||
|
const content = input.content.trim();
|
||||||
|
if (!content) {
|
||||||
|
throw badRequest("Message content is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const thread = await jobChatRepo.getThreadForJob(input.jobId, input.threadId);
|
||||||
|
if (!thread) {
|
||||||
|
throw notFound("Thread not found for this job");
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await jobChatRepo.getMessageById(input.messageId);
|
||||||
|
if (
|
||||||
|
!target ||
|
||||||
|
target.threadId !== input.threadId ||
|
||||||
|
target.jobId !== input.jobId
|
||||||
|
) {
|
||||||
|
throw notFound("Message not found for this thread");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.role !== "user") {
|
||||||
|
throw badRequest("Only user messages can be edited");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new sibling user message (same parent as the original)
|
||||||
|
const newUserMessage = await jobChatRepo.createMessage({
|
||||||
|
threadId: input.threadId,
|
||||||
|
jobId: input.jobId,
|
||||||
|
role: "user",
|
||||||
|
content,
|
||||||
|
status: "complete",
|
||||||
|
tokensIn: estimateTokenCount(content),
|
||||||
|
tokensOut: null,
|
||||||
|
parentMessageId: target.parentMessageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the grandparent's activeChildId to point to the new user message
|
||||||
|
if (target.parentMessageId) {
|
||||||
|
await jobChatRepo.setActiveChild(target.parentMessageId, newUserMessage.id);
|
||||||
|
} else {
|
||||||
|
// Editing a root message — set the new message as active root
|
||||||
|
await jobChatRepo.setActiveRoot(input.threadId, newUserMessage.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate assistant reply as a child of the new user message
|
||||||
|
const result = await runAssistantReply({
|
||||||
|
jobId: input.jobId,
|
||||||
|
threadId: input.threadId,
|
||||||
|
prompt: content,
|
||||||
|
parentMessageId: newUserMessage.id,
|
||||||
|
stream: input.stream,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update new user message's activeChildId to the assistant reply
|
||||||
|
await jobChatRepo.setActiveChild(newUserMessage.id, result.messageId);
|
||||||
|
|
||||||
|
const assistantMessage = await jobChatRepo.getMessageById(result.messageId);
|
||||||
|
return {
|
||||||
|
userMessage: newUserMessage,
|
||||||
|
assistantMessage,
|
||||||
|
runId: result.runId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editMessageForJob(input: {
|
||||||
|
jobId: string;
|
||||||
|
messageId: string;
|
||||||
|
content: string;
|
||||||
|
stream?: GenerateReplyOptions["stream"];
|
||||||
|
}) {
|
||||||
|
const thread = await ensureJobThread(input.jobId);
|
||||||
|
return editMessage({
|
||||||
|
jobId: input.jobId,
|
||||||
|
threadId: thread.id,
|
||||||
|
messageId: input.messageId,
|
||||||
|
content: input.content,
|
||||||
|
stream: input.stream,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function switchBranch(input: {
|
||||||
|
jobId: string;
|
||||||
|
threadId: string;
|
||||||
|
messageId: string;
|
||||||
|
}): Promise<{ messages: JobChatMessage[]; branches: BranchInfo[] }> {
|
||||||
|
const thread = await jobChatRepo.getThreadForJob(input.jobId, input.threadId);
|
||||||
|
if (!thread) {
|
||||||
|
throw notFound("Thread not found for this job");
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await jobChatRepo.getMessageById(input.messageId);
|
||||||
|
if (
|
||||||
|
!target ||
|
||||||
|
target.threadId !== input.threadId ||
|
||||||
|
target.jobId !== input.jobId
|
||||||
|
) {
|
||||||
|
throw notFound("Message not found for this thread");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.parentMessageId) {
|
||||||
|
// Update the parent's activeChildId to point to this sibling
|
||||||
|
await jobChatRepo.setActiveChild(target.parentMessageId, target.id);
|
||||||
|
} else {
|
||||||
|
// Switching between root messages
|
||||||
|
await jobChatRepo.setActiveRoot(input.threadId, target.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the updated active path
|
||||||
|
return listMessages({
|
||||||
|
jobId: input.jobId,
|
||||||
|
threadId: input.threadId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function switchBranchForJob(input: {
|
||||||
|
jobId: string;
|
||||||
|
messageId: string;
|
||||||
|
}): Promise<{ messages: JobChatMessage[]; branches: BranchInfo[] }> {
|
||||||
|
const thread = await ensureJobThread(input.jobId);
|
||||||
|
return switchBranch({
|
||||||
|
jobId: input.jobId,
|
||||||
|
threadId: thread.id,
|
||||||
|
messageId: input.messageId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function cancelRun(input: {
|
export async function cancelRun(input: {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
|||||||
@ -28,5 +28,8 @@
|
|||||||
"knip": "^5.83.1",
|
"knip": "^5.83.1",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"volta": {
|
||||||
|
"node": "22.22.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,7 @@ export interface JobChatThread {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lastMessageAt: string | null;
|
lastMessageAt: string | null;
|
||||||
|
activeRootMessageId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JobChatMessage {
|
export interface JobChatMessage {
|
||||||
@ -42,10 +43,21 @@ export interface JobChatMessage {
|
|||||||
tokensOut: number | null;
|
tokensOut: number | null;
|
||||||
version: number;
|
version: number;
|
||||||
replacesMessageId: string | null;
|
replacesMessageId: string | null;
|
||||||
|
parentMessageId: string | null;
|
||||||
|
activeChildId: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BranchInfo {
|
||||||
|
/** The message ID this branch info belongs to (the currently active sibling). */
|
||||||
|
messageId: string;
|
||||||
|
/** Ordered sibling IDs at this branch point (by createdAt). */
|
||||||
|
siblingIds: string[];
|
||||||
|
/** 0-based index of the active sibling within siblingIds. */
|
||||||
|
activeIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface JobChatRun {
|
export interface JobChatRun {
|
||||||
id: string;
|
id: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user