mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-14 22:52:15 +02:00
feat: implement new chat feature with message persistence and UI integration
This commit is contained in:
parent
f115980d2b
commit
0c3574d049
16 changed files with 1814 additions and 397 deletions
|
|
@ -47,6 +47,9 @@ export function DashboardClientLayout({
|
|||
// Check if we're on the researcher page
|
||||
const isResearcherPage = pathname?.includes("/researcher");
|
||||
|
||||
// Check if we're on the new-chat page (uses separate thread persistence)
|
||||
const isNewChatPage = pathname?.includes("/new-chat");
|
||||
|
||||
// Show indicator when chat becomes active and panel is closed
|
||||
useEffect(() => {
|
||||
if (activeChatId && !isChatPannelOpen) {
|
||||
|
|
@ -151,6 +154,12 @@ export function DashboardClientLayout({
|
|||
}, [search_space_id]);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip setting activeChatIdAtom on new-chat page (uses separate thread persistence)
|
||||
if (isNewChatPage) {
|
||||
setActiveChatIdState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeChatId =
|
||||
typeof chat_id === "string"
|
||||
? chat_id
|
||||
|
|
@ -159,7 +168,7 @@ export function DashboardClientLayout({
|
|||
: "";
|
||||
if (!activeChatId) return;
|
||||
setActiveChatIdState(activeChatId);
|
||||
}, [chat_id, search_space_id]);
|
||||
}, [chat_id, search_space_id, isNewChatPage]);
|
||||
|
||||
// Show loading screen while checking onboarding status (only on first load)
|
||||
if (!hasCheckedOnboarding && (loading || accessLoading) && !isOnboardingPage) {
|
||||
|
|
|
|||
|
|
@ -1,23 +1,73 @@
|
|||
"use client";
|
||||
|
||||
import { AssistantRuntimeProvider, useLocalRuntime } from "@assistant-ui/react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
AssistantRuntimeProvider,
|
||||
useExternalStoreRuntime,
|
||||
type ThreadMessageLike,
|
||||
} from "@assistant-ui/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Thread } from "@/components/assistant-ui/thread";
|
||||
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
||||
import { createNewChatAdapter } from "@/lib/chat/new-chat-transport";
|
||||
import {
|
||||
createThread,
|
||||
getThreadMessages,
|
||||
appendMessage,
|
||||
type MessageRecord,
|
||||
} from "@/lib/chat/thread-persistence";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
isPodcastGenerating,
|
||||
looksLikePodcastRequest,
|
||||
setActivePodcastTaskId,
|
||||
} from "@/lib/chat/podcast-state";
|
||||
|
||||
/**
|
||||
* Convert backend message to assistant-ui ThreadMessageLike format
|
||||
*/
|
||||
function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
||||
let content: ThreadMessageLike["content"];
|
||||
|
||||
if (typeof msg.content === "string") {
|
||||
content = [{ type: "text", text: msg.content }];
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
content = msg.content as ThreadMessageLike["content"];
|
||||
} else {
|
||||
content = [{ type: "text", text: String(msg.content) }];
|
||||
}
|
||||
|
||||
return {
|
||||
id: `msg-${msg.id}`,
|
||||
role: msg.role,
|
||||
content,
|
||||
createdAt: new Date(msg.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools that should render custom UI in the chat.
|
||||
*/
|
||||
const TOOLS_WITH_UI = new Set(["generate_podcast"]);
|
||||
|
||||
export default function NewChatPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
const [threadId, setThreadId] = useState<number | null>(null);
|
||||
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Extract search_space_id and chat_id from URL params
|
||||
// Extract search_space_id from URL params
|
||||
const searchSpaceId = useMemo(() => {
|
||||
const id = params.search_space_id;
|
||||
const parsed = typeof id === "string" ? Number.parseInt(id, 10) : 0;
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}, [params.search_space_id]);
|
||||
|
||||
const chatId = useMemo(() => {
|
||||
// Extract chat_id from URL params
|
||||
const urlChatId = useMemo(() => {
|
||||
const id = params.chat_id;
|
||||
let parsed = 0;
|
||||
if (Array.isArray(id) && id.length > 0) {
|
||||
|
|
@ -28,18 +78,368 @@ export default function NewChatPage() {
|
|||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}, [params.chat_id]);
|
||||
|
||||
// Create the adapter with the extracted params
|
||||
const adapter = useMemo(
|
||||
() => createNewChatAdapter({ searchSpaceId, chatId }),
|
||||
[searchSpaceId, chatId]
|
||||
// Initialize thread and load messages
|
||||
const initializeThread = useCallback(async () => {
|
||||
setIsInitializing(true);
|
||||
|
||||
try {
|
||||
if (urlChatId > 0) {
|
||||
// Thread exists - load messages
|
||||
setThreadId(urlChatId);
|
||||
const response = await getThreadMessages(urlChatId);
|
||||
if (response.messages && response.messages.length > 0) {
|
||||
const loadedMessages = response.messages.map(convertToThreadMessage);
|
||||
setMessages(loadedMessages);
|
||||
}
|
||||
} else {
|
||||
// Create new thread
|
||||
const newThread = await createThread(searchSpaceId, "New Chat");
|
||||
setThreadId(newThread.id);
|
||||
router.replace(`/dashboard/${searchSpaceId}/new-chat/${newThread.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[NewChatPage] Failed to initialize thread:", error);
|
||||
setThreadId(Date.now());
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
}, [urlChatId, searchSpaceId, router]);
|
||||
|
||||
// Initialize on mount
|
||||
useEffect(() => {
|
||||
initializeThread();
|
||||
}, [initializeThread]);
|
||||
|
||||
// Cancel ongoing request
|
||||
const cancelRun = useCallback(async () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
setIsRunning(false);
|
||||
}, []);
|
||||
|
||||
// Handle new message from user
|
||||
const onNew = useCallback(
|
||||
async (message: ThreadMessageLike) => {
|
||||
if (!threadId) return;
|
||||
|
||||
// Extract user query text
|
||||
let userQuery = "";
|
||||
for (const part of message.content) {
|
||||
if (typeof part === "object" && part.type === "text" && "text" in part) {
|
||||
userQuery += part.text;
|
||||
}
|
||||
}
|
||||
|
||||
if (!userQuery.trim()) return;
|
||||
|
||||
// Check if podcast is already generating
|
||||
if (isPodcastGenerating() && looksLikePodcastRequest(userQuery)) {
|
||||
toast.warning("A podcast is already being generated.");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
toast.error("Not authenticated. Please log in again.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Add user message to state
|
||||
const userMsgId = `msg-user-${Date.now()}`;
|
||||
const userMessage: ThreadMessageLike = {
|
||||
id: userMsgId,
|
||||
role: "user",
|
||||
content: message.content,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
|
||||
// Persist user message (don't await, fire and forget)
|
||||
appendMessage(threadId, {
|
||||
role: "user",
|
||||
content: message.content,
|
||||
}).catch((err) => console.error("Failed to persist user message:", err));
|
||||
|
||||
// Start streaming response
|
||||
setIsRunning(true);
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
// Prepare assistant message
|
||||
const assistantMsgId = `msg-assistant-${Date.now()}`;
|
||||
let accumulatedText = "";
|
||||
const toolCalls = new Map<
|
||||
string,
|
||||
{
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
}
|
||||
>();
|
||||
|
||||
// Helper to build content
|
||||
const buildContent = (): ThreadMessageLike["content"] => {
|
||||
const parts: Array<
|
||||
| { type: "text"; text: string }
|
||||
| {
|
||||
type: "tool-call";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
}
|
||||
> = [];
|
||||
if (accumulatedText) {
|
||||
parts.push({ type: "text", text: accumulatedText });
|
||||
}
|
||||
for (const toolCall of toolCalls.values()) {
|
||||
if (TOOLS_WITH_UI.has(toolCall.toolName)) {
|
||||
parts.push({
|
||||
type: "tool-call",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
args: toolCall.args,
|
||||
result: toolCall.result,
|
||||
});
|
||||
}
|
||||
}
|
||||
return parts.length > 0
|
||||
? (parts as ThreadMessageLike["content"])
|
||||
: [{ type: "text", text: "" }];
|
||||
};
|
||||
|
||||
// Add placeholder assistant message
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: assistantMsgId,
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "" }],
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
const backendUrl =
|
||||
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||
|
||||
// Build message history for context
|
||||
const messageHistory = messages
|
||||
.filter((m) => m.role === "user" || m.role === "assistant")
|
||||
.map((m) => {
|
||||
let text = "";
|
||||
for (const part of m.content) {
|
||||
if (
|
||||
typeof part === "object" &&
|
||||
part.type === "text" &&
|
||||
"text" in part
|
||||
) {
|
||||
text += part.text;
|
||||
}
|
||||
}
|
||||
return { role: m.role, content: text };
|
||||
})
|
||||
.filter((m) => m.content.length > 0);
|
||||
|
||||
const response = await fetch(`${backendUrl}/api/v1/new_chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_id: threadId,
|
||||
user_query: userQuery.trim(),
|
||||
search_space_id: searchSpaceId,
|
||||
messages: messageHistory,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Backend error: ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("No response body");
|
||||
}
|
||||
|
||||
// Parse SSE stream
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const events = buffer.split(/\r?\n\r?\n/);
|
||||
buffer = events.pop() || "";
|
||||
|
||||
for (const event of events) {
|
||||
const lines = event.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const data = line.slice(6).trim();
|
||||
if (!data || data === "[DONE]") continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
switch (parsed.type) {
|
||||
case "text-delta":
|
||||
accumulatedText += parsed.delta;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId
|
||||
? { ...m, content: buildContent() }
|
||||
: m
|
||||
)
|
||||
);
|
||||
break;
|
||||
|
||||
case "tool-input-start":
|
||||
toolCalls.set(parsed.toolCallId, {
|
||||
toolCallId: parsed.toolCallId,
|
||||
toolName: parsed.toolName,
|
||||
args: {},
|
||||
});
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId
|
||||
? { ...m, content: buildContent() }
|
||||
: m
|
||||
)
|
||||
);
|
||||
break;
|
||||
|
||||
case "tool-input-available": {
|
||||
const tc = toolCalls.get(parsed.toolCallId);
|
||||
if (tc) tc.args = parsed.input || {};
|
||||
else
|
||||
toolCalls.set(parsed.toolCallId, {
|
||||
toolCallId: parsed.toolCallId,
|
||||
toolName: parsed.toolName,
|
||||
args: parsed.input || {},
|
||||
});
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId
|
||||
? { ...m, content: buildContent() }
|
||||
: m
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool-output-available": {
|
||||
const tc = toolCalls.get(parsed.toolCallId);
|
||||
if (tc) {
|
||||
tc.result = parsed.output;
|
||||
if (
|
||||
tc.toolName === "generate_podcast" &&
|
||||
parsed.output?.status === "processing" &&
|
||||
parsed.output?.task_id
|
||||
) {
|
||||
setActivePodcastTaskId(parsed.output.task_id);
|
||||
}
|
||||
}
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId
|
||||
? { ...m, content: buildContent() }
|
||||
: m
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "error":
|
||||
throw new Error(parsed.errorText || "Server error");
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) continue;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
// Persist assistant message
|
||||
const finalContent = buildContent();
|
||||
if (accumulatedText || toolCalls.size > 0) {
|
||||
appendMessage(threadId, {
|
||||
role: "assistant",
|
||||
content: finalContent,
|
||||
}).catch((err) =>
|
||||
console.error("Failed to persist assistant message:", err)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
// Request was cancelled
|
||||
return;
|
||||
}
|
||||
console.error("[NewChatPage] Chat error:", error);
|
||||
toast.error("Failed to get response. Please try again.");
|
||||
// Update assistant message with error
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId
|
||||
? {
|
||||
...m,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Sorry, there was an error. Please try again.",
|
||||
},
|
||||
],
|
||||
}
|
||||
: m
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[threadId, searchSpaceId, messages]
|
||||
);
|
||||
|
||||
// Use LocalRuntime with our custom adapter
|
||||
const runtime = useLocalRuntime(adapter);
|
||||
// Convert message (pass through since already in correct format)
|
||||
const convertMessage = useCallback(
|
||||
(message: ThreadMessageLike): ThreadMessageLike => message,
|
||||
[]
|
||||
);
|
||||
|
||||
// Create external store runtime
|
||||
const runtime = useExternalStoreRuntime({
|
||||
messages,
|
||||
isRunning,
|
||||
onNew,
|
||||
convertMessage,
|
||||
onCancel: cancelRun,
|
||||
});
|
||||
|
||||
// Show loading state
|
||||
if (isInitializing) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-64px)] items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading chat...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
{/* Register tool UI components */}
|
||||
<GeneratePodcastToolUI />
|
||||
<div className="h-[calc(100vh-64px)] max-h-[calc(100vh-64px)] overflow-hidden">
|
||||
<Thread />
|
||||
|
|
|
|||
286
surfsense_web/components/assistant-ui/thread-list.tsx
Normal file
286
surfsense_web/components/assistant-ui/thread-list.tsx
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArchiveIcon, MessageSquareIcon, PlusIcon, TrashIcon, MoreVerticalIcon, RotateCcwIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
type ThreadListItem,
|
||||
createThreadListManager,
|
||||
type ThreadListState,
|
||||
} from "@/lib/chat/thread-persistence";
|
||||
|
||||
interface ThreadListProps {
|
||||
searchSpaceId: number;
|
||||
currentThreadId?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ThreadList({ searchSpaceId, currentThreadId, className }: ThreadListProps) {
|
||||
const router = useRouter();
|
||||
const [state, setState] = useState<ThreadListState>({
|
||||
threads: [],
|
||||
archivedThreads: [],
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
// Create the thread list manager
|
||||
const manager = useCallback(
|
||||
() =>
|
||||
createThreadListManager({
|
||||
searchSpaceId,
|
||||
currentThreadId: currentThreadId ?? null,
|
||||
onThreadSwitch: (threadId) => {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`);
|
||||
},
|
||||
onNewThread: (threadId) => {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`);
|
||||
},
|
||||
}),
|
||||
[searchSpaceId, currentThreadId, router]
|
||||
);
|
||||
|
||||
// Load threads on mount and when searchSpaceId changes
|
||||
const loadThreads = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
const newState = await manager().loadThreads();
|
||||
setState(newState);
|
||||
}, [manager]);
|
||||
|
||||
useEffect(() => {
|
||||
loadThreads();
|
||||
}, [loadThreads]);
|
||||
|
||||
// Handle new thread creation
|
||||
const handleNewThread = async () => {
|
||||
await manager().createNewThread();
|
||||
await loadThreads();
|
||||
};
|
||||
|
||||
// Handle thread actions
|
||||
const handleArchive = async (threadId: number) => {
|
||||
const success = await manager().archiveThread(threadId);
|
||||
if (success) await loadThreads();
|
||||
};
|
||||
|
||||
const handleUnarchive = async (threadId: number) => {
|
||||
const success = await manager().unarchiveThread(threadId);
|
||||
if (success) await loadThreads();
|
||||
};
|
||||
|
||||
const handleDelete = async (threadId: number) => {
|
||||
const success = await manager().deleteThread(threadId);
|
||||
if (success) {
|
||||
await loadThreads();
|
||||
// If we deleted the current thread, redirect to new chat
|
||||
if (threadId === currentThreadId) {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchToThread = (threadId: number) => {
|
||||
manager().switchToThread(threadId);
|
||||
};
|
||||
|
||||
const displayedThreads = showArchived ? state.archivedThreads : state.threads;
|
||||
|
||||
if (state.isLoading) {
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<span className="text-muted-foreground text-sm">Loading threads...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
<div className="p-4 text-center">
|
||||
<span className="text-destructive text-sm">{state.error}</span>
|
||||
<Button variant="ghost" size="sm" className="mt-2" onClick={loadThreads}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
{/* Header with New Chat button */}
|
||||
<div className="flex items-center justify-between border-b p-3">
|
||||
<h2 className="font-semibold text-sm">Conversations</h2>
|
||||
<Button variant="ghost" size="icon" className="size-8" onClick={handleNewThread} title="New Chat">
|
||||
<PlusIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tab toggle for active/archived */}
|
||||
<div className="flex border-b">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchived(false)}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
|
||||
!showArchived
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Active ({state.threads.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchived(true)}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
|
||||
showArchived
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Archived ({state.archivedThreads.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Thread list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{displayedThreads.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center p-6 text-center">
|
||||
<MessageSquareIcon className="mb-2 size-8 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{showArchived ? "No archived conversations" : "No conversations yet"}
|
||||
</p>
|
||||
{!showArchived && (
|
||||
<Button variant="outline" size="sm" className="mt-3" onClick={handleNewThread}>
|
||||
<PlusIcon className="mr-1 size-3" />
|
||||
Start a conversation
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 p-2">
|
||||
{displayedThreads.map((thread) => (
|
||||
<ThreadListItemComponent
|
||||
key={thread.id}
|
||||
thread={thread}
|
||||
isActive={thread.id === currentThreadId}
|
||||
isArchived={showArchived}
|
||||
onClick={() => handleSwitchToThread(thread.id)}
|
||||
onArchive={() => handleArchive(thread.id)}
|
||||
onUnarchive={() => handleUnarchive(thread.id)}
|
||||
onDelete={() => handleDelete(thread.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ThreadListItemComponentProps {
|
||||
thread: ThreadListItem;
|
||||
isActive: boolean;
|
||||
isArchived: boolean;
|
||||
onClick: () => void;
|
||||
onArchive: () => void;
|
||||
onUnarchive: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function ThreadListItemComponent({
|
||||
thread,
|
||||
isActive,
|
||||
isArchived,
|
||||
onClick,
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
onDelete,
|
||||
}: ThreadListItemComponentProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-lg px-3 py-2 transition-colors cursor-pointer",
|
||||
isActive ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
|
||||
)}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") onClick();
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-medium">{thread.title || "New Chat"}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{formatRelativeTime(new Date(thread.updatedAt))}
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVerticalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{isArchived ? (
|
||||
<DropdownMenuItem onClick={onUnarchive}>
|
||||
<RotateCcwIcon className="mr-2 size-4" />
|
||||
Unarchive
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={onArchive}>
|
||||
<ArchiveIcon className="mr-2 size-4" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
||||
<TrashIcon className="mr-2 size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date as relative time (e.g., "2 hours ago", "Yesterday")
|
||||
*/
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffSecs < 60) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins} min${diffMins === 1 ? "" : "s"} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`;
|
||||
if (diffDays === 1) return "Yesterday";
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
|
@ -1,345 +0,0 @@
|
|||
/**
|
||||
* Custom ChatModelAdapter for the new-chat feature using LocalRuntime.
|
||||
* Connects directly to the FastAPI backend using the Vercel AI SDK Data Stream Protocol.
|
||||
*/
|
||||
|
||||
import type { ChatModelAdapter, ChatModelRunOptions } from "@assistant-ui/react";
|
||||
import { toast } from "sonner";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import {
|
||||
isPodcastGenerating,
|
||||
looksLikePodcastRequest,
|
||||
setActivePodcastTaskId,
|
||||
} from "@/lib/chat/podcast-state";
|
||||
|
||||
interface NewChatAdapterConfig {
|
||||
searchSpaceId: number;
|
||||
chatId: number;
|
||||
}
|
||||
|
||||
interface ChatMessageForBackend {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts assistant-ui messages to a simple format for the backend
|
||||
*/
|
||||
function convertMessagesToBackendFormat(
|
||||
messages: ChatModelRunOptions["messages"]
|
||||
): ChatMessageForBackend[] {
|
||||
return messages
|
||||
.filter((m) => m.role === "user" || m.role === "assistant")
|
||||
.map((m) => {
|
||||
// Extract text content from the message parts
|
||||
let content = "";
|
||||
for (const part of m.content) {
|
||||
if (part.type === "text") {
|
||||
content += part.text;
|
||||
}
|
||||
}
|
||||
return {
|
||||
role: m.role as "user" | "assistant",
|
||||
content: content.trim(),
|
||||
};
|
||||
})
|
||||
.filter((m) => m.content.length > 0); // Filter out empty messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an in-progress or completed tool call
|
||||
*/
|
||||
interface ToolCallState {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools that should render custom UI in the chat.
|
||||
* Other tools (like search_knowledge_base) will be hidden from the UI.
|
||||
*/
|
||||
const TOOLS_WITH_UI = new Set(["generate_podcast"]);
|
||||
|
||||
/**
|
||||
* Creates a ChatModelAdapter that connects to the FastAPI new_chat endpoint.
|
||||
*
|
||||
* The backend expects:
|
||||
* - POST /api/v1/new_chat
|
||||
* - Body: { chat_id: number, user_query: string, search_space_id: number, messages: [...] }
|
||||
* - Returns: SSE stream with Vercel AI SDK Data Stream Protocol
|
||||
*/
|
||||
export function createNewChatAdapter(config: NewChatAdapterConfig): ChatModelAdapter {
|
||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||
|
||||
return {
|
||||
async *run({ messages, abortSignal }: ChatModelRunOptions) {
|
||||
// Get the last user message
|
||||
const lastUserMessage = messages.filter((m) => m.role === "user").pop();
|
||||
|
||||
if (!lastUserMessage) {
|
||||
throw new Error("No user message found");
|
||||
}
|
||||
|
||||
// Extract text content from the last user message
|
||||
let userQuery = "";
|
||||
for (const part of lastUserMessage.content) {
|
||||
if (part.type === "text") {
|
||||
userQuery += part.text;
|
||||
}
|
||||
}
|
||||
|
||||
if (!userQuery.trim()) {
|
||||
throw new Error("User query cannot be empty");
|
||||
}
|
||||
|
||||
// Check if user is requesting a podcast while one is already generating
|
||||
if (isPodcastGenerating() && looksLikePodcastRequest(userQuery)) {
|
||||
toast.warning("A podcast is already being generated. Please wait for it to complete.");
|
||||
// Return a message telling the user to wait
|
||||
yield {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "A podcast is already being generated. Please wait for it to complete before requesting another one.",
|
||||
},
|
||||
],
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
throw new Error("Not authenticated. Please log in again.");
|
||||
}
|
||||
|
||||
// Convert all messages to backend format for chat history
|
||||
const messageHistory = convertMessagesToBackendFormat(messages);
|
||||
|
||||
const response = await fetch(`${backendUrl}/api/v1/new_chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_id: config.chatId,
|
||||
user_query: userQuery.trim(),
|
||||
search_space_id: config.searchSpaceId,
|
||||
messages: messageHistory,
|
||||
}),
|
||||
signal: abortSignal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "Unknown error");
|
||||
throw new Error(`Backend error (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("No response body");
|
||||
}
|
||||
|
||||
// Parse the SSE stream (Vercel AI SDK Data Stream Protocol)
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let accumulatedText = "";
|
||||
|
||||
// Track tool calls by their ID
|
||||
const toolCalls = new Map<string, ToolCallState>();
|
||||
|
||||
/**
|
||||
* Build the content array with text and tool calls.
|
||||
* Only includes tools that have custom UI (defined in TOOLS_WITH_UI).
|
||||
*/
|
||||
function buildContent() {
|
||||
const content: Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "tool-call"; toolCallId: string; toolName: string; args: Record<string, unknown>; result?: unknown }
|
||||
> = [];
|
||||
|
||||
// Add text content if any
|
||||
if (accumulatedText) {
|
||||
content.push({ type: "text" as const, text: accumulatedText });
|
||||
}
|
||||
|
||||
// Only add tool calls that have custom UI registered
|
||||
// Other tools (like search_knowledge_base) are hidden from the UI
|
||||
for (const toolCall of toolCalls.values()) {
|
||||
if (TOOLS_WITH_UI.has(toolCall.toolName)) {
|
||||
content.push({
|
||||
type: "tool-call" as const,
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
args: toolCall.args,
|
||||
result: toolCall.result,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
buffer += chunk;
|
||||
|
||||
// Split on double newlines (handle both \n\n and \r\n\r\n)
|
||||
const events = buffer.split(/\r?\n\r?\n/);
|
||||
buffer = events.pop() || "";
|
||||
|
||||
for (const event of events) {
|
||||
// Each event can have multiple lines, find the data line
|
||||
const lines = event.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
|
||||
const data = line.slice(6).trim(); // Remove "data: " prefix
|
||||
|
||||
// Handle [DONE] marker
|
||||
if (data === "[DONE]") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!data) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
// Handle different message types from the Data Stream Protocol
|
||||
switch (parsed.type) {
|
||||
case "text-delta":
|
||||
accumulatedText += parsed.delta;
|
||||
yield { content: buildContent() };
|
||||
break;
|
||||
|
||||
case "tool-input-start": {
|
||||
// Tool call is starting - create a new tool call entry
|
||||
const { toolCallId, toolName } = parsed;
|
||||
toolCalls.set(toolCallId, {
|
||||
toolCallId,
|
||||
toolName,
|
||||
args: {},
|
||||
});
|
||||
// Yield to show tool is starting (running state)
|
||||
yield { content: buildContent() };
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool-input-available": {
|
||||
// Tool input is complete - update the args
|
||||
const { toolCallId, toolName, input } = parsed;
|
||||
const existing = toolCalls.get(toolCallId);
|
||||
if (existing) {
|
||||
existing.args = input || {};
|
||||
} else {
|
||||
// Create new entry if we missed tool-input-start
|
||||
toolCalls.set(toolCallId, {
|
||||
toolCallId,
|
||||
toolName,
|
||||
args: input || {},
|
||||
});
|
||||
}
|
||||
yield { content: buildContent() };
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool-output-available": {
|
||||
// Tool execution is complete - add the result
|
||||
const { toolCallId, output } = parsed;
|
||||
const existing = toolCalls.get(toolCallId);
|
||||
if (existing) {
|
||||
existing.result = output;
|
||||
|
||||
// If this is a podcast tool with status="processing", set the state immediately
|
||||
// This ensures subsequent podcast requests are intercepted
|
||||
if (
|
||||
existing.toolName === "generate_podcast" &&
|
||||
output &&
|
||||
typeof output === "object" &&
|
||||
"status" in output &&
|
||||
output.status === "processing" &&
|
||||
"task_id" in output &&
|
||||
typeof output.task_id === "string"
|
||||
) {
|
||||
setActivePodcastTaskId(output.task_id);
|
||||
}
|
||||
}
|
||||
yield { content: buildContent() };
|
||||
break;
|
||||
}
|
||||
|
||||
case "error":
|
||||
throw new Error(parsed.errorText || "Unknown error from server");
|
||||
|
||||
// Other types like text-start, text-end, start-step, finish-step, etc.
|
||||
// are handled implicitly
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip non-JSON lines
|
||||
if (e instanceof SyntaxError) {
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle any remaining buffer
|
||||
if (buffer.trim()) {
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6).trim();
|
||||
if (data && data !== "[DONE]") {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed.type === "text-delta") {
|
||||
accumulatedText += parsed.delta;
|
||||
yield { content: buildContent() };
|
||||
} else if (parsed.type === "tool-output-available") {
|
||||
const { toolCallId, output } = parsed;
|
||||
const existing = toolCalls.get(toolCallId);
|
||||
if (existing) {
|
||||
existing.result = output;
|
||||
|
||||
// Set podcast state if processing
|
||||
if (
|
||||
existing.toolName === "generate_podcast" &&
|
||||
output &&
|
||||
typeof output === "object" &&
|
||||
"status" in output &&
|
||||
output.status === "processing" &&
|
||||
"task_id" in output &&
|
||||
typeof output.task_id === "string"
|
||||
) {
|
||||
setActivePodcastTaskId(output.task_id);
|
||||
}
|
||||
}
|
||||
yield { content: buildContent() };
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
223
surfsense_web/lib/chat/thread-persistence.ts
Normal file
223
surfsense_web/lib/chat/thread-persistence.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/**
|
||||
* Thread persistence utilities for the new chat feature.
|
||||
* Provides API functions and thread list management.
|
||||
*/
|
||||
|
||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||
|
||||
// =============================================================================
|
||||
// Types matching backend schemas
|
||||
// =============================================================================
|
||||
|
||||
export interface ThreadRecord {
|
||||
id: number;
|
||||
title: string;
|
||||
archived: boolean;
|
||||
search_space_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MessageRecord {
|
||||
id: number;
|
||||
thread_id: number;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: unknown;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ThreadListResponse {
|
||||
threads: ThreadListItem[];
|
||||
archived_threads: ThreadListItem[];
|
||||
}
|
||||
|
||||
export interface ThreadListItem {
|
||||
id: number;
|
||||
title: string;
|
||||
archived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ThreadHistoryLoadResponse {
|
||||
messages: MessageRecord[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Service Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Fetch list of threads for a search space
|
||||
*/
|
||||
export async function fetchThreads(
|
||||
searchSpaceId: number
|
||||
): Promise<ThreadListResponse> {
|
||||
return baseApiService.get<ThreadListResponse>(
|
||||
`/api/v1/threads?search_space_id=${searchSpaceId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new thread
|
||||
*/
|
||||
export async function createThread(
|
||||
searchSpaceId: number,
|
||||
title = "New Chat"
|
||||
): Promise<ThreadRecord> {
|
||||
return baseApiService.post<ThreadRecord>("/api/v1/threads", undefined, {
|
||||
body: {
|
||||
title,
|
||||
archived: false,
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thread messages
|
||||
*/
|
||||
export async function getThreadMessages(
|
||||
threadId: number
|
||||
): Promise<ThreadHistoryLoadResponse> {
|
||||
return baseApiService.get<ThreadHistoryLoadResponse>(
|
||||
`/api/v1/threads/${threadId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a message to a thread
|
||||
*/
|
||||
export async function appendMessage(
|
||||
threadId: number,
|
||||
message: { role: "user" | "assistant" | "system"; content: unknown }
|
||||
): Promise<MessageRecord> {
|
||||
return baseApiService.post<MessageRecord>(
|
||||
`/api/v1/threads/${threadId}/messages`,
|
||||
undefined,
|
||||
{ body: message }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update thread (rename, archive)
|
||||
*/
|
||||
export async function updateThread(
|
||||
threadId: number,
|
||||
updates: { title?: string; archived?: boolean }
|
||||
): Promise<ThreadRecord> {
|
||||
return baseApiService.put<ThreadRecord>(
|
||||
`/api/v1/threads/${threadId}`,
|
||||
undefined,
|
||||
{ body: updates }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a thread
|
||||
*/
|
||||
export async function deleteThread(threadId: number): Promise<void> {
|
||||
await baseApiService.delete(`/api/v1/threads/${threadId}`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Thread List Manager (for thread list sidebar)
|
||||
// =============================================================================
|
||||
|
||||
export interface ThreadListAdapterConfig {
|
||||
searchSpaceId: number;
|
||||
currentThreadId: number | null;
|
||||
onThreadSwitch: (threadId: number) => void;
|
||||
onNewThread: (threadId: number) => void;
|
||||
}
|
||||
|
||||
export interface ThreadListState {
|
||||
threads: ThreadListItem[];
|
||||
archivedThreads: ThreadListItem[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a thread list management object.
|
||||
* This provides methods to manage the thread list for the sidebar.
|
||||
*/
|
||||
export function createThreadListManager(config: ThreadListAdapterConfig) {
|
||||
return {
|
||||
async loadThreads(): Promise<ThreadListState> {
|
||||
try {
|
||||
const response = await fetchThreads(config.searchSpaceId);
|
||||
return {
|
||||
threads: response.threads,
|
||||
archivedThreads: response.archived_threads,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[ThreadListManager] Failed to load threads:", error);
|
||||
return {
|
||||
threads: [],
|
||||
archivedThreads: [],
|
||||
isLoading: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to load threads",
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
async createNewThread(title = "New Chat"): Promise<number | null> {
|
||||
try {
|
||||
const thread = await createThread(config.searchSpaceId, title);
|
||||
config.onNewThread(thread.id);
|
||||
return thread.id;
|
||||
} catch (error) {
|
||||
console.error("[ThreadListManager] Failed to create thread:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
switchToThread(threadId: number) {
|
||||
config.onThreadSwitch(threadId);
|
||||
},
|
||||
|
||||
async renameThread(threadId: number, newTitle: string): Promise<boolean> {
|
||||
try {
|
||||
await updateThread(threadId, { title: newTitle });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[ThreadListManager] Failed to rename thread:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async archiveThread(threadId: number): Promise<boolean> {
|
||||
try {
|
||||
await updateThread(threadId, { archived: true });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[ThreadListManager] Failed to archive thread:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async unarchiveThread(threadId: number): Promise<boolean> {
|
||||
try {
|
||||
await updateThread(threadId, { archived: false });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[ThreadListManager] Failed to unarchive thread:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteThread(threadId: number): Promise<boolean> {
|
||||
try {
|
||||
await deleteThread(threadId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[ThreadListManager] Failed to delete thread:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue