mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-11-04 07:30:17 +00:00
Merge 55a4209252 into fffcfdb47c
This commit is contained in:
commit
4f86385152
11 changed files with 1754 additions and 54 deletions
|
|
@ -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<NovaChatMode>("chat")
|
||||
const chatModeRef = useRef(chatMode)
|
||||
chatModeRef.current = chatMode
|
||||
const [researchRun, setResearchRun] = useState<NovaResearchRun | null>(null)
|
||||
const [researchConnection, setResearchConnection] =
|
||||
useState<NovaResearchConnectionState>({
|
||||
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<ChatAttachmentDraft>) => {
|
||||
setAttachmentDrafts((prev) =>
|
||||
|
|
@ -902,6 +944,8 @@ export function ChatSidebar({
|
|||
text: string,
|
||||
source: ChatMessageSendSource,
|
||||
drafts = attachmentDrafts,
|
||||
modeOverride?: NovaChatMode,
|
||||
preserveComposer = false,
|
||||
): Promise<boolean> => {
|
||||
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}
|
||||
/>
|
||||
<ResearchModeSelector
|
||||
value={chatMode}
|
||||
onChange={handleChatModeChange}
|
||||
disabled={researchIsActive}
|
||||
/>
|
||||
<SpaceSelector
|
||||
selectedProjects={chatSpaceProjects}
|
||||
onValueChange={handleChatSpaceProjectsChange}
|
||||
|
|
@ -2060,6 +2401,18 @@ export function ChatSidebar({
|
|||
)}
|
||||
</div>
|
||||
))}
|
||||
{researchRun ? (
|
||||
<ResearchProgress
|
||||
run={researchRun}
|
||||
onCancel={cancelResearch}
|
||||
onSubmitClarification={handleSubmitResearchClarification}
|
||||
connectionState={researchConnection}
|
||||
onRetryConnection={handleRetryResearchConnection}
|
||||
onRetryFinalization={handleRetryResearchFinalization}
|
||||
onRunAgain={handleRunResearchAgain}
|
||||
className="mt-1"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -2140,6 +2493,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,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 ? <ChainOfThought messages={messages} /> : null
|
||||
|
|
@ -2182,10 +2544,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,
|
||||
|
|
@ -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") ? (
|
||||
<span className="inline-flex h-4 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] px-1.5 text-[9px] font-medium leading-none text-white/50 transition-colors group-hover/source:text-white/70 group-focus-within/source:text-white/70">
|
||||
{sourceId}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{children}
|
||||
<span className="ml-1 inline-flex h-3.5 min-w-3.5 translate-y-[-1px] items-center justify-center rounded-full border border-white/10 bg-white/[0.04] px-1 text-[9px] font-medium leading-none text-white/45 transition-colors group-hover/source:text-white/65 group-focus-within/source:text-white/65">
|
||||
{sourceId}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<span className="group/source relative inline rounded-[3px] border-b border-dotted border-white/20 bg-white/[0.025] px-px text-white/90 transition-colors hover:border-white/35 hover:bg-white/[0.045] focus-within:border-white/35 focus-within:bg-white/[0.045]">
|
||||
|
|
@ -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}
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-help border-0 bg-transparent p-0 text-inherit"
|
||||
>
|
||||
{children}
|
||||
{citationContent}
|
||||
</button>
|
||||
)}
|
||||
<span className="ml-1 inline-flex h-3.5 min-w-3.5 translate-y-[-1px] items-center justify-center rounded-full border border-white/10 bg-white/[0.04] px-1 text-[9px] font-medium leading-none text-white/45 transition-colors group-hover/source:text-white/65 group-focus-within/source:text-white/65">
|
||||
{sourceId}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 z-[1000] hidden w-72 -translate-x-1/2 pb-2 group-hover/source:block group-focus-within/source:block">
|
||||
<span className="pointer-events-auto block rounded-xl border border-white/10 bg-[#0B0F16]/95 p-3 text-left shadow-[0_16px_44px_rgba(0,0,0,0.48)] backdrop-blur-xl">
|
||||
<span className="mb-1 flex items-center justify-between gap-2">
|
||||
|
|
@ -940,7 +966,7 @@ function makeMarkdownComponents(
|
|||
}
|
||||
}
|
||||
|
||||
function WebSourcesPill({ sources }: { sources: SourceUrlPart[] }) {
|
||||
function SourcesPill({ sources }: { sources: ActionSource[] }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(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"}`}
|
||||
>
|
||||
<span className="flex -space-x-1.5">
|
||||
{faviconHosts.length > 0 ? (
|
||||
|
|
@ -995,16 +1022,11 @@ function WebSourcesPill({ sources }: { sources: SourceUrlPart[] }) {
|
|||
{expanded && (
|
||||
<div className="absolute bottom-full left-0 z-[1000] mb-2 w-[min(22rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-white/10 bg-[#0B0F16]/95 p-1.5 shadow-[0_12px_32px_rgba(0,0,0,0.4)] backdrop-blur-xl">
|
||||
<ul className="max-h-72 list-none space-y-0.5 overflow-y-auto">
|
||||
{sources.map((s) => {
|
||||
const host = sourceHost(s.url)
|
||||
return (
|
||||
<li key={s.sourceId}>
|
||||
<a
|
||||
href={s.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-start gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-white/[0.06]"
|
||||
>
|
||||
{sources.map((source) => {
|
||||
const host = source.url ? sourceHost(source.url) : null
|
||||
const content = (
|
||||
<>
|
||||
{host ? (
|
||||
<img
|
||||
src={faviconUrl(host)}
|
||||
alt=""
|
||||
|
|
@ -1012,15 +1034,35 @@ function WebSourcesPill({ sources }: { sources: SourceUrlPart[] }) {
|
|||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-white/80">
|
||||
{s.title?.trim() || host}
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-white/40">
|
||||
{host}
|
||||
</span>
|
||||
) : (
|
||||
<BookOpenIcon className="mt-0.5 size-4 shrink-0 text-white/35" />
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-white/80">
|
||||
{source.title}
|
||||
</span>
|
||||
</a>
|
||||
<span className="block truncate text-[11px] text-white/40">
|
||||
{source.subtitle || host || "Saved memory"}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<li key={source.id}>
|
||||
{source.url ? (
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-start gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-white/[0.06]"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<div className="flex items-start gap-2 rounded-lg px-2 py-1.5">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
|
@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
disabled={saved || saving}
|
||||
title={label}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] transition-colors",
|
||||
saved
|
||||
? "text-emerald-400/75"
|
||||
: saveState === "error"
|
||||
? "text-red-300/75 hover:bg-red-400/10"
|
||||
: "text-white/50 hover:bg-white/10 hover:text-white/80",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : saved ? (
|
||||
<CheckIcon className="size-3.5" />
|
||||
) : (
|
||||
<BookOpenIcon className="size-3.5" />
|
||||
)}
|
||||
<span>{saving ? "Saving…" : label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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<string>()
|
||||
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 (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
|
|
@ -1360,6 +1553,14 @@ export function AgentMessage({
|
|||
expandedMemories={expandedMemories}
|
||||
onToggle={onToggleMemories}
|
||||
/>
|
||||
{researchDuration ? (
|
||||
<p className="mb-1 text-xs text-white/40">
|
||||
Research completed in{" "}
|
||||
<span className="tabular-nums text-white/60">
|
||||
{researchDuration}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{message.parts.map((part, partIndex) => {
|
||||
if (part.type === "source-url") {
|
||||
|
|
@ -1410,13 +1611,18 @@ 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")}
|
||||
>
|
||||
<Streamdown components={markdownComponents}>
|
||||
{
|
||||
parseSourceAnnotatedMarkdown(runText, allowedSourceIds)
|
||||
.markdown
|
||||
}
|
||||
{researchMetadata
|
||||
? normalizeResearchMarkdownForDisplay(
|
||||
parseSourceAnnotatedMarkdown(
|
||||
runText,
|
||||
allowedSourceIds,
|
||||
).markdown,
|
||||
)
|
||||
: parseSourceAnnotatedMarkdown(runText, allowedSourceIds)
|
||||
.markdown}
|
||||
</Streamdown>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1479,16 +1685,38 @@ export function AgentMessage({
|
|||
onLike={onLike}
|
||||
onDislike={onDislike}
|
||||
/>
|
||||
{webSources.length > 0 && (
|
||||
{actionSources.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"transition-opacity duration-200",
|
||||
isHovered || isLastAgentMessage ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
<WebSourcesPill sources={webSources} />
|
||||
<SourcesPill sources={actionSources} />
|
||||
</div>
|
||||
)}
|
||||
{researchMetadata?.runId ? (
|
||||
<>
|
||||
<SaveResearchToMemoryButton
|
||||
runId={researchMetadata.runId}
|
||||
apiBase={researchApiBase}
|
||||
className={
|
||||
isHovered || isLastAgentMessage ? "opacity-100" : "opacity-0"
|
||||
}
|
||||
/>
|
||||
<a
|
||||
href={`${researchApiBase}/chat/research/${researchMetadata.runId}/report.md`}
|
||||
download
|
||||
title="Download research as Markdown"
|
||||
className={cn(
|
||||
"rounded p-1.5 text-white/50 transition-colors hover:bg-white/10 hover:text-white/80",
|
||||
isHovered || isLastAgentMessage ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
{responseModelLabel && (
|
||||
<span
|
||||
className={cn(
|
||||
|
|
|
|||
242
apps/web/components/chat/research-clarification.tsx
Normal file
242
apps/web/components/chat/research-clarification.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
CheckIcon,
|
||||
Loader2Icon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import type {
|
||||
NovaResearchClarificationAnswer,
|
||||
NovaResearchClarificationRequest,
|
||||
} from "@/lib/nova-research"
|
||||
|
||||
export function ResearchClarification({
|
||||
request,
|
||||
onSubmit,
|
||||
}: {
|
||||
request: NovaResearchClarificationRequest
|
||||
onSubmit: (answers: NovaResearchClarificationAnswer[]) => Promise<void>
|
||||
}) {
|
||||
const [questionIndex, setQuestionIndex] = useState(0)
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({})
|
||||
const [otherByQuestion, setOtherByQuestion] = useState<
|
||||
Record<string, boolean>
|
||||
>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="mt-3 overflow-hidden rounded-[14px] bg-[#191D24] shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="border-white/[0.06] border-b px-4 py-4 sm:px-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white/85">
|
||||
A few details first
|
||||
</p>
|
||||
{request.intro ? (
|
||||
<p className="mt-0.5 text-xs text-white/45">{request.intro}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 text-xs tabular-nums text-white/35">
|
||||
{questionIndex + 1} / {request.questions.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-1.5">
|
||||
{request.questions.map((item, index) => (
|
||||
<span
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"h-1 flex-1 rounded-full transition-colors",
|
||||
index === questionIndex
|
||||
? "bg-[#A1A1AA]"
|
||||
: index < questionIndex
|
||||
? "bg-[#525D6E]"
|
||||
: "bg-[#0D121A]",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-4 sm:px-5 sm:py-5">
|
||||
<h3 className="text-sm leading-relaxed text-white/85">
|
||||
{question.question}
|
||||
</h3>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
{question.options.map((option) => {
|
||||
const selected = !usingOther && answer === option.label
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={option.label}
|
||||
aria-pressed={selected}
|
||||
onClick={() => {
|
||||
setAnswers((current) => ({
|
||||
...current,
|
||||
[question.id]: option.label,
|
||||
}))
|
||||
setOtherByQuestion((current) => ({
|
||||
...current,
|
||||
[question.id]: false,
|
||||
}))
|
||||
}}
|
||||
className={cn(
|
||||
"flex min-h-[72px] items-start gap-3 rounded-[12px] bg-[#14161A] px-4 py-3 text-left shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/25",
|
||||
selected
|
||||
? "bg-[#1B1F24] ring-1 ring-inset ring-white/15"
|
||||
: "hover:bg-[#16181D]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border",
|
||||
selected
|
||||
? "border-[#FAFAFA] bg-[#FAFAFA] text-[#0D121A]"
|
||||
: "border-[#525D6E] bg-[#0D121A]",
|
||||
)}
|
||||
>
|
||||
{selected ? <CheckIcon className="size-2.5" /> : null}
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-xs font-medium text-[#FAFAFA]">
|
||||
{option.label}
|
||||
</span>
|
||||
{option.description ? (
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-[#A1A1AA]">
|
||||
{option.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{question.allowOther !== false ? (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-[12px] bg-[#14161A] shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] transition-colors",
|
||||
usingOther
|
||||
? "bg-[#1B1F24] ring-1 ring-inset ring-white/15 sm:col-span-2 sm:flex sm:items-center"
|
||||
: "hover:bg-[#16181D]",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={usingOther}
|
||||
aria-pressed={usingOther}
|
||||
onClick={() => {
|
||||
setOtherByQuestion((current) => ({
|
||||
...current,
|
||||
[question.id]: true,
|
||||
}))
|
||||
setAnswers((current) => ({ ...current, [question.id]: "" }))
|
||||
}}
|
||||
className={cn(
|
||||
"flex min-h-14 w-full items-center gap-3 px-4 py-3 text-left focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-white/25",
|
||||
usingOther && "sm:w-[220px] sm:shrink-0",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-4 shrink-0 items-center justify-center rounded-full border",
|
||||
usingOther
|
||||
? "border-[#FAFAFA] bg-[#FAFAFA] text-[#0D121A]"
|
||||
: "border-[#525D6E] bg-[#0D121A]",
|
||||
)}
|
||||
>
|
||||
{usingOther ? <CheckIcon className="size-2.5" /> : null}
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-xs font-medium text-[#FAFAFA]">
|
||||
Something else
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-[#A1A1AA]">
|
||||
Write a custom answer
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{usingOther ? (
|
||||
<div className="flex-1 px-3 pb-3 sm:py-2.5 sm:pl-0">
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Custom answer"
|
||||
value={answer}
|
||||
onChange={(event) =>
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <p className="mt-3 text-xs text-red-300">{error}</p> : null}
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuestionIndex((index) => Math.max(0, index - 1))}
|
||||
disabled={questionIndex === 0 || submitting}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-4 text-xs text-[#A1A1AA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:pointer-events-none disabled:opacity-25"
|
||||
>
|
||||
<ArrowLeftIcon className="size-3.5" /> Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isLast) void submit()
|
||||
else setQuestionIndex((index) => index + 1)
|
||||
}}
|
||||
disabled={!canContinue || submitting}
|
||||
className="inline-flex h-9 min-w-[116px] items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-xs font-medium text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:pointer-events-none disabled:opacity-35"
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
) : isLast ? (
|
||||
<CheckIcon className="size-3.5" />
|
||||
) : (
|
||||
<ArrowRightIcon className="size-3.5" />
|
||||
)}
|
||||
{isLast ? "Start research" : "Next"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
379
apps/web/components/chat/research-progress.tsx
Normal file
379
apps/web/components/chat/research-progress.tsx
Normal file
|
|
@ -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 || /<response\b|(?:^|\n)#{1,6}\s/.test(label))
|
||||
) {
|
||||
return "Nova prepared a draft report."
|
||||
}
|
||||
return label.length > 600 ? `${label.slice(0, 597)}…` : label
|
||||
}
|
||||
|
||||
function eventIcon(event: NovaResearchEvent) {
|
||||
if (event.status === "running" || event.status === "pending") {
|
||||
return <span className="size-2 animate-pulse rounded-full bg-white/45" />
|
||||
}
|
||||
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/80" />
|
||||
}
|
||||
if (event.type === "assistant") {
|
||||
return <ArrowRightIcon className="size-3.5 text-white/45" />
|
||||
}
|
||||
return <CircleIcon className="size-3 fill-white/35 text-white/35" />
|
||||
}
|
||||
|
||||
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> | void
|
||||
onSubmitClarification: (
|
||||
requestId: string,
|
||||
answers: NovaResearchClarificationAnswer[],
|
||||
) => Promise<void>
|
||||
connectionState?: NovaResearchConnectionState
|
||||
onRetryConnection?: () => void
|
||||
onRetryFinalization?: () => Promise<void>
|
||||
onRunAgain?: () => Promise<void>
|
||||
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<string | null>(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<typeof pendingAction>,
|
||||
callback: (() => Promise<void> | 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 (
|
||||
<section
|
||||
className={cn("w-full py-1", dmSansClassName(), className)}
|
||||
aria-live="polite"
|
||||
data-testid="nova-research-activity"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{awaitingInput ? (
|
||||
<CircleHelpIcon className="size-4 shrink-0 text-white/45" />
|
||||
) : active ? (
|
||||
<SuperLoader
|
||||
size={34}
|
||||
label={statusLabel}
|
||||
colorClassName="text-[#4BA0FA]"
|
||||
className="!min-w-0 !gap-0 [&>span]:hidden"
|
||||
/>
|
||||
) : failed ? (
|
||||
<XCircleIcon className="size-4 shrink-0 text-red-400" />
|
||||
) : cancelled ? (
|
||||
<SquareIcon className="size-3.5 shrink-0 text-white/40" />
|
||||
) : (
|
||||
<CheckCircle2Icon className="size-4 shrink-0 text-emerald-400/80" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
className="flex min-w-0 items-center gap-1.5 text-left text-sm text-white/72 transition-colors hover:text-white"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<span className="font-medium">{statusLabel}</span>
|
||||
<span className="text-xs text-white/35">
|
||||
· {run.toolCallCount} tool call{run.toolCallCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
{expanded ? (
|
||||
<ChevronUpIcon className="size-3.5 text-white/35" />
|
||||
) : (
|
||||
<ChevronDownIcon className="size-3.5 text-white/35" />
|
||||
)}
|
||||
</button>
|
||||
{active ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void invokeAction("cancel", onCancel)}
|
||||
disabled={pendingAction !== null}
|
||||
className="ml-auto flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-[11px] text-white/40 transition-colors hover:bg-white/[0.06] hover:text-white/75"
|
||||
>
|
||||
<SquareIcon className="size-2.5 fill-current" />
|
||||
{pendingAction === "cancel" ? "Stopping…" : "Stop"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{active && connectionState.status === "reconnecting" ? (
|
||||
<output className="mt-3 flex items-center gap-2 rounded-xl bg-[#14161A] px-3 py-2 text-xs text-white/55 shadow-[inset_1.5px_1.5px_3px_rgba(0,0,0,0.55)]">
|
||||
<RefreshCwIcon className="size-3.5 animate-spin text-white/45" />
|
||||
<span className="min-w-0 flex-1">
|
||||
{connectionState.attempts >= 3
|
||||
? "Connection lost. Reconnecting to research updates…"
|
||||
: "Research updates are delayed. Reconnecting…"}
|
||||
</span>
|
||||
{onRetryConnection ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetryConnection}
|
||||
className="shrink-0 rounded-full bg-[#0D121A] px-3 py-1.5 text-white/70 transition-colors hover:text-white"
|
||||
>
|
||||
Retry now
|
||||
</button>
|
||||
) : null}
|
||||
</output>
|
||||
) : null}
|
||||
|
||||
{failure ? (
|
||||
<div
|
||||
className="mt-3 rounded-xl bg-[#191D24] p-3.5 shadow-[inset_1.5px_1.5px_3px_rgba(0,0,0,0.55)]"
|
||||
role="alert"
|
||||
>
|
||||
<p className="text-sm font-medium text-white/80">{failure.title}</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-white/50">
|
||||
{failure.description}
|
||||
</p>
|
||||
{failure.incidentId ? (
|
||||
<p className="mt-1.5 font-mono text-[10px] text-white/30">
|
||||
Reference: {failure.incidentId}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{failure.canRetryFinalization && onRetryFinalization ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void invokeAction("retry-finalization", onRetryFinalization)
|
||||
}
|
||||
disabled={pendingAction !== null}
|
||||
className="rounded-full bg-[#0D121A] px-3 py-1.5 text-xs text-white/75 transition-colors hover:text-white disabled:opacity-50"
|
||||
>
|
||||
{pendingAction === "retry-finalization"
|
||||
? "Retrying…"
|
||||
: (failure.retryLabel ?? "Retry report")}
|
||||
</button>
|
||||
) : null}
|
||||
{onRunAgain ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void invokeAction("run-again", onRunAgain)}
|
||||
disabled={pendingAction !== null}
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs text-white/45 transition-colors hover:bg-white/[0.05] hover:text-white/75 disabled:opacity-50"
|
||||
>
|
||||
<RotateCcwIcon className="size-3" />
|
||||
{pendingAction === "run-again" ? "Starting…" : "Run again"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : cancelled && onRunAgain ? (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void invokeAction("run-again", onRunAgain)}
|
||||
disabled={pendingAction !== null}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[#14161A] px-3 py-1.5 text-xs text-white/55 transition-colors hover:text-white disabled:opacity-50"
|
||||
>
|
||||
<RotateCcwIcon className="size-3" />
|
||||
{pendingAction === "run-again" ? "Starting…" : "Run again"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{actionError ? (
|
||||
<p className="mt-2 text-xs text-red-300/80" role="alert">
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
{clarification ? (
|
||||
<ResearchClarification
|
||||
key={clarification.id}
|
||||
request={clarification}
|
||||
onSubmit={(answers) =>
|
||||
onSubmitClarification(clarification.id, answers)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{expanded && !clarification ? (
|
||||
<div className="mt-3 ml-[7px] border-white/10 border-l pl-5">
|
||||
{run.plan ? (
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 text-[10px] font-medium uppercase tracking-[0.12em] text-white/30">
|
||||
Plan
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{run.plan.steps.map((step) => {
|
||||
const displayStatus = researchPlanStepDisplayStatus(
|
||||
run.status,
|
||||
step.status,
|
||||
)
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2 text-xs",
|
||||
displayStatus === "complete"
|
||||
? "text-white/55"
|
||||
: "text-white/35",
|
||||
)}
|
||||
>
|
||||
{displayStatus === "complete" ? (
|
||||
<CheckCircle2Icon className="size-3.5 shrink-0 text-emerald-400/80" />
|
||||
) : displayStatus === "in_progress" ? (
|
||||
<span className="size-2 shrink-0 animate-pulse rounded-full bg-white/45" />
|
||||
) : displayStatus === "failed" ? (
|
||||
<XCircleIcon className="size-3.5 shrink-0 text-red-400/75" />
|
||||
) : displayStatus === "cancelled" ? (
|
||||
<SquareIcon className="size-3 shrink-0 text-white/30" />
|
||||
) : (
|
||||
<CircleIcon className="size-3.5 shrink-0 text-white/15" />
|
||||
)}
|
||||
<span>{step.title}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="max-h-72 space-y-3 overflow-y-auto pr-2">
|
||||
{timeline.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-xs text-white/45">
|
||||
<CircleIcon className="size-3 fill-white/30 text-white/30" />
|
||||
Preparing the investigation…
|
||||
</div>
|
||||
) : (
|
||||
timeline.map((event) => {
|
||||
const label = eventLabel(event)
|
||||
if (!label) return null
|
||||
const toolFailureDescription =
|
||||
researchToolFailureDescription(event)
|
||||
return (
|
||||
<div key={event.id} className="flex items-start gap-2.5">
|
||||
<div className="mt-0.5 flex size-3.5 shrink-0 items-center justify-center">
|
||||
{eventIcon(event)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-xs leading-relaxed text-white/65">
|
||||
{label}
|
||||
{toolFailureDescription ? (
|
||||
<div className="mt-0.5 text-[10px] text-white/30">
|
||||
{toolFailureDescription}
|
||||
</div>
|
||||
) : event.type === "tool" && event.toolName ? (
|
||||
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-white/25">
|
||||
<SearchIcon className="size-2.5" /> {event.toolName}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -192,6 +192,7 @@
|
|||
|
||||
/* Loose lists wrap item content in <p> whose top margin detaches the bullet */
|
||||
.chat-markdown-content li > p:first-child {
|
||||
display: inline;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
360
apps/web/lib/nova-research.ts
Normal file
360
apps/web/lib/nova-research.ts
Normal file
|
|
@ -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<string, string>()
|
||||
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<string, number>()
|
||||
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<NovaResearchClarificationRequest>
|
||||
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"
|
||||
)
|
||||
}
|
||||
|
|
@ -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]",
|
||||
"`[S1]`",
|
||||
"```",
|
||||
"[S1]",
|
||||
"```",
|
||||
].join("\n")
|
||||
|
||||
expect(parseSourceAnnotatedMarkdown(input, new Set(["S1"])).markdown).toBe(
|
||||
input,
|
||||
)
|
||||
})
|
||||
|
||||
it("strips source markup for copy text", () => {
|
||||
expect(
|
||||
stripSourceMarkup('Alpha <response source="S1">Beta</response>'),
|
||||
|
|
|
|||
|
|
@ -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++
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue