mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-18 21:14:06 +00:00
add Nova research experience
Adds a persisted Chat/Research mode selector, durable progress timeline, cancellation, navigation restoration, and a rendered/downloadable Markdown report experience. Research state loads in parallel with thread state, and unsupported attachments are rejected instead of silently ignored. Validation: 2 focused web tests, Biome, and diff checks.
This commit is contained in:
parent
878d6c2ab9
commit
b4d6a21c19
7 changed files with 603 additions and 23 deletions
|
|
@ -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<NovaChatMode>("chat")
|
||||
const chatModeRef = useRef(chatMode)
|
||||
chatModeRef.current = chatMode
|
||||
const [researchRun, setResearchRun] = useState<NovaResearchRun | null>(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<ChatAttachmentDraft>) => {
|
||||
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}
|
||||
/>
|
||||
<ResearchModeSelector
|
||||
value={chatMode}
|
||||
onChange={handleChatModeChange}
|
||||
disabled={researchIsActive}
|
||||
/>
|
||||
<SpaceSelector
|
||||
selectedProjects={chatSpaceProjects}
|
||||
onValueChange={handleChatSpaceProjectsChange}
|
||||
|
|
@ -2060,6 +2242,14 @@ export function ChatSidebar({
|
|||
)}
|
||||
</div>
|
||||
))}
|
||||
{researchRun ? (
|
||||
<ResearchProgress
|
||||
run={researchRun}
|
||||
apiBase={chatApiBase}
|
||||
onCancel={handleStop}
|
||||
className="mt-2"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -2140,6 +2330,11 @@ export function ChatSidebar({
|
|||
>
|
||||
<ChatInput
|
||||
value={input}
|
||||
placeholder={
|
||||
chatMode === "research"
|
||||
? "Describe what you want Nova to research..."
|
||||
: "Ask your supermemory..."
|
||||
}
|
||||
onChange={(e) => 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 ? (
|
||||
<ReasoningSelector
|
||||
value={reasoningEffort}
|
||||
onChange={handleReasoningEffortChange}
|
||||
/>
|
||||
<>
|
||||
<ReasoningSelector
|
||||
value={reasoningEffort}
|
||||
onChange={handleReasoningEffortChange}
|
||||
disabled={researchIsActive}
|
||||
/>
|
||||
<ResearchModeSelector
|
||||
value={chatMode}
|
||||
onChange={handleChatModeChange}
|
||||
disabled={researchIsActive}
|
||||
/>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
toolbarEnd={
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export interface QueuedChatMessagePreview {
|
|||
|
||||
interface ChatInputProps {
|
||||
value: string
|
||||
placeholder?: string
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => 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}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{researchMetadata?.runId ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-[#267BF1]/20 bg-[linear-gradient(135deg,rgba(38,123,241,0.12),rgba(9,18,32,0.72))] px-3.5 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-[#267BF1]/12">
|
||||
<TelescopeIcon className="size-4 text-[#8DBDFF]" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-medium uppercase tracking-[0.12em] text-[#8DBDFF]/70">
|
||||
Nova Research Report
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-sm font-medium text-white/90">
|
||||
{researchMetadata.title || "Research complete"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={`${researchApiBase}/chat/research/${researchMetadata.runId}/report.md`}
|
||||
download
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-[#267BF1]/25 bg-[#267BF1]/10 px-2.5 py-1.5 text-[11px] text-[#A8CCFF] transition-colors hover:bg-[#267BF1]/20"
|
||||
>
|
||||
<DownloadIcon className="size-3" /> Markdown
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
<RelatedMemories
|
||||
message={message}
|
||||
expandedMemories={expandedMemories}
|
||||
|
|
@ -1410,7 +1445,11 @@ export function AgentMessage({
|
|||
return (
|
||||
<div
|
||||
key={`${message.id}-${partIndex}`}
|
||||
className="text-sm text-white/90 chat-markdown-content"
|
||||
className={cn(
|
||||
"text-sm text-white/90 chat-markdown-content",
|
||||
researchMetadata?.runId &&
|
||||
"rounded-xl border border-white/[0.06] bg-white/[0.02] px-4 py-3.5",
|
||||
)}
|
||||
>
|
||||
<Streamdown components={markdownComponents}>
|
||||
{
|
||||
|
|
|
|||
48
apps/web/components/chat/research-mode-selector.tsx
Normal file
48
apps/web/components/chat/research-mode-selector.tsx
Normal file
|
|
@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(isResearch ? "chat" : "research")}
|
||||
className={cn(
|
||||
"group flex shrink-0 cursor-pointer items-center gap-1 rounded-md px-1.5 py-1 text-xs transition-colors hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
isResearch ? "text-[#8DBDFF]" : "text-white/80",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
title={
|
||||
isResearch
|
||||
? "Research mode: durable multi-step investigation"
|
||||
: "Chat mode: quick conversational answer"
|
||||
}
|
||||
aria-label={`Mode: ${isResearch ? "Research" : "Chat"}. Click to switch.`}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-3.5",
|
||||
isResearch
|
||||
? "text-[#8DBDFF]"
|
||||
: "text-white/45 group-hover:text-white/70",
|
||||
)}
|
||||
/>
|
||||
<span>{isResearch ? "Research" : "Chat"}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
205
apps/web/components/chat/research-progress.tsx
Normal file
205
apps/web/components/chat/research-progress.tsx
Normal file
|
|
@ -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 <Loader2Icon className="size-3.5 animate-spin text-[#8DBDFF]" />
|
||||
}
|
||||
if (event.status === "failed" || event.type === "error") {
|
||||
return <XCircleIcon className="size-3.5 text-red-400" />
|
||||
}
|
||||
if (event.type === "tool") {
|
||||
return <CheckCircle2Icon className="size-3.5 text-emerald-400/85" />
|
||||
}
|
||||
return <CircleIcon className="size-3 fill-[#267BF1] text-[#267BF1]" />
|
||||
}
|
||||
|
||||
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 (
|
||||
<section
|
||||
className={cn(
|
||||
"w-full overflow-hidden rounded-2xl border border-[#1B2D47] bg-[linear-gradient(145deg,rgba(8,20,38,0.96),rgba(4,9,17,0.96))] shadow-[0_18px_60px_rgba(0,0,0,0.28)]",
|
||||
dmSansClassName(),
|
||||
className,
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 border-[#1B2D47] border-b px-4 py-3.5">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-xl border border-[#267BF1]/25 bg-[#267BF1]/10">
|
||||
{active ? (
|
||||
<Loader2Icon className="size-4 animate-spin text-[#8DBDFF]" />
|
||||
) : (
|
||||
<TelescopeIcon className="size-4 text-[#8DBDFF]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-medium text-sm text-white">{statusLabel}</h3>
|
||||
<span className="rounded-full bg-white/[0.05] px-2 py-0.5 text-[10px] text-white/45">
|
||||
{run.toolCallCount} tool call
|
||||
{run.toolCallCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-white/48">
|
||||
{run.query}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{active ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-white/10 px-2.5 py-1.5 text-[11px] text-white/55 transition-colors hover:border-white/20 hover:bg-white/5 hover:text-white/85"
|
||||
>
|
||||
<SquareIcon className="size-2.5 fill-current" /> Stop
|
||||
</button>
|
||||
) : run.reportMarkdown ? (
|
||||
<a
|
||||
href={`${apiBase}/chat/research/${run.id}/report.md`}
|
||||
download
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-[#267BF1]/30 bg-[#267BF1]/10 px-2.5 py-1.5 text-[11px] text-[#A8CCFF] transition-colors hover:bg-[#267BF1]/20"
|
||||
>
|
||||
<DownloadIcon className="size-3" /> Markdown
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{run.plan ? (
|
||||
<div className="border-[#1B2D47]/80 border-b px-4 py-3">
|
||||
<div className="mb-2 text-[10px] font-medium uppercase tracking-[0.12em] text-white/35">
|
||||
Plan
|
||||
</div>
|
||||
<div className="grid gap-1.5 sm:grid-cols-2">
|
||||
{run.plan.steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className="flex min-w-0 items-center gap-2 text-xs text-white/58"
|
||||
>
|
||||
{step.status === "complete" ? (
|
||||
<CheckCircle2Icon className="size-3.5 shrink-0 text-emerald-400/80" />
|
||||
) : step.status === "in_progress" ? (
|
||||
<Loader2Icon className="size-3.5 shrink-0 animate-spin text-[#8DBDFF]" />
|
||||
) : (
|
||||
<CircleIcon className="size-3.5 shrink-0 text-white/20" />
|
||||
)}
|
||||
<span className="truncate">{step.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="max-h-72 overflow-y-auto px-4 py-3">
|
||||
<div className="relative space-y-3 before:absolute before:top-2 before:bottom-2 before:left-[6px] before:w-px before:bg-[#1B2D47]">
|
||||
{timeline.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-xs text-white/45">
|
||||
<Loader2Icon className="size-3.5 animate-spin text-[#8DBDFF]" />
|
||||
Preparing the investigation…
|
||||
</div>
|
||||
) : (
|
||||
timeline.map((event) => (
|
||||
<div key={event.id} className="relative flex items-start gap-3">
|
||||
<div className="z-10 flex size-3.5 shrink-0 items-center justify-center bg-[#081426]">
|
||||
{eventIcon(event)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 -mt-0.5">
|
||||
<div className="text-xs leading-relaxed text-white/72">
|
||||
{event.type === "tool"
|
||||
? toolLabel(event.toolName, event.title)
|
||||
: event.message || event.title}
|
||||
</div>
|
||||
{event.type === "tool" && event.toolName ? (
|
||||
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-white/28">
|
||||
<SearchIcon className="size-2.5" /> {event.toolName}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{run.sources.length > 0 ? (
|
||||
<div className="flex items-center gap-2 overflow-x-auto border-[#1B2D47]/80 border-t px-4 py-2.5">
|
||||
<span className="shrink-0 text-[10px] text-white/30">Sources</span>
|
||||
{run.sources.slice(0, 8).map((source) =>
|
||||
source.url ? (
|
||||
<a
|
||||
key={source.id}
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex max-w-44 shrink-0 items-center gap-1 truncate rounded-full bg-white/[0.05] px-2 py-1 text-[10px] text-white/48 hover:text-white/75"
|
||||
>
|
||||
<span className="truncate">{source.title || source.url}</span>
|
||||
<ExternalLinkIcon className="size-2.5 shrink-0" />
|
||||
</a>
|
||||
) : (
|
||||
<span
|
||||
key={source.id}
|
||||
className="max-w-44 shrink-0 truncate rounded-full bg-white/[0.05] px-2 py-1 text-[10px] text-white/48"
|
||||
>
|
||||
{source.title || source.space || "Memory"}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<string, unknown>)
|
||||
: {}
|
||||
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 }
|
||||
}
|
||||
|
|
|
|||
75
apps/web/lib/nova-research.ts
Normal file
75
apps/web/lib/nova-research.ts
Normal file
|
|
@ -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"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue