diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index aa45c4c9..502ae374 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -73,10 +73,16 @@ import { } from "./attachments" import { cacheFileBlob, removeCachedFile } from "@/lib/file-cache" import { ReasoningSelector } from "./reasoning-selector" +import { + ResearchModeSelector, + type NovaChatMode, +} from "./research-mode-selector" +import { ResearchProgress } from "./research-progress" import { type ChatThreadSettings, readChatThreadSettings, } from "@/lib/chat-thread-settings" +import { isActiveResearchRun, type NovaResearchRun } from "@/lib/nova-research" type ChatMessageSendSource = "typed" | "suggested" | "highlight" | "home" @@ -216,6 +222,11 @@ export function ChatSidebar({ initialReasoningEffort ?? getDefaultReasoningEffort(initialSelectedModel ?? "grok-4.5"), ) + const [chatMode, setChatMode] = useState("chat") + const chatModeRef = useRef(chatMode) + chatModeRef.current = chatMode + const [researchRun, setResearchRun] = useState(null) + const researchIsActive = isActiveResearchRun(researchRun) const selectedModelRef = useRef(selectedModel) selectedModelRef.current = selectedModel const reasoningEffortRef = useRef(reasoningEffort) @@ -503,6 +514,7 @@ export function ChatSidebar({ setReasoningEffort(nextReasoningEffort) clearError() void persistThreadSettings({ + mode: chatModeRef.current, model: modelId, projectId: selectedProjectRef.current, reasoningEffort: nextReasoningEffort, @@ -517,6 +529,7 @@ export function ChatSidebar({ (nextReasoningEffort: ReasoningEffort) => { setReasoningEffort(nextReasoningEffort) void persistThreadSettings({ + mode: chatModeRef.current, model: selectedModelRef.current, projectId: selectedProjectRef.current, reasoningEffort: nextReasoningEffort, @@ -532,6 +545,7 @@ export function ChatSidebar({ const nextProject = nextProjects[0] ?? AUTO_CHAT_SPACE_ID setChatSpaceProjects([nextProject]) void persistThreadSettings({ + mode: chatModeRef.current, model: selectedModelRef.current, projectId: nextProject, reasoningEffort: reasoningEffortRef.current, @@ -541,6 +555,22 @@ export function ChatSidebar({ [persistThreadSettings], ) + const handleChatModeChange = useCallback( + (nextMode: NovaChatMode) => { + if (researchIsActive) return + setChatMode(nextMode) + void persistThreadSettings({ + mode: nextMode, + model: selectedModelRef.current, + projectId: selectedProjectRef.current, + reasoningEffort: reasoningEffortRef.current, + spaceMode: + selectedProjectRef.current === AUTO_CHAT_SPACE_ID ? "auto" : "manual", + }) + }, + [persistThreadSettings, researchIsActive], + ) + const setAttachmentDraftState = useCallback( (id: string, patch: Partial) => { setAttachmentDrafts((prev) => @@ -912,6 +942,12 @@ export function ChatSidebar({ if (hasBusy) return false const hasErrored = drafts.some((d) => d.status === "error") if (hasErrored) return false + if (chatMode === "research" && drafts.length > 0) { + toast.error( + "Research mode currently works from saved memories and web sources. Save the attachment first, then research it from its space.", + ) + return false + } const chatIdForSend = threadId ?? fallbackChatId @@ -923,6 +959,73 @@ export function ChatSidebar({ const messageText = trimmed || "Analyze the attached file(s)." const isRespondingNow = status === "submitted" || status === "streaming" + if (chatMode === "research") { + if (isRespondingNow || researchIsActive) return false + const userMessageId = generateId() + if (!threadId) setThreadId(fallbackChatId) + setMessages((current) => [ + ...current, + { + id: userMessageId, + role: "user", + parts: [{ type: "text", text: messageText }], + metadata: + uploadedAttachments.length > 0 + ? { attachments: uploadedAttachments } + : undefined, + }, + ]) + setInput("") + setAttachmentDrafts([]) + uploadPromisesRef.current.clear() + abortControllersRef.current.clear() + discardedDraftIdsRef.current.clear() + userJustSentRef.current = true + scrollToBottom() + analytics.chatMessageSent({ + source, + attachment_count: uploadedAttachments.length, + saved_attachment_count: uploadedAttachments.filter( + (attachment) => attachment.saveToMemory, + ).length, + temporary_attachment_count: uploadedAttachments.filter( + (attachment) => !attachment.saveToMemory, + ).length, + }) + + const response = await fetch(`${chatApiBase}/chat/research`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: messageText, + chatId: chatIdForSend, + userMessageId, + metadata: { + model: selectedModel, + reasoningEffort, + projectId: selectedProjectRef.current, + spaceMode: + selectedProjectRef.current === AUTO_CHAT_SPACE_ID + ? "auto" + : "manual", + }, + }), + }) + const payload = (await response.json().catch(() => ({}))) as { + run?: NovaResearchRun | null + error?: string + } + if (!response.ok || !payload.run) { + setMessages((current) => + current.filter((message) => message.id !== userMessageId), + ) + throw new Error(payload.error || "Failed to start research") + } + setResearchRun(payload.run) + return true + } + if (isRespondingNow) { if (messageQueue.length >= CHAT_QUEUE_LIMIT) return false setMessageQueue((prev) => { @@ -1003,13 +1106,17 @@ export function ChatSidebar({ }, [ attachmentDrafts, + chatApiBase, + chatMode, fallbackChatId, messageQueue.length, reasoningEffort, + researchIsActive, scrollToBottom, selectedModel, sendMessage, setThreadId, + setMessages, status, threadId, uploadAttachmentDrafts, @@ -1095,8 +1202,26 @@ export function ChatSidebar({ // Keep the user message on stop so it isn't lost when generation is halted // before any assistant response arrives (ENG-732). const handleStop = useCallback(() => { + if (researchRun && isActiveResearchRun(researchRun)) { + void fetch(`${chatApiBase}/chat/research/${researchRun.id}/cancel`, { + method: "POST", + credentials: "include", + }).then((response) => { + if (!response.ok) return + setResearchRun((current) => + current?.id === researchRun.id + ? { + ...current, + status: "cancelled", + completedAt: new Date().toISOString(), + } + : current, + ) + }) + return + } stop() - }, [stop]) + }, [chatApiBase, researchRun, stop]) const handleCopyMessage = useCallback((messageId: string, text: string) => { analytics.chatMessageCopied({ message_id: messageId }) @@ -1164,6 +1289,8 @@ export function ChatSidebar({ pendingResponseModelsRef.current = [] seenAssistantMessageIdsRef.current = new Set() setResponseModelByMessageId({}) + setResearchRun(null) + setChatMode("chat") queuedDispatchInFlightRef.current = false queuedDispatchSawResponseRef.current = false }, [setThreadId, setMessages]) @@ -1197,11 +1324,21 @@ export function ChatSidebar({ const loadThread = useCallback( async (id: string) => { try { - const response = await fetch(`${chatApiBase}/chat/threads/${id}`, { - credentials: "include", - }) + const [response, researchResponse] = await Promise.all([ + fetch(`${chatApiBase}/chat/threads/${id}`, { + credentials: "include", + }), + fetch(`${chatApiBase}/chat/research/threads/${id}/latest`, { + credentials: "include", + }), + ]) if (response.ok) { const data = await response.json() + const researchData = researchResponse.ok + ? ((await researchResponse.json()) as { + run?: NovaResearchRun | null + }) + : null const restoredSettings = readChatThreadSettings( data.thread?.settings, data.thread?.space?.containerTag ?? @@ -1236,6 +1373,13 @@ export function ChatSidebar({ setResponseModelByMessageId({}) setSelectedModel(restoredSettings.model) setReasoningEffort(restoredSettings.reasoningEffort) + setChatMode(restoredSettings.mode) + const latestResearchRun = researchData?.run ?? null + setResearchRun( + latestResearchRun?.status === "completed" + ? null + : latestResearchRun, + ) setChatSpaceProjects([restoredSettings.projectId]) setThreadId(id) setPendingThreadLoad({ id, messages: uiMessages }) @@ -1253,6 +1397,38 @@ export function ChatSidebar({ [chatApiBase, selectedProject, setThreadId], ) + useEffect(() => { + const runId = researchRun?.id + if (!runId || !researchIsActive) return + let disposed = false + let timeoutId: number | undefined + + const poll = async () => { + try { + const response = await fetch(`${chatApiBase}/chat/research/${runId}`, { + credentials: "include", + }) + if (!response.ok || disposed) return + const data = (await response.json()) as { run?: NovaResearchRun } + if (!data.run) return + setResearchRun(data.run) + if (!isActiveResearchRun(data.run)) { + await loadThread(data.run.threadId) + return + } + } catch (error) { + console.error("Failed to refresh research progress", error) + } + if (!disposed) timeoutId = window.setTimeout(poll, 1500) + } + + timeoutId = window.setTimeout(poll, 700) + return () => { + disposed = true + if (timeoutId !== undefined) window.clearTimeout(timeoutId) + } + }, [chatApiBase, loadThread, researchIsActive, researchRun?.id]) + // Auto-restore thread from URL on mount (e.g. reload or direct link) const didAutoLoadRef = useRef(false) const initialThreadIdRef = useRef(threadId) @@ -1629,7 +1805,8 @@ export function ChatSidebar({ const isStackedInput = layout === "page" const showHeaderRow = !isPageDesktop || isMobile || !isStackedInput - const isResponding = status === "submitted" || status === "streaming" + const isResponding = + status === "submitted" || status === "streaming" || researchIsActive const isWebSearching = isResponding && (() => { @@ -1968,6 +2145,11 @@ export function ChatSidebar({ value={reasoningEffort} onChange={handleReasoningEffortChange} /> + ))} + {researchRun ? ( + + ) : null} @@ -2140,6 +2330,11 @@ export function ChatSidebar({ > setInput(e.target.value)} onSend={handleSend} onStop={handleStop} @@ -2152,18 +2347,24 @@ export function ChatSidebar({ canSend={canSendMessage} attachmentAccept={CHAT_ATTACHMENT_ACCEPT} disableFileDropZone - sendDisabled={isResponding && isQueueFull} - sendDisabledTooltip={`Queue is full (${CHAT_QUEUE_LIMIT} max)`} + sendDisabled={researchIsActive || (isResponding && isQueueFull)} + sendDisabledTooltip={ + researchIsActive + ? "Wait for the current research run or stop it" + : `Queue is full (${CHAT_QUEUE_LIMIT} max)` + } activeStatus={ - isResponding && isQueueFull - ? `Queue full (${CHAT_QUEUE_LIMIT} max)` - : isWebSearching - ? "Searching the web…" - : status === "submitted" - ? "Thinking…" - : status === "streaming" + researchIsActive + ? researchRun?.events.at(-1)?.message || "Researching…" + : isResponding && isQueueFull + ? `Queue full (${CHAT_QUEUE_LIMIT} max)` + : isWebSearching + ? "Searching the web…" + : status === "submitted" ? "Thinking…" - : "Waiting for input…" + : status === "streaming" + ? "Thinking…" + : "Waiting for input…" } queuedMessages={messageQueue} showStatusStrip={showInputStatusStrip} @@ -2182,10 +2383,18 @@ export function ChatSidebar({ } toolbarTrailing={ isStackedInput ? ( - + <> + + + ) : undefined } toolbarEnd={ diff --git a/apps/web/components/chat/input/index.tsx b/apps/web/components/chat/input/index.tsx index 0926af53..1e88a5b0 100644 --- a/apps/web/components/chat/input/index.tsx +++ b/apps/web/components/chat/input/index.tsx @@ -34,6 +34,7 @@ export interface QueuedChatMessagePreview { interface ChatInputProps { value: string + placeholder?: string onChange: (e: React.ChangeEvent) => void onSend: () => void onStop: () => void @@ -64,6 +65,7 @@ interface ChatInputProps { export default function ChatInput({ value, + placeholder = "Ask your supermemory...", onChange, onSend, onStop, @@ -349,7 +351,7 @@ export default function ChatInput({ value={value} onChange={handleChange} onKeyDown={onKeyDown} - placeholder="Ask your supermemory..." + placeholder={placeholder} className="w-full resize-none overflow-y-auto bg-transparent p-2 text-fg-primary transition-all duration-200 placeholder:text-fg-faint focus:outline-none" style={{ minHeight: "36px" }} rows={1} @@ -389,7 +391,7 @@ export default function ChatInput({ value={value} onChange={handleChange} onKeyDown={onKeyDown} - placeholder="Ask your supermemory..." + placeholder={placeholder} className="w-full resize-none overflow-y-auto bg-transparent p-2 text-fg-primary transition-all duration-200 placeholder:text-fg-faint focus:outline-none" style={{ minHeight: "36px" }} rows={1} diff --git a/apps/web/components/chat/message/agent-message.tsx b/apps/web/components/chat/message/agent-message.tsx index d9e490f7..c1f3de6c 100644 --- a/apps/web/components/chat/message/agent-message.tsx +++ b/apps/web/components/chat/message/agent-message.tsx @@ -11,6 +11,7 @@ import { ChevronRightIcon, ClockIcon, CopyIcon, + DownloadIcon, ExternalLinkIcon, GlobeIcon, ListIcon, @@ -18,6 +19,7 @@ import { PlusIcon, SearchIcon, TerminalIcon, + TelescopeIcon, WrenchIcon, XCircleIcon, ZapIcon, @@ -1350,11 +1352,44 @@ export function AgentMessage({ const responseModelLabel = responseModel ? `${modelNames[responseModel].name} ${modelNames[responseModel].version}` : null + const researchMetadata = ( + message as UIMessage & { + metadata?: { + research?: { runId?: string; title?: string; artifact?: string } + } + } + ).metadata?.research + const researchApiBase = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" return (
+ {researchMetadata?.runId ? ( +
+
+
+ +
+
+
+ Nova Research Report +
+
+ {researchMetadata.title || "Research complete"} +
+
+
+ + Markdown + +
+ ) : null} { diff --git a/apps/web/components/chat/research-mode-selector.tsx b/apps/web/components/chat/research-mode-selector.tsx new file mode 100644 index 00000000..7f0ff93e --- /dev/null +++ b/apps/web/components/chat/research-mode-selector.tsx @@ -0,0 +1,48 @@ +"use client" + +import { MessageCircleIcon, TelescopeIcon } from "lucide-react" +import { cn } from "@lib/utils" +import { dmSansClassName } from "@/lib/fonts" + +export type NovaChatMode = "chat" | "research" + +export function ResearchModeSelector({ + value, + onChange, + disabled = false, +}: { + value: NovaChatMode + onChange: (value: NovaChatMode) => void + disabled?: boolean +}) { + const isResearch = value === "research" + const Icon = isResearch ? TelescopeIcon : MessageCircleIcon + return ( + + ) +} diff --git a/apps/web/components/chat/research-progress.tsx b/apps/web/components/chat/research-progress.tsx new file mode 100644 index 00000000..a2253214 --- /dev/null +++ b/apps/web/components/chat/research-progress.tsx @@ -0,0 +1,205 @@ +"use client" + +import { + CheckCircle2Icon, + CircleIcon, + DownloadIcon, + ExternalLinkIcon, + Loader2Icon, + SearchIcon, + SquareIcon, + TelescopeIcon, + XCircleIcon, +} from "lucide-react" +import { cn } from "@lib/utils" +import { dmSansClassName } from "@/lib/fonts" +import type { NovaResearchEvent, NovaResearchRun } from "@/lib/nova-research" + +function toolLabel(name: string | null, fallback: string | null): string { + if (fallback) return fallback + return (name ?? "Research tool") + .replace(/_/g, " ") + .replace(/\b\w/g, (character) => character.toUpperCase()) +} + +function eventIcon(event: NovaResearchEvent) { + if (event.status === "running" || event.status === "pending") { + return + } + if (event.status === "failed" || event.type === "error") { + return + } + if (event.type === "tool") { + return + } + return +} + +function isUsefulTimelineEvent(event: NovaResearchEvent): boolean { + return ( + event.type === "assistant" || + event.type === "tool" || + event.type === "status" || + event.type === "artifact" || + event.type === "error" + ) +} + +export function ResearchProgress({ + run, + apiBase, + onCancel, + className, +}: { + run: NovaResearchRun + apiBase: string + onCancel: () => void + className?: string +}) { + const active = run.status === "queued" || run.status === "running" + const failed = run.status === "failed" + const cancelled = run.status === "cancelled" + const timeline = run.events.filter(isUsefulTimelineEvent) + const statusLabel = active + ? "Researching" + : failed + ? "Research failed" + : cancelled + ? "Research stopped" + : "Research complete" + + return ( +
+
+
+
+ {active ? ( + + ) : ( + + )} +
+
+
+

{statusLabel}

+ + {run.toolCallCount} tool call + {run.toolCallCount === 1 ? "" : "s"} + +
+

+ {run.query} +

+
+
+ {active ? ( + + ) : run.reportMarkdown ? ( + + Markdown + + ) : null} +
+ + {run.plan ? ( +
+
+ Plan +
+
+ {run.plan.steps.map((step) => ( +
+ {step.status === "complete" ? ( + + ) : step.status === "in_progress" ? ( + + ) : ( + + )} + {step.title} +
+ ))} +
+
+ ) : null} + +
+
+ {timeline.length === 0 ? ( +
+ + Preparing the investigation… +
+ ) : ( + timeline.map((event) => ( +
+
+ {eventIcon(event)} +
+
+
+ {event.type === "tool" + ? toolLabel(event.toolName, event.title) + : event.message || event.title} +
+ {event.type === "tool" && event.toolName ? ( +
+ {event.toolName} +
+ ) : null} +
+
+ )) + )} +
+
+ + {run.sources.length > 0 ? ( +
+ Sources + {run.sources.slice(0, 8).map((source) => + source.url ? ( + + {source.title || source.url} + + + ) : ( + + {source.title || source.space || "Memory"} + + ), + )} +
+ ) : null} +
+ ) +} diff --git a/apps/web/lib/chat-thread-settings.ts b/apps/web/lib/chat-thread-settings.ts index 58fa2711..421be659 100644 --- a/apps/web/lib/chat-thread-settings.ts +++ b/apps/web/lib/chat-thread-settings.ts @@ -7,6 +7,7 @@ import { } from "./models" export type ChatThreadSettings = { + mode: "chat" | "research" model: ModelId reasoningEffort: ReasoningEffort spaceMode: "auto" | "manual" @@ -25,6 +26,7 @@ export function readChatThreadSettings( typeof value === "object" && value !== null ? (value as Record) : {} + const mode = settings.mode === "research" ? "research" : "chat" const model = isModelId(settings.model) ? settings.model : "grok-4.5" const reasoningEffort = settings.reasoningEffort === "instant" || @@ -38,5 +40,5 @@ export function readChatThreadSettings( : fallbackProjectId const projectId = spaceMode === "auto" ? AUTO_CHAT_SPACE_ID : storedProjectId - return { model, reasoningEffort, spaceMode, projectId } + return { mode, model, reasoningEffort, spaceMode, projectId } } diff --git a/apps/web/lib/nova-research.ts b/apps/web/lib/nova-research.ts new file mode 100644 index 00000000..01fb82b0 --- /dev/null +++ b/apps/web/lib/nova-research.ts @@ -0,0 +1,75 @@ +export type NovaResearchStatus = + | "queued" + | "running" + | "completed" + | "failed" + | "cancelled" + +export type NovaResearchPlan = { + goal: string + steps: Array<{ + id: string + title: string + status: "pending" | "in_progress" | "complete" | "skipped" + }> +} + +export type NovaResearchSource = { + id: string + type: "memory" | "web" + title?: string + url?: string + sourceId?: string + documentId?: string + space?: string +} + +export type NovaResearchEvent = { + id: string + sequence: number + type: + | "status" + | "assistant" + | "plan" + | "tool" + | "source" + | "artifact" + | "error" + status: string | null + title: string | null + message: string | null + toolName: string | null + input: unknown + output: unknown + createdAt: string +} + +export type NovaResearchRun = { + id: string + threadId: string + userMessageId: string + assistantMessageId: string + workflowInstanceId: string | null + query: string + model: string + reasoningEffort: "instant" | "thinking" + spaceMode: "auto" | "manual" + projectId: string + status: NovaResearchStatus + plan: NovaResearchPlan | null + sources: NovaResearchSource[] + reportTitle: string | null + reportMarkdown: string | null + reportDocumentId: string | null + error: string | null + toolCallCount: number + startedAt: string | null + completedAt: string | null + createdAt: string + updatedAt: string + events: NovaResearchEvent[] +} + +export function isActiveResearchRun(run: NovaResearchRun | null): boolean { + return run?.status === "queued" || run?.status === "running" +}