diff --git a/src/components/chat-composer.test.ts b/src/components/chat-composer.test.ts index 1d7051b..a12264a 100644 --- a/src/components/chat-composer.test.ts +++ b/src/components/chat-composer.test.ts @@ -28,10 +28,51 @@ describe("ChatComposer", () => { await user.type(input, " Summarize this document "); await user.click(screen.getByRole("button", { name: "Send message" })); - expect(onSend).toHaveBeenCalledWith("Summarize this document"); + expect(onSend).toHaveBeenCalledWith("Summarize this document", { + useAgentic: true, + }); expect(input.value).toBe(""); }); + it("defaults to deep search enabled and explains the toggle", async () => { + const user = userEvent.setup(); + + render(React.createElement(ChatComposer)); + + const toggle = screen.getByRole("checkbox", { + name: "Deep search", + }); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + + await user.hover(toggle); + + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip.textContent).toContain( + "Deep search plans document selection and navigation", + ); + }); + + it("sends useAgentic false after toggling deep search off", async () => { + const user = userEvent.setup(); + const onSend = vi.fn(); + + render(React.createElement(ChatComposer, { onSend })); + + const toggle = screen.getByRole("checkbox", { + name: "Deep search", + }); + await user.click(toggle); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + + const input = getComposerTextArea(); + await user.type(input, "Quick summary"); + await user.click(screen.getByRole("button", { name: "Send message" })); + + expect(onSend).toHaveBeenCalledWith("Quick summary", { + useAgentic: false, + }); + }); + it("caps long prompts and resets the composer after sending", async () => { const user = userEvent.setup(); const onSend = vi.fn(); diff --git a/src/components/chat-composer.tsx b/src/components/chat-composer.tsx index 4cfa62c..4aab263 100644 --- a/src/components/chat-composer.tsx +++ b/src/components/chat-composer.tsx @@ -13,6 +13,7 @@ import { import { BarChart3, FileText, Plus, Send } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { DropdownMenu, DropdownMenuContent, @@ -22,6 +23,12 @@ import { } from "@/components/ui/dropdown-menu"; import { Spinner } from "@/components/ui/spinner"; import { Textarea } from "@/components/ui/textarea"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { chatPromptTemplates } from "@/domains/chat/prompt-templates"; const chatComposerName = "chat-composer"; const chatComposerTextAreaMinHeight = 128; @@ -33,6 +40,10 @@ type TextRange = { readonly end: number; }; +export type ChatSendOptions = { + readonly useAgentic: boolean; +}; + export type ChatComposerProps = { readonly canCreateDiagram?: boolean; readonly isDisabled?: boolean; @@ -40,7 +51,7 @@ export type ChatComposerProps = { readonly isSending?: boolean; readonly onCreateDiagram?: () => void; readonly onLoginClick?: () => void; - readonly onSend?: (text: string) => void; + readonly onSend?: (text: string, options: ChatSendOptions) => void; }; export function ChatComposer({ @@ -53,6 +64,7 @@ export function ChatComposer({ onSend, }: ChatComposerProps): ReactElement { const [input, setInput] = useState(""); + const [useAgentic, setUseAgentic] = useState(true); const composerInputId = useId(); const pendingTemplatePromptRef = useRef(null); const textareaRef = useRef(null); @@ -79,7 +91,7 @@ export function ChatComposer({ function handleSend(): void { if (!canSend) return; - onSend?.(trimmedInput); + onSend?.(trimmedInput, { useAgentic }); setInput(""); } @@ -177,22 +189,53 @@ export function ChatComposer({ onCreateDiagram={onCreateDiagram} onTemplateSelect={handleTemplateSelect} /> - +
+ + + + + + + Deep search plans document selection and navigation for + more thorough answers. Turn off for faster classic search. + + + + +
)} diff --git a/src/components/chat-panel.test.ts b/src/components/chat-panel.test.ts index 54ccce5..f1bd6d1 100644 --- a/src/components/chat-panel.test.ts +++ b/src/components/chat-panel.test.ts @@ -254,7 +254,9 @@ describe("ChatPanel", () => { ); await user.click(screen.getByRole("button", { name: "Send message" })); - expect(onSend).toHaveBeenCalledWith("Summarize revenue"); + expect(onSend).toHaveBeenCalledWith("Summarize revenue", { + useAgentic: true, + }); expect( analyticsMocks.trackNotebookAssistantQuestionSubmitted, ).toHaveBeenCalledWith({ diff --git a/src/components/chat-panel.tsx b/src/components/chat-panel.tsx index 92d3cbf..1f0d1e8 100644 --- a/src/components/chat-panel.tsx +++ b/src/components/chat-panel.tsx @@ -6,7 +6,10 @@ import { type ReactElement, } from "react"; import { History, Plus } from "lucide-react"; -import { ChatComposer } from "@/components/chat-composer"; +import { + ChatComposer, + type ChatSendOptions, +} from "@/components/chat-composer"; import { ChatHistorySheet } from "@/components/chat-history-sheet"; import { ChatMessageList, @@ -47,7 +50,7 @@ export type ChatPanelProps = { messages: ChatMessageView[]; threads: ChatThreadView[]; activeThreadId?: string | null; - onSend?: (text: string) => void; + onSend?: (text: string, options: ChatSendOptions) => void; onNewChat?: () => void; onThreadSelect?: (threadId: string) => void; onThreadArchive?: (threadId: string) => void; @@ -152,7 +155,10 @@ export function ChatPanel({ } } - function handleComposerSend(text: string): void { + function handleComposerSend( + text: string, + options: ChatSendOptions, + ): void { if (isCreateDiagramCommand(text)) { void handleCreateDiagramCommand(); return; @@ -165,7 +171,7 @@ export function ChatPanel({ sourceCountSnapshot: sourceCount, messageLength: text.length, }); - onSend?.(text); + onSend?.(text, options); } return ( diff --git a/src/components/workspace-chat-workflow.test.ts b/src/components/workspace-chat-workflow.test.ts index f6b36a3..babf01a 100644 --- a/src/components/workspace-chat-workflow.test.ts +++ b/src/components/workspace-chat-workflow.test.ts @@ -84,12 +84,15 @@ describe("useWorkspaceChatWorkflow", () => { }) await act(async () => { - await result.current.handleChatSend("Summarize it") + await result.current.handleChatSend("Summarize it", { + useAgentic: true, + }) }) expect(mocks.sendChatMessage).toHaveBeenCalledWith({ message: "Summarize it", threadId: undefined, + useAgentic: true, excludedSourceIds: ["source_excluded"], }) await waitFor(() => { @@ -127,7 +130,9 @@ describe("useWorkspaceChatWorkflow", () => { }) await act(async () => { - await result.current.handleChatSend("What changed in Q4?") + await result.current.handleChatSend("What changed in Q4?", { + useAgentic: true, + }) }) expect(mocks.materializeDemoSources).toHaveBeenCalledWith({ @@ -172,7 +177,9 @@ describe("useWorkspaceChatWorkflow", () => { }) await act(async () => { - await result.current.handleChatSend("Summarize it") + await result.current.handleChatSend("Summarize it", { + useAgentic: true, + }) }) expect(mocks.materializeDemoSources).not.toHaveBeenCalled() diff --git a/src/components/workspace-chat-workflow.ts b/src/components/workspace-chat-workflow.ts index 2943a23..dd85576 100644 --- a/src/components/workspace-chat-workflow.ts +++ b/src/components/workspace-chat-workflow.ts @@ -12,6 +12,7 @@ import { type AnalyticsContext, } from "@/lib/posthog" import { workspaceClient } from "@/domains/workspace/client" +import type { ChatSendOptions } from "@/components/chat-composer" import { workspaceClientCache, type ChatThreadDetailResponse, @@ -43,7 +44,10 @@ type WorkspaceChatWorkflow = { readonly chat: ReturnType readonly chatThreads: ChatThreadView[] readonly handleArchiveChatThread: (threadId: string) => Promise - readonly handleChatSend: (text: string) => Promise + readonly handleChatSend: ( + text: string, + options: ChatSendOptions, + ) => Promise readonly handleCreateChatThread: () => Promise readonly handleRefreshActiveChatThread: () => Promise readonly handleSelectChatThread: (threadId: string) => void @@ -247,7 +251,10 @@ export function useWorkspaceChatWorkflow({ } } - async function handleChatSend(text: string): Promise { + async function handleChatSend( + text: string, + options: ChatSendOptions, + ): Promise { const sendStart = Date.now() const selectedSourcesCount = sources.filter( (source) => @@ -297,6 +304,7 @@ export function useWorkspaceChatWorkflow({ const body = await sendChatMessage({ message: text, threadId: chat.threadId ?? undefined, + useAgentic: options.useAgentic, excludedSourceIds: sources .filter((source) => source.excludedFromQuery) .map((source) => source.id), diff --git a/src/components/workspace-shell-layout.tsx b/src/components/workspace-shell-layout.tsx index 0a71d90..72c6724 100644 --- a/src/components/workspace-shell-layout.tsx +++ b/src/components/workspace-shell-layout.tsx @@ -8,6 +8,7 @@ import { } from "lucide-react" import { ChatPanel } from "@/components/chat-panel" +import type { ChatSendOptions } from "@/components/chat-composer" import { ChunksPanel } from "@/components/chunks-panel" import { MobileTabBar } from "@/components/mobile-tab-bar" import { OfficialLibraryPanel } from "@/components/official-library-panel" @@ -99,7 +100,10 @@ export type WorkspaceShellLayoutProps = { readonly onArchiveChatThread: (threadId: string) => void | Promise readonly onArchiveSource: (sourceId: string) => void | Promise readonly onRetrySource?: (sourceId: string) => void | Promise - readonly onChatSend: (text: string) => void | Promise + readonly onChatSend: ( + text: string, + options: ChatSendOptions, + ) => void | Promise readonly onCitationClick: ( citation: ChatCitationView, citationId: string, diff --git a/src/domains/chat/contracts.ts b/src/domains/chat/contracts.ts index e43b232..527b52c 100644 --- a/src/domains/chat/contracts.ts +++ b/src/domains/chat/contracts.ts @@ -73,6 +73,7 @@ export type AnswerQuestionInput = { namespaces?: readonly string[] sources: readonly Source[] excludedSourceIds: readonly string[] + useAgentic?: boolean retrieval: RetrievalClient knowledge?: Knowledge remoteDocumentClient?: NotebookKnowhereRemoteDocumentClient diff --git a/src/domains/chat/index.test.ts b/src/domains/chat/index.test.ts index 697fe77..f026dc6 100644 --- a/src/domains/chat/index.test.ts +++ b/src/domains/chat/index.test.ts @@ -85,7 +85,7 @@ describe("answerQuestionWithRetrieval", () => { namespace: "notebook-workspace", query: "What does the document say?", topK: 8, - useAgentic: false, + useAgentic: true, dataType: 1, excludeDocumentIds: ["doc_excluded", "doc_remote"], }); @@ -246,7 +246,7 @@ describe("answerQuestionWithRetrieval", () => { namespace: "notebook-workspace", query: "diagram", topK: 2, - useAgentic: false, + useAgentic: true, dataType: 3, excludeDocumentIds: ["doc_excluded"], }); @@ -772,7 +772,7 @@ describe("answerQuestionWithRetrieval", () => { namespace: "notebook-workspace", query: "SpaceX rocket photos", topK: 8, - useAgentic: false, + useAgentic: true, dataType: 3, }); expect(answer.answer).toBe("Use this launch photo."); @@ -1897,7 +1897,7 @@ describe("answerQuestionWithRetrieval", () => { namespace: "notebook-workspace", query: "公民身份证明 图片", topK: 8, - useAgentic: false, + useAgentic: true, dataType: 3, }); const imageCitations = answer.citations.filter( @@ -1988,7 +1988,7 @@ describe("answerQuestionWithRetrieval", () => { namespace: "notebook-workspace", query: "Tesla Q4 2025 Update energy generation and storage deployments", topK: 8, - useAgentic: false, + useAgentic: true, dataType: 1, }); expect(generateAnswer).toHaveBeenCalledWith({ @@ -2034,6 +2034,7 @@ describe("answerQuestionWithRetrieval", () => { namespace: "notebook-workspace", sources: [makeSource()], excludedSourceIds: [], + useAgentic: false, retrieval, generateAnswer, messages, @@ -2711,11 +2712,28 @@ describe("parseChatRequestBody", () => { value: { question: "What changed?", threadId: "thread_1", + useAgentic: true, excludedSourceIds: ["source_1", "source_2"], }, }); }); + it("keeps an explicit useAgentic choice from the request body", () => { + expect( + parseChatRequestBody({ + message: "Quick summary", + useAgentic: false, + }), + ).toEqual({ + ok: true, + value: { + question: "Quick summary", + useAgentic: false, + excludedSourceIds: [], + }, + }); + }); + it("rejects empty questions before retrieval or model calls", () => { expect(parseChatRequestBody({ message: " " })).toEqual({ ok: false, diff --git a/src/domains/chat/index.ts b/src/domains/chat/index.ts index d4dbcc5..f45c88d 100644 --- a/src/domains/chat/index.ts +++ b/src/domains/chat/index.ts @@ -42,9 +42,6 @@ import type { HardenableRetrievalResult } from "./media-asset-hardening" import { notebookKnowhereTools } from "./knowhere-tools" const DEFAULT_TOP_K = 8 -const NOTEBOOK_USE_AGENTIC_RETRIEVAL: NonNullable< - RetrievalQueryParams["useAgentic"] -> = false const MAX_AGENTIC_TOP_K = 12 const MAX_AGENTIC_MERGED_RESULT_COUNT = 24 const MAX_AGENTIC_MERGED_REFERENCED_CHUNK_COUNT = 24 @@ -146,6 +143,7 @@ export const answerQuestionWithRetrieval = ( input: queryInput, fallbackQuestion: question, namespace, + useAgentic: input.useAgentic ?? true, sources: input.sources, excludedSourceIds: input.excludedSourceIds, }) @@ -153,6 +151,7 @@ export const answerQuestionWithRetrieval = ( namespace, query: retrievalQueryParams.query, topK: retrievalQueryParams.topK, + useAgentic: retrievalQueryParams.useAgentic, dataType: retrievalQueryParams.dataType ?? null, signalPathCount: retrievalQueryParams.signalPaths?.length ?? 0, filterMode: retrievalQueryParams.filterMode ?? null, @@ -775,6 +774,7 @@ function buildRetrievalQueryParams(input: { readonly input: AgenticRetrievalQuery readonly fallbackQuestion: string readonly namespace: string + readonly useAgentic: boolean readonly sources: AnswerQuestionInput["sources"] readonly excludedSourceIds: readonly string[] }): RetrievalQueryParams { @@ -787,7 +787,7 @@ function buildRetrievalQueryParams(input: { namespace: input.namespace, query, topK: normalizeTopK(input.input.topK), - useAgentic: NOTEBOOK_USE_AGENTIC_RETRIEVAL, + useAgentic: input.useAgentic, dataType, ...(input.input.signalPaths && input.input.signalPaths.length > 0 ? { signalPaths: input.input.signalPaths } diff --git a/src/domains/chat/request.ts b/src/domains/chat/request.ts index c15a76e..3e56f06 100644 --- a/src/domains/chat/request.ts +++ b/src/domains/chat/request.ts @@ -3,6 +3,7 @@ import { Either, Schema } from "effect" export type ParsedChatRequest = { question: string threadId?: string + useAgentic: boolean excludedSourceIds: string[] } @@ -13,6 +14,7 @@ export type ParseChatRequestResult = const ChatRequestBody = Schema.Struct({ message: Schema.String, threadId: Schema.optional(Schema.String), + useAgentic: Schema.optional(Schema.Boolean), excludedSourceIds: Schema.optional(Schema.Array(Schema.Unknown)), }) @@ -43,6 +45,7 @@ export function parseChatRequestBody(body: unknown): ParseChatRequestResult { parsed.threadId !== undefined && parsed.threadId.length > 0 ? parsed.threadId : undefined, + useAgentic: parsed.useAgentic ?? true, excludedSourceIds, }, } diff --git a/src/domains/chat/route-answer.ts b/src/domains/chat/route-answer.ts index 3f9fc9d..ae8b13d 100644 --- a/src/domains/chat/route-answer.ts +++ b/src/domains/chat/route-answer.ts @@ -142,6 +142,7 @@ const answerChatEffect = (input: AnswerChatInput) => sources, question: body.value.question, threadId: body.value.threadId, + useAgentic: body.value.useAgentic, excludedSourceIds: body.value.excludedSourceIds, retrieval: client.retrieval, knowledge: knowhereResources.knowledge, diff --git a/src/domains/chat/route-service.test.ts b/src/domains/chat/route-service.test.ts index d66ae68..97c9f6d 100644 --- a/src/domains/chat/route-service.test.ts +++ b/src/domains/chat/route-service.test.ts @@ -137,6 +137,7 @@ describe("chat route services", () => { body: { message: " Summarize it ", threadId: "thread_1", + useAgentic: true, excludedSourceIds: ["source_skipped", null], }, }) @@ -159,6 +160,7 @@ describe("chat route services", () => { sources: [readySource], question: "Summarize it", threadId: "thread_1", + useAgentic: true, excludedSourceIds: ["source_skipped"], retrieval: client.retrieval, generateAnswer: mocks.generateAgenticOutputManifest, diff --git a/src/domains/chat/service.test.ts b/src/domains/chat/service.test.ts index 3a1ab82..6001171 100644 --- a/src/domains/chat/service.test.ts +++ b/src/domains/chat/service.test.ts @@ -57,7 +57,7 @@ describe("handleChatTurn", () => { namespace: "notebook-namespace", query: "What does the document say?", topK: 8, - useAgentic: false, + useAgentic: true, dataType: 1, excludeDocumentIds: ["doc_excluded"], }); @@ -223,7 +223,7 @@ describe("handleChatTurn", () => { namespace: "notebook-namespace", query: "Tesla Q4 2025 Update energy generation and storage deployments", topK: 8, - useAgentic: false, + useAgentic: true, dataType: 1, }); }); diff --git a/src/domains/chat/service.ts b/src/domains/chat/service.ts index 7a7b2b5..23bfa68 100644 --- a/src/domains/chat/service.ts +++ b/src/domains/chat/service.ts @@ -64,6 +64,7 @@ type ChatTurnInput = { sources: readonly Source[] question: string threadId?: string + useAgentic?: boolean excludedSourceIds: readonly string[] retrieval: RetrievalClient knowledge?: AnswerQuestionInput["knowledge"] @@ -126,6 +127,7 @@ export const handleChatTurnEffect = (input: ChatTurnInput) => namespace: input.workspace.namespace, namespaces: getCompatibleNamespaces(input.workspace), sources: readySources, + useAgentic: input.useAgentic ?? true, excludedSourceIds: input.excludedSourceIds, retrieval: input.retrieval, knowledge: input.knowledge, diff --git a/src/domains/workspace/client.ts b/src/domains/workspace/client.ts index a415d6a..7720383 100644 --- a/src/domains/workspace/client.ts +++ b/src/domains/workspace/client.ts @@ -50,6 +50,7 @@ type ChatThreadDetailResponse = ChatThreadResponse & { type ChatMessageRequest = { message: string threadId?: string + useAgentic: boolean excludedSourceIds: string[] }