diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index aa45c4c9..b2329751 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -73,10 +73,22 @@ 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 NovaResearchClarificationAnswer, + type NovaResearchConnectionState, + type NovaResearchRun, + researchPollDelayMs, +} from "@/lib/nova-research" type ChatMessageSendSource = "typed" | "suggested" | "highlight" | "home" @@ -216,6 +228,17 @@ 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 [researchConnection, setResearchConnection] = + useState({ + status: "connected", + attempts: 0, + }) + const [researchPollRevision, setResearchPollRevision] = useState(0) + const researchIsActive = isActiveResearchRun(researchRun) const selectedModelRef = useRef(selectedModel) selectedModelRef.current = selectedModel const reasoningEffortRef = useRef(reasoningEffort) @@ -503,6 +526,7 @@ export function ChatSidebar({ setReasoningEffort(nextReasoningEffort) clearError() void persistThreadSettings({ + mode: chatModeRef.current, model: modelId, projectId: selectedProjectRef.current, reasoningEffort: nextReasoningEffort, @@ -517,6 +541,7 @@ export function ChatSidebar({ (nextReasoningEffort: ReasoningEffort) => { setReasoningEffort(nextReasoningEffort) void persistThreadSettings({ + mode: chatModeRef.current, model: selectedModelRef.current, projectId: selectedProjectRef.current, reasoningEffort: nextReasoningEffort, @@ -532,6 +557,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 +567,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) => @@ -902,6 +944,8 @@ export function ChatSidebar({ text: string, source: ChatMessageSendSource, drafts = attachmentDrafts, + modeOverride?: NovaChatMode, + preserveComposer = false, ): Promise => { const trimmed = text.trim() if (!trimmed && drafts.length === 0) return false @@ -923,6 +967,76 @@ export function ChatSidebar({ const messageText = trimmed || "Analyze the attached file(s)." const isRespondingNow = status === "submitted" || status === "streaming" + if ((modeOverride ?? 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, + }, + ]) + if (!preserveComposer) { + 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, + attachments: uploadedAttachments, + 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 +1117,17 @@ export function ChatSidebar({ }, [ attachmentDrafts, + chatApiBase, + chatMode, fallbackChatId, messageQueue.length, reasoningEffort, + researchIsActive, scrollToBottom, selectedModel, sendMessage, setThreadId, + setMessages, status, threadId, uploadAttachmentDrafts, @@ -1094,9 +1212,120 @@ 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 cancelResearch = useCallback(async () => { + if (!researchRun || !isActiveResearchRun(researchRun)) return + const runId = researchRun.id + const response = await fetch( + `${chatApiBase}/chat/research/${runId}/cancel`, + { + method: "POST", + credentials: "include", + }, + ) + if (!response.ok) { + const latestResponse = await fetch( + `${chatApiBase}/chat/research/${runId}`, + { + credentials: "include", + cache: "no-store", + }, + ).catch(() => null) + const latestData = latestResponse?.ok + ? ((await latestResponse.json().catch(() => null)) as { + run?: NovaResearchRun | null + } | null) + : null + if (latestData?.run && !isActiveResearchRun(latestData.run)) { + setResearchRun(latestData.run) + return + } + throw new Error("Research cancellation failed") + } + const data = (await response.json().catch(() => null)) as { + run?: NovaResearchRun | null + } | null + setResearchRun((current) => { + if (current?.id !== runId) return current + return ( + data?.run ?? { + ...current, + status: "cancelled", + completedAt: new Date().toISOString(), + } + ) + }) + }, [chatApiBase, researchRun]) + const handleStop = useCallback(() => { + if (researchRun && isActiveResearchRun(researchRun)) { + void cancelResearch().catch(() => { + toast.error("Could not stop research", { + description: "Please try again. The run may still be active.", + }) + }) + return + } stop() - }, [stop]) + }, [cancelResearch, researchRun, stop]) + + const handleRetryResearchFinalization = useCallback(async () => { + if (!researchRun || researchRun.status !== "failed") { + throw new Error("Research run is unavailable") + } + const response = await fetch( + `${chatApiBase}/chat/research/${researchRun.id}/retry-finalization`, + { + method: "POST", + credentials: "include", + }, + ) + const data = (await response.json().catch(() => null)) as { + run?: NovaResearchRun | null + } | null + if (!response.ok || !data?.run) { + throw new Error("Report finalization retry failed") + } + setResearchRun(data.run) + setResearchConnection({ status: "connected", attempts: 0 }) + }, [chatApiBase, researchRun]) + + const handleRunResearchAgain = useCallback(async () => { + const query = researchRun?.query.trim() + if (!query) throw new Error("Research query is unavailable") + const started = await submitChatMessage( + query, + "typed", + [], + "research", + true, + ) + if (!started) throw new Error("Research could not restart") + setChatMode("research") + }, [researchRun?.query, submitChatMessage]) + + const handleSubmitResearchClarification = useCallback( + async (requestId: string, answers: NovaResearchClarificationAnswer[]) => { + if (!researchRun) throw new Error("Research run is unavailable.") + const response = await fetch( + `${chatApiBase}/chat/research/${researchRun.id}/clarification`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requestId, answers }), + }, + ) + const data = (await response.json().catch(() => null)) as { + error?: string + run?: NovaResearchRun | null + } | null + if (!response.ok) { + throw new Error(data?.error || "Could not submit your answers.") + } + if (data?.run) setResearchRun(data.run) + }, + [chatApiBase, researchRun], + ) const handleCopyMessage = useCallback((messageId: string, text: string) => { analytics.chatMessageCopied({ message_id: messageId }) @@ -1164,6 +1393,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 +1428,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 +1477,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 +1501,93 @@ export function ChatSidebar({ [chatApiBase, selectedProject, setThreadId], ) + useEffect(() => { + const runId = researchRun?.id + if (!runId || !researchIsActive) return + let disposed = false + let timeoutId: number | undefined + let requestTimeoutId: number | undefined + let requestController: AbortController | null = null + let consecutiveFailures = 0 + let latestRunStatus = researchRun?.status + + setResearchConnection({ status: "connected", attempts: 0 }) + + const schedulePoll = (delayMs: number) => { + if (disposed) return + timeoutId = window.setTimeout(poll, delayMs) + } + + const poll = async () => { + requestController = new AbortController() + requestTimeoutId = window.setTimeout( + () => requestController?.abort(), + 10_000, + ) + try { + const response = await fetch(`${chatApiBase}/chat/research/${runId}`, { + credentials: "include", + cache: "no-store", + signal: requestController.signal, + }) + if (disposed) return + if (!response.ok) { + throw new Error(`Research polling returned ${response.status}`) + } + const data = (await response.json()) as { run?: NovaResearchRun } + if (!data.run) throw new Error("Research polling response had no run") + consecutiveFailures = 0 + latestRunStatus = data.run.status + setResearchConnection({ status: "connected", attempts: 0 }) + setResearchRun(data.run) + if (!isActiveResearchRun(data.run)) { + await loadThread(data.run.threadId) + return + } + } catch (error) { + if (disposed) return + consecutiveFailures += 1 + setResearchConnection({ + status: "reconnecting", + attempts: consecutiveFailures, + }) + console.error("Failed to refresh research progress", error) + } finally { + if (requestTimeoutId !== undefined) { + window.clearTimeout(requestTimeoutId) + requestTimeoutId = undefined + } + requestController = null + } + schedulePoll(researchPollDelayMs(consecutiveFailures, latestRunStatus)) + } + + schedulePoll( + researchPollRevision > 0 + ? 0 + : researchRun?.status === "awaiting_input" + ? researchPollDelayMs(0, "awaiting_input") + : 700, + ) + return () => { + disposed = true + if (timeoutId !== undefined) window.clearTimeout(timeoutId) + if (requestTimeoutId !== undefined) window.clearTimeout(requestTimeoutId) + requestController?.abort() + } + }, [ + chatApiBase, + loadThread, + researchIsActive, + researchPollRevision, + researchRun?.id, + researchRun?.status, + ]) + + const handleRetryResearchConnection = useCallback(() => { + setResearchPollRevision((revision) => revision + 1) + }, []) + // Auto-restore thread from URL on mount (e.g. reload or direct link) const didAutoLoadRef = useRef(false) const initialThreadIdRef = useRef(threadId) @@ -1629,7 +1964,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 +2304,11 @@ export function ChatSidebar({ value={reasoningEffort} onChange={handleReasoningEffortChange} /> + ))} + {researchRun ? ( + + ) : null} @@ -2140,6 +2493,11 @@ export function ChatSidebar({ > setInput(e.target.value)} onSend={handleSend} onStop={handleStop} @@ -2152,8 +2510,12 @@ 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)` @@ -2166,7 +2528,7 @@ export function ChatSidebar({ : "Waiting for input…" } queuedMessages={messageQueue} - showStatusStrip={showInputStatusStrip} + showStatusStrip={showInputStatusStrip && !researchIsActive} onExpandedChange={setIsInputExpanded} chainOfThoughtComponent={ messages.length > 0 ? : null @@ -2182,10 +2544,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..4b8581fe 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, @@ -35,10 +36,16 @@ import { extractMemoryToolOutputs, } from "@/lib/chat-memory-tools" import { + isSafeSourceId, parseSourceAnnotatedMarkdown, stripSourceMarkup, } from "@/lib/source-annotations" import { modelNames, type ModelId } from "@/lib/models" +import { + formatResearchDuration, + normalizeResearchMarkdownForDisplay, + type NovaResearchSource, +} from "@/lib/nova-research" import { RelatedMemories } from "./related-memories" import { MessageActions } from "./message-actions" @@ -76,6 +83,14 @@ type SourceUrlPart = { title?: string } +type ActionSource = { + id: string + title: string + subtitle?: string + url?: string + type: "memory" | "web" +} + function sourceHost(url: string): string { try { return new URL(url).hostname.replace(/^www\./, "") @@ -837,6 +852,20 @@ function SourceCitationLink({ ) const title = sourceTitle(target, document) const summary = sourceSummary(target, document) + const citationContent = + typeof children === "string" && + (children === sourceId || children === "memory") ? ( + + {sourceId} + + ) : ( + <> + {children} + + {sourceId} + + + ) return ( @@ -847,19 +876,16 @@ function SourceCitationLink({ rel="noopener noreferrer" className="text-inherit no-underline outline-none focus-visible:ring-1 focus-visible:ring-white/25" > - {children} + {citationContent} ) : ( )} - - {sourceId} - @@ -940,7 +966,7 @@ function makeMarkdownComponents( } } -function WebSourcesPill({ sources }: { sources: SourceUrlPart[] }) { +function SourcesPill({ sources }: { sources: ActionSource[] }) { const [expanded, setExpanded] = useState(false) const ref = useRef(null) @@ -958,8 +984,9 @@ function WebSourcesPill({ sources }: { sources: SourceUrlPart[] }) { if (sources.length === 0) return null const faviconHosts: string[] = [] - for (const s of sources) { - const host = sourceHost(s.url) + for (const source of sources) { + if (!source.url) continue + const host = sourceHost(source.url) if (!faviconHosts.includes(host)) faviconHosts.push(host) if (faviconHosts.length >= 3) break } @@ -972,7 +999,7 @@ function WebSourcesPill({ sources }: { sources: SourceUrlPart[] }) { onClick={() => setExpanded((v) => !v)} className="flex cursor-pointer items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] py-1 pr-2.5 pl-1.5 text-xs text-white/65 transition-colors hover:bg-white/[0.08] hover:text-white/80" aria-expanded={expanded} - aria-label={`${count} web ${count === 1 ? "source" : "sources"}`} + aria-label={`${count} ${count === 1 ? "source" : "sources"}`} > {faviconHosts.length > 0 ? ( @@ -995,16 +1022,11 @@ function WebSourcesPill({ sources }: { sources: SourceUrlPart[] }) { {expanded && (
    - {sources.map((s) => { - const host = sourceHost(s.url) - return ( -
  • - + {sources.map((source) => { + const host = source.url ? sourceHost(source.url) : null + const content = ( + <> + {host ? ( - - - {s.title?.trim() || host} - - - {host} - + ) : ( + + )} + + + {source.title} - + + {source.subtitle || host || "Saved memory"} + + + + ) + return ( +
  • + {source.url ? ( + + {content} + + ) : ( +
    + {content} +
    + )}
  • ) })} @@ -1261,6 +1303,89 @@ function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) { ) } +function SaveResearchToMemoryButton({ + runId, + apiBase, + className, +}: { + runId: string + apiBase: string + className?: string +}) { + const [saveState, setSaveState] = useState< + "idle" | "saving" | "saved" | "error" + >("idle") + const { data } = useQuery({ + queryKey: ["nova-research-memory-status", runId], + queryFn: async () => { + const response = await fetch(`${apiBase}/chat/research/${runId}`, { + credentials: "include", + }) + if (!response.ok) throw new Error("Could not load research status") + return (await response.json()) as { + run?: { reportDocumentId?: string | null } + } + }, + staleTime: 30 * 1000, + }) + const saved = saveState === "saved" || Boolean(data?.run?.reportDocumentId) + const saving = saveState === "saving" + + const save = async () => { + if (saved || saving) return + setSaveState("saving") + try { + const response = await fetch( + `${apiBase}/chat/research/${runId}/save-to-memory`, + { method: "POST", credentials: "include" }, + ) + const result = (await response.json().catch(() => null)) as { + error?: string + saved?: boolean + } | null + if (!response.ok || !result?.saved) { + throw new Error(result?.error || "Could not save this report") + } + setSaveState("saved") + } catch { + setSaveState("error") + } + } + + const label = saved + ? "Saved to memory" + : saveState === "error" + ? "Try saving again" + : "Save to memory" + + return ( + + ) +} + interface AgentMessageProps { message: UIMessage index: number @@ -1297,15 +1422,47 @@ export function AgentMessage({ .filter((part) => part.type === "text") .map((part) => part.text) .join(" ") - const copyText = stripSourceMarkup(messageText) + const researchMetadata = ( + message as UIMessage & { + metadata?: { + research?: { + runId?: string + title?: string + artifact?: string + durationMs?: number + sources?: NovaResearchSource[] + } + } + } + ).metadata?.research + const copyText = researchMetadata + ? normalizeResearchMarkdownForDisplay(stripSourceMarkup(messageText)) + : stripSourceMarkup(messageText) const memoryOutputs = useMemo( () => extractMemoryToolOutputs(message), [message], ) - const citationIndex = useMemo( - () => buildCitationIndex(memoryOutputs), - [memoryOutputs], - ) + const citationIndex = useMemo(() => { + const index = buildCitationIndex(memoryOutputs) + for (const source of researchMetadata?.sources ?? []) { + if ( + source.type !== "memory" || + !source.sourceId || + !isSafeSourceId(source.sourceId) || + index.has(source.sourceId) + ) { + continue + } + index.set(source.sourceId, { + sourceId: source.sourceId, + documentId: source.documentId, + title: source.title ?? source.space, + type: "memory", + url: source.url, + }) + } + return index + }, [memoryOutputs, researchMetadata?.sources]) const allowedSourceIds = useMemo( () => new Set(citationIndex.keys()), [citationIndex], @@ -1340,6 +1497,39 @@ export function AgentMessage({ } return out }, [message.parts]) + const actionSources = useMemo(() => { + const seen = new Set() + const sources: ActionSource[] = [] + for (const source of webSources) { + const id = `web:${source.url}` + if (seen.has(id)) continue + seen.add(id) + sources.push({ + id, + type: "web", + title: source.title?.trim() || sourceHost(source.url), + subtitle: sourceHost(source.url), + url: source.url, + }) + } + for (const source of researchMetadata?.sources ?? []) { + const id = source.url ? `web:${source.url}` : source.id + if (seen.has(id)) continue + seen.add(id) + sources.push({ + id, + type: source.type, + title: + source.title?.trim() || + source.space || + (source.type === "memory" ? "Saved memory" : "Web source"), + subtitle: + source.space || source.sourceId || source.documentId || undefined, + url: source.url, + }) + } + return sources + }, [researchMetadata?.sources, webSources]) const hasAssistantText = message.parts.some( (p) => p.type === "text" && (p as { text?: string }).text?.trim(), ) @@ -1350,6 +1540,9 @@ export function AgentMessage({ const responseModelLabel = responseModel ? `${modelNames[responseModel].name} ${modelNames[responseModel].version}` : null + const researchApiBase = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + const researchDuration = formatResearchDuration(researchMetadata?.durationMs) return (
    @@ -1360,6 +1553,14 @@ export function AgentMessage({ expandedMemories={expandedMemories} onToggle={onToggleMemories} /> + {researchDuration ? ( +

    + Research completed in{" "} + + {researchDuration} + +

    + ) : null} {message.parts.map((part, partIndex) => { if (part.type === "source-url") { @@ -1410,13 +1611,18 @@ export function AgentMessage({ return (
    - { - parseSourceAnnotatedMarkdown(runText, allowedSourceIds) - .markdown - } + {researchMetadata + ? normalizeResearchMarkdownForDisplay( + parseSourceAnnotatedMarkdown( + runText, + allowedSourceIds, + ).markdown, + ) + : parseSourceAnnotatedMarkdown(runText, allowedSourceIds) + .markdown}
    ) @@ -1479,16 +1685,38 @@ export function AgentMessage({ onLike={onLike} onDislike={onDislike} /> - {webSources.length > 0 && ( + {actionSources.length > 0 && (
    - +
    )} + {researchMetadata?.runId ? ( + <> + + + + + + ) : null} {responseModelLabel && ( Promise +}) { + const [questionIndex, setQuestionIndex] = useState(0) + const [answers, setAnswers] = useState>({}) + const [otherByQuestion, setOtherByQuestion] = useState< + Record + >({}) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const question = request.questions[questionIndex] + if (!question) return null + + const answer = answers[question.id] ?? "" + const usingOther = otherByQuestion[question.id] === true + const isLast = questionIndex === request.questions.length - 1 + const canContinue = answer.trim().length > 0 + + const submit = async () => { + if (!canContinue || submitting) return + setSubmitting(true) + setError(null) + try { + await onSubmit( + request.questions.map((item) => ({ + questionId: item.id, + value: answers[item.id]?.trim() ?? "", + })), + ) + } catch (submitError) { + setError( + submitError instanceof Error + ? submitError.message + : "Could not submit your answers.", + ) + setSubmitting(false) + } + } + + return ( +
    +
    +
    +
    +

    + A few details first +

    + {request.intro ? ( +

    {request.intro}

    + ) : null} +
    + + {questionIndex + 1} / {request.questions.length} + +
    +
    + {request.questions.map((item, index) => ( + + ))} +
    +
    + +
    +

    + {question.question} +

    +
    + {question.options.map((option) => { + const selected = !usingOther && answer === option.label + return ( + + ) + })} + {question.allowOther !== false ? ( +
    + + {usingOther ? ( +
    + + setAnswers((current) => ({ + ...current, + [question.id]: event.target.value, + })) + } + placeholder="Write your answer…" + maxLength={500} + className="h-10 w-full rounded-[10px] bg-[#0D121A] px-3.5 text-xs text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.55)] outline-none ring-1 ring-inset ring-white/[0.06] placeholder:text-[#737373] focus:ring-white/15" + /> +
    + ) : null} +
    + ) : null} +
    + + {error ?

    {error}

    : 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..10263656 --- /dev/null +++ b/apps/web/components/chat/research-progress.tsx @@ -0,0 +1,379 @@ +"use client" + +import { useState } from "react" +import { + ArrowRightIcon, + CheckCircle2Icon, + ChevronDownIcon, + ChevronUpIcon, + CircleHelpIcon, + CircleIcon, + RefreshCwIcon, + RotateCcwIcon, + SearchIcon, + SquareIcon, + XCircleIcon, +} from "lucide-react" +import { cn } from "@lib/utils" +import { SuperLoader } from "@/components/superloader" +import { dmSansClassName } from "@/lib/fonts" +import { + pendingResearchClarification, + type NovaResearchClarificationAnswer, + type NovaResearchConnectionState, + type NovaResearchEvent, + type NovaResearchRun, + researchFailurePresentation, + researchPlanStepDisplayStatus, + researchToolFailureDescription, +} from "@/lib/nova-research" +import { ResearchClarification } from "./research-clarification" + +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 eventLabel(event: NovaResearchEvent): string | null { + if (event.type === "error") return "A research step could not complete." + if (event.type === "tool" && event.status === "failed") { + return `${toolLabel(event.toolName, null)} failed` + } + if (event.status === "failed") return "A research step could not complete." + if (event.title === "Research capabilities enabled" && event.message) { + return `Enabled ${event.message} research` + } + if (event.type === "tool") return toolLabel(event.toolName, event.title) + + const label = event.message || event.title + if (!label) return null + if ( + event.type === "assistant" && + (label.length > 600 || / 600 ? `${label.slice(0, 597)}…` : label +} + +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 + } + if (event.type === "assistant") { + 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, + onCancel, + onSubmitClarification, + connectionState = { status: "connected", attempts: 0 }, + onRetryConnection, + onRetryFinalization, + onRunAgain, + className, +}: { + run: NovaResearchRun + onCancel: () => Promise | void + onSubmitClarification: ( + requestId: string, + answers: NovaResearchClarificationAnswer[], + ) => Promise + connectionState?: NovaResearchConnectionState + onRetryConnection?: () => void + onRetryFinalization?: () => Promise + onRunAgain?: () => Promise + className?: string +}) { + const active = + run.status === "queued" || + run.status === "running" || + run.status === "awaiting_input" + const failed = run.status === "failed" + const cancelled = run.status === "cancelled" + const awaitingInput = run.status === "awaiting_input" + const [expanded, setExpanded] = useState(true) + const [pendingAction, setPendingAction] = useState< + "cancel" | "retry-finalization" | "run-again" | null + >(null) + const [actionError, setActionError] = useState(null) + const timeline = run.events.filter(isUsefulTimelineEvent) + const clarification = pendingResearchClarification(run) + const failure = failed ? researchFailurePresentation(run) : null + const statusLabel = awaitingInput + ? "Waiting for your answers" + : active + ? "Researching" + : failed + ? (failure?.title ?? "Research could not complete") + : cancelled + ? "Research stopped" + : "Research complete" + + const invokeAction = async ( + action: NonNullable, + callback: (() => Promise | void) | undefined, + ) => { + if (!callback || pendingAction) return + setPendingAction(action) + setActionError(null) + try { + await callback() + } catch { + setActionError( + action === "cancel" + ? "Could not stop this research. Please try again." + : action === "retry-finalization" + ? "Could not retry the report yet. Your collected sources are still preserved." + : "Could not start a new research run. Please try again.", + ) + } finally { + setPendingAction(null) + } + } + + return ( +
    +
    + {awaitingInput ? ( + + ) : active ? ( + + ) : failed ? ( + + ) : cancelled ? ( + + ) : ( + + )} + + {active ? ( + + ) : null} +
    + + {active && connectionState.status === "reconnecting" ? ( + + + + {connectionState.attempts >= 3 + ? "Connection lost. Reconnecting to research updates…" + : "Research updates are delayed. Reconnecting…"} + + {onRetryConnection ? ( + + ) : null} + + ) : null} + + {failure ? ( +
    +

    {failure.title}

    +

    + {failure.description} +

    + {failure.incidentId ? ( +

    + Reference: {failure.incidentId} +

    + ) : null} +
    + {failure.canRetryFinalization && onRetryFinalization ? ( + + ) : null} + {onRunAgain ? ( + + ) : null} +
    +
    + ) : cancelled && onRunAgain ? ( +
    + +
    + ) : null} + + {actionError ? ( +

    + {actionError} +

    + ) : null} + {clarification ? ( + + onSubmitClarification(clarification.id, answers) + } + /> + ) : null} + + {expanded && !clarification ? ( +
    + {run.plan ? ( +
    +
    + Plan +
    +
    + {run.plan.steps.map((step) => { + const displayStatus = researchPlanStepDisplayStatus( + run.status, + step.status, + ) + return ( +
    + {displayStatus === "complete" ? ( + + ) : displayStatus === "in_progress" ? ( + + ) : displayStatus === "failed" ? ( + + ) : displayStatus === "cancelled" ? ( + + ) : ( + + )} + {step.title} +
    + ) + })} +
    +
    + ) : null} + +
    + {timeline.length === 0 ? ( +
    + + Preparing the investigation… +
    + ) : ( + timeline.map((event) => { + const label = eventLabel(event) + if (!label) return null + const toolFailureDescription = + researchToolFailureDescription(event) + return ( +
    +
    + {eventIcon(event)} +
    +
    + {label} + {toolFailureDescription ? ( +
    + {toolFailureDescription} +
    + ) : event.type === "tool" && event.toolName ? ( +
    + {event.toolName} +
    + ) : null} +
    +
    + ) + }) + )} +
    +
    + ) : null} +
    + ) +} diff --git a/apps/web/globals.css b/apps/web/globals.css index ff8d4e90..28bd9072 100644 --- a/apps/web/globals.css +++ b/apps/web/globals.css @@ -192,6 +192,7 @@ /* Loose lists wrap item content in

    whose top margin detaches the bullet */ .chat-markdown-content li > p:first-child { + display: inline; margin-top: 0; } 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..79e95402 --- /dev/null +++ b/apps/web/lib/nova-research.ts @@ -0,0 +1,360 @@ +export type NovaResearchStatus = + | "queued" + | "running" + | "awaiting_input" + | "completed" + | "failed" + | "cancelled" + +export type NovaResearchFailure = { + code: string + stage: string + retryable: boolean + incidentId?: string | null + /** A user-safe explanation supplied by the API. Legacy `run.error` is never rendered. */ + message?: string | null +} + +export type NovaResearchConnectionState = { + status: "connected" | "reconnecting" + attempts: number +} + +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 +} + +const FOOTNOTE_REFERENCE_RE = /\[\^([A-Za-z0-9_-]+)\](?!:)/g +const MARKDOWN_LINK_RE = /\]\((https?:\/\/[^\s)]+)(?:\s+"[^"]*")?\)/g +const AUTOLINK_RE = /<(https?:\/\/[^\s>]+)>/g +const REFERENCE_SECTION_RE = + /(?:^|\n)(?:---\s*\n)?#{1,6}\s+(?:sources|references|footnotes)\s*\n[\s\S]*$/i + +function markdownUrls(markdown: string): string[] { + return [ + ...markdown.matchAll(MARKDOWN_LINK_RE), + ...markdown.matchAll(AUTOLINK_RE), + ] + .map((match) => match[1]) + .filter((url): url is string => Boolean(url)) +} + +export function normalizeResearchMarkdownForDisplay(markdown: string): string { + const definitions = new Map() + const bodyLines: string[] = [] + const lines = markdown.replace(/\r\n?/g, "\n").split("\n") + + for (let index = 0; index < lines.length; index++) { + const line = lines[index] ?? "" + const match = line.match(/^\[\^([A-Za-z0-9_-]+)\]:\s*(.*)$/) + if (!match?.[1]) { + bodyLines.push(line) + continue + } + + const parts = [match[2] ?? ""] + while (/^(?:\t| {2,})\S/.test(lines[index + 1] ?? "")) { + index++ + parts.push((lines[index] ?? "").trim()) + } + definitions.set(match[1], parts.join(" ").trim()) + } + + const citationNumberByUrl = new Map() + let nextCitationNumber = 1 + const withInlineCitations = bodyLines + .join("\n") + .replace(FOOTNOTE_REFERENCE_RE, (reference, footnoteId: string) => { + const definition = definitions.get(footnoteId) + if (!definition) return reference + const urls = [...new Set(markdownUrls(definition))] + if (urls.length === 0) return reference + return urls + .map((url) => { + let number = citationNumberByUrl.get(url) + if (!number) { + number = nextCitationNumber++ + citationNumberByUrl.set(url, number) + } + return `[${number}](${url})` + }) + .join(" ") + }) + + return withInlineCitations + .replace(REFERENCE_SECTION_RE, "") + .replace(/(?:^|\n)---\s*$/, "") + .replace(/\n{3,}/g, "\n\n") + .trim() +} + +export function formatResearchDuration(durationMs?: number): string | null { + if ( + typeof durationMs !== "number" || + !Number.isFinite(durationMs) || + durationMs < 0 + ) { + return null + } + + const totalSeconds = Math.max(1, Math.round(durationMs / 1000)) + if (totalSeconds < 60) return `${totalSeconds}s` + + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s` +} + +export type NovaResearchEvent = { + id: string + sequence: number + type: + | "status" + | "assistant" + | "clarification" + | "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 + failure?: NovaResearchFailure | null + /** @deprecated Legacy internal error detail. Keep for response compatibility only. */ + error: string | null + toolCallCount: number + startedAt: string | null + completedAt: string | null + createdAt: string + updatedAt: string + events: NovaResearchEvent[] +} + +export type NovaResearchPlanStepDisplayStatus = + | NovaResearchPlan["steps"][number]["status"] + | "failed" + | "cancelled" + +const SAFE_RESEARCH_TOOL_FAILURE_MESSAGES = new Set([ + "Web search could not be completed.", + "The webpage could not be read.", + "Memory retrieval could not be completed.", + "This research step could not be completed.", +]) + +function safeResearchIncidentId( + failure?: NovaResearchFailure | null, +): string | null { + const incidentId = failure?.incidentId?.trim() + return incidentId && /^[A-Za-z0-9_-]{1,64}$/.test(incidentId) + ? incidentId + : null +} + +export function researchToolFailureDescription( + event: NovaResearchEvent, +): string | null { + if (event.type !== "tool" || event.status !== "failed") return null + const output = + event.output && typeof event.output === "object" + ? (event.output as { error?: unknown }) + : null + const message = typeof output?.error === "string" ? output.error.trim() : "" + return SAFE_RESEARCH_TOOL_FAILURE_MESSAGES.has(message) + ? message + : "This research tool could not be completed." +} + +export function researchFailurePresentation(run: NovaResearchRun): { + title: string + description: string + incidentId: string | null + canRetryFinalization: boolean + retryLabel: string | null +} { + const failure = run.failure + const incidentId = safeResearchIncidentId(failure) + const retryable = failure?.retryable === true + + switch (failure?.code?.trim().toUpperCase()) { + case "START_FAILED": + return { + title: "Research could not start", + description: "Research could not be started. Please try again.", + incidentId, + canRetryFinalization: false, + retryLabel: null, + } + case "RESEARCH_FAILED": + return { + title: "A research step failed", + description: + "Research stopped before the evidence review finished. You can run it again.", + incidentId, + canRetryFinalization: false, + retryLabel: null, + } + case "FINALIZATION_FAILED": + return { + title: "Report finalization failed", + description: + "The evidence was collected, but Nova could not finish a properly cited report. Your collected sources are preserved.", + incidentId, + canRetryFinalization: retryable, + retryLabel: retryable ? "Retry finalizing" : null, + } + case "PERSISTENCE_FAILED": + return { + title: "Saving the report failed", + description: + "The report was prepared but could not be saved. Your collected sources are preserved.", + incidentId, + canRetryFinalization: retryable, + retryLabel: retryable ? "Retry saving" : null, + } + default: + return { + title: "Research could not complete", + description: + "Nova stopped safely before producing a report. You can run the research again.", + incidentId, + canRetryFinalization: false, + retryLabel: null, + } + } +} + +export function researchPlanStepDisplayStatus( + runStatus: NovaResearchStatus, + stepStatus: NovaResearchPlan["steps"][number]["status"], +): NovaResearchPlanStepDisplayStatus { + if (stepStatus === "complete" || stepStatus === "skipped") return stepStatus + if (runStatus === "failed") { + return stepStatus === "in_progress" ? "failed" : "skipped" + } + if (runStatus === "cancelled") return "cancelled" + if (runStatus === "completed") return "skipped" + return stepStatus +} + +const RESEARCH_POLL_RETRY_DELAYS_MS = [1500, 2500, 4000, 7000, 12000, 15000] + +export function researchPollDelayMs( + consecutiveFailures: number, + runStatus?: NovaResearchStatus, +): number { + if (!Number.isFinite(consecutiveFailures) || consecutiveFailures <= 0) { + if (runStatus === "awaiting_input") return 30_000 + return RESEARCH_POLL_RETRY_DELAYS_MS[0] ?? 1500 + } + const index = Math.min( + Math.floor(consecutiveFailures), + RESEARCH_POLL_RETRY_DELAYS_MS.length - 1, + ) + return RESEARCH_POLL_RETRY_DELAYS_MS[index] ?? 15000 +} + +export type NovaResearchClarificationQuestion = { + id: string + question: string + options: Array<{ label: string; description?: string }> + allowOther?: boolean +} + +export type NovaResearchClarificationRequest = { + id: string + intro?: string + questions: NovaResearchClarificationQuestion[] +} + +export type NovaResearchClarificationAnswer = { + questionId: string + value: string +} + +function isClarificationRequest( + value: unknown, +): value is NovaResearchClarificationRequest { + if (!value || typeof value !== "object") return false + const request = value as Partial + return ( + typeof request.id === "string" && + Array.isArray(request.questions) && + request.questions.length > 0 && + request.questions.every( + (question) => + question && + typeof question.id === "string" && + typeof question.question === "string" && + Array.isArray(question.options) && + question.options.length >= 2 && + question.options.every( + (option) => option && typeof option.label === "string", + ), + ) + ) +} + +export function pendingResearchClarification( + run: NovaResearchRun, +): NovaResearchClarificationRequest | null { + if (run.status !== "awaiting_input") return null + for (let index = run.events.length - 1; index >= 0; index--) { + const event = run.events[index] + if ( + event?.type === "clarification" && + event.status === "pending" && + isClarificationRequest(event.input) + ) { + return event.input + } + } + return null +} + +export function isActiveResearchRun(run: NovaResearchRun | null): boolean { + return ( + run?.status === "queued" || + run?.status === "running" || + run?.status === "awaiting_input" + ) +} diff --git a/apps/web/lib/source-annotations.test.ts b/apps/web/lib/source-annotations.test.ts index d2aa10b2..c5096801 100644 --- a/apps/web/lib/source-annotations.test.ts +++ b/apps/web/lib/source-annotations.test.ts @@ -78,6 +78,35 @@ describe("source annotation parsing", () => { ) }) + it("turns known bare memory ids into internal citation links", () => { + const parsed = parseSourceAnnotatedMarkdown( + "Fact [S1]. Combined [S1, S2]. Unknown [S3].", + new Set(["S1", "S2"]), + ) + + expect(parsed.markdown).toBe( + "Fact [S1](#sm-source:S1). Combined [S1](#sm-source:S1) [S2](#sm-source:S2). Unknown [S3].", + ) + }) + + it("does not reinterpret normal markdown as bare memory citations", () => { + const input = [ + "[S1](https://example.com)", + "[S1][ref]", + "[S1]: https://example.com", + "![S1](image.png)", + "\\[S1]", + "`[S1]`", + "```", + "[S1]", + "```", + ].join("\n") + + expect(parseSourceAnnotatedMarkdown(input, new Set(["S1"])).markdown).toBe( + input, + ) + }) + it("strips source markup for copy text", () => { expect( stripSourceMarkup('Alpha Beta'), diff --git a/apps/web/lib/source-annotations.ts b/apps/web/lib/source-annotations.ts index eb64c422..be5ef521 100644 --- a/apps/web/lib/source-annotations.ts +++ b/apps/web/lib/source-annotations.ts @@ -225,6 +225,45 @@ export function parseSourceAnnotatedMarkdown( } } + if ( + !codeState.inFence && + !codeState.inInlineCode && + text[i] === "[" && + text[i - 1] !== "!" && + text[i - 1] !== "\\" + ) { + const closeIndex = text.indexOf("]", i + 1) + if (closeIndex !== -1 && !text.slice(i + 1, closeIndex).includes("\n")) { + const sourceIds = text + .slice(i + 1, closeIndex) + .split(",") + .map((sourceId) => sourceId.trim()) + const next = text[closeIndex + 1] + if ( + sourceIds.length > 0 && + sourceIds.every( + (sourceId) => + isSafeSourceId(sourceId) && allowedSourceIds.has(sourceId), + ) && + next !== "(" && + next !== "[" && + next !== ":" + ) { + output.push( + sourceIds + .map( + (sourceId) => + `[${escapeMarkdownLinkText(sourceId)}](#sm-source:${encodeURIComponent(sourceId)})`, + ) + .join(" "), + ) + i = closeIndex + 1 + codeState.lineStart = false + continue + } + } + } + appendChar(text, i, output, codeState) i++ }