diff --git a/next.config.ts b/next.config.ts index b665d61..490c3cc 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,7 +3,11 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { cacheComponents: true, reactCompiler: true, - serverExternalPackages: ["pg", "@neondatabase/serverless", "postgres"], + serverExternalPackages: [ + "pg", + "@neondatabase/serverless", + "postgres", + ], allowedDevOrigins: [ "127.0.0.1", "notebook.local.knowhereto.ai", diff --git a/package.json b/package.json index a0f56ea..aca7377 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@ai-sdk/react": "^3.0.177", + "@antv/chart-visualization-skills": "0.1.3", "@effect/platform": "^0.96.1", "@neondatabase/serverless": "^1.1.0", "@ontos-ai/knowhere-sdk": "^0.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3013e92..d6213a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@ai-sdk/react': specifier: ^3.0.177 version: 3.0.177(react@19.2.4)(zod@4.4.3) + '@antv/chart-visualization-skills': + specifier: 0.1.3 + version: 0.1.3 '@effect/platform': specifier: ^0.96.1 version: 0.96.1(effect@3.21.2) @@ -218,6 +221,10 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@antv/chart-visualization-skills@0.1.3': + resolution: {integrity: sha512-znoffbHkpo37zWAB+KjemVAk1arviP9tqlM5E0jZTp2NyYxspPiuCCMCFQNel9vNpn6g3mSErNt4s+BDpzXutg==} + hasBin: true + '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -2675,6 +2682,10 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -3245,6 +3256,10 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -3461,6 +3476,10 @@ packages: resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -3627,6 +3646,10 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -3786,6 +3809,10 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -3847,6 +3874,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -4712,6 +4743,10 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -4888,6 +4923,10 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -5419,6 +5458,11 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@antv/chart-visualization-skills@0.1.3': + dependencies: + commander: 12.1.0 + gray-matter: 4.0.3 + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 @@ -7517,6 +7561,8 @@ snapshots: commander@11.1.0: {} + commander@12.1.0: {} + commander@14.0.3: {} concat-map@0.0.1: {} @@ -8214,6 +8260,10 @@ snapshots: transitivePeerDependencies: - supports-color + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + extend@3.0.2: {} fast-check@3.23.2: @@ -8423,6 +8473,13 @@ snapshots: graphql@16.13.2: {} + gray-matter@4.0.3: + dependencies: + js-yaml: 3.14.2 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -8599,6 +8656,8 @@ snapshots: is-docker@3.0.0: {} + is-extendable@0.1.1: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -8728,6 +8787,11 @@ snapshots: js-tokens@4.0.0: {} + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -8804,6 +8868,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kind-of@6.0.3: {} + kleur@3.0.3: {} kleur@4.1.5: {} @@ -9935,6 +10001,11 @@ snapshots: scheduler@0.27.0: {} + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + semver@6.3.1: {} semver@7.7.4: {} @@ -10226,6 +10297,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-bom-string@1.0.0: {} + strip-bom@3.0.0: {} strip-final-newline@2.0.0: {} diff --git a/public/icons/official-library/pdf-document.svg b/public/icons/official-library/pdf-document.svg new file mode 100644 index 0000000..460463b --- /dev/null +++ b/public/icons/official-library/pdf-document.svg @@ -0,0 +1,9 @@ + + + + + + + + .pdf + diff --git a/public/images/official-library/financial-reports.svg b/public/images/official-library/financial-reports.svg new file mode 100644 index 0000000..541468f --- /dev/null +++ b/public/images/official-library/financial-reports.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/official-library/other-docs.svg b/public/images/official-library/other-docs.svg new file mode 100644 index 0000000..6bb6039 --- /dev/null +++ b/public/images/official-library/other-docs.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/official-library/research-papers.svg b/public/images/official-library/research-papers.svg new file mode 100644 index 0000000..e1171e5 --- /dev/null +++ b/public/images/official-library/research-papers.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/official-library/stem-books.svg b/public/images/official-library/stem-books.svg new file mode 100644 index 0000000..3353699 --- /dev/null +++ b/public/images/official-library/stem-books.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/app/api/chat/diagram/route.test.ts b/src/app/api/chat/diagram/route.test.ts new file mode 100644 index 0000000..cdb49dd --- /dev/null +++ b/src/app/api/chat/diagram/route.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + generateChatDiagramSpec: vi.fn(), + getAuthenticated: vi.fn(), + getAuthenticatedWithClient: vi.fn(), +})) + +vi.mock("@/domains/chat/diagram", async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + generateChatDiagramSpec: mocks.generateChatDiagramSpec, + } +}) + +vi.mock("@/domains/workspace/request-context", () => ({ + notebookRequestContext: { + getAuthenticated: mocks.getAuthenticated, + getAuthenticatedWithClient: mocks.getAuthenticatedWithClient, + }, +})) + +import { POST } from "./route" + +describe("POST /api/chat/diagram", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("requires authentication without provisioning a Knowhere client", async () => { + mocks.getAuthenticated.mockResolvedValue({ + user: { id: "user_1", name: null, email: null }, + workspace: { id: "workspace_1", namespace: "notebook-workspace_1" }, + }) + mocks.generateChatDiagramSpec.mockResolvedValue({ + type: "none", + reason: "The answer did not contain enough concrete data for a chart.", + }) + + const response = await POST( + new Request("http://localhost:3001/api/chat/diagram", { + method: "POST", + body: JSON.stringify({ answer: "Revenue was 42." }), + }), + ) + + await expect(response.json()).resolves.toEqual({ + diagram: { + type: "none", + reason: "The answer did not contain enough concrete data for a chart.", + }, + }) + expect(response.status).toBe(200) + expect(mocks.getAuthenticated).toHaveBeenCalledOnce() + expect(mocks.getAuthenticatedWithClient).not.toHaveBeenCalled() + expect(mocks.generateChatDiagramSpec).toHaveBeenCalledWith({ + answer: "Revenue was 42.", + }) + }) +}) diff --git a/src/app/api/chat/diagram/route.ts b/src/app/api/chat/diagram/route.ts new file mode 100644 index 0000000..7443ad0 --- /dev/null +++ b/src/app/api/chat/diagram/route.ts @@ -0,0 +1,41 @@ +import type { NextResponse } from "next/server" + +import { + generateChatDiagramSpec, + parseChatDiagramRequestBody, +} from "@/domains/chat/diagram" +import { notebookRequestContext } from "@/domains/workspace/request-context" +import { isAuthError } from "@/integrations/dashboard/api-key-service" +import { summarizeUnknownError } from "@/lib/format-log-value" +import { logger } from "@/lib/logger" +import { nextRouteResponse } from "@/lib/next-route-response" +import { routeResult } from "@/lib/route-result" + +export async function POST(request: Request): Promise { + const body = parseChatDiagramRequestBody( + await routeResult.readJsonOrNull(request), + ) + if (!body.ok) { + return nextRouteResponse.toNextResponse( + routeResult.error(body.status, body.message), + ) + } + + try { + await notebookRequestContext.getAuthenticated() + const diagram = await generateChatDiagramSpec({ + answer: body.value.answer, + }) + return nextRouteResponse.toNextResponse(routeResult.ok({ diagram })) + } catch (error) { + const detail = summarizeUnknownError(error) + const status = isAuthError({ message: detail }) ? 401 : 502 + logger.error("chat-diagram: generation failed", { + status, + detail, + }) + return nextRouteResponse.toNextResponse( + routeResult.error(status, `Diagram generation failed: ${detail}`), + ) + } +} diff --git a/src/components/chat-composer.test.ts b/src/components/chat-composer.test.ts index 7e893a1..7041fc5 100644 --- a/src/components/chat-composer.test.ts +++ b/src/components/chat-composer.test.ts @@ -39,7 +39,7 @@ describe("ChatComposer", () => { ); expect( - screen.queryByPlaceholderText("Upload a document to start asking questions."), + screen.queryByPlaceholderText("Add a ready source to start asking questions."), ).toBeNull(); const loginButton = screen.getByRole("button", { @@ -50,4 +50,36 @@ describe("ChatComposer", () => { await user.click(loginButton); expect(onLoginClick).toHaveBeenCalledOnce(); }); + + it("inserts expert templates and highlights bracket placeholders", async () => { + const user = userEvent.setup(); + + render(React.createElement(ChatComposer)); + + await user.click(screen.getByRole("button", { name: "Create" })); + await user.click( + screen.getByRole("menuitem", { name: /IPO Prospectus Risk Mining/ }), + ); + + const input = screen.getByPlaceholderText( + "Ask a question about your documents…", + ) as HTMLTextAreaElement; + expect(input.value).toContain("prospectus of [Company Name]"); + expect(screen.getByText("[Company Name]").className).toContain( + "text-primary", + ); + }); + + it("renders a larger embedded composer input surface", () => { + render(React.createElement(ChatComposer)); + + const input = screen.getByPlaceholderText( + "Ask a question about your documents…", + ); + + expect(input.className).toContain("h-[128px]"); + expect(input.className).toContain("border-0"); + expect(input.className).toContain("shadow-none"); + expect(screen.getByRole("button", { name: "Create" })).toBeTruthy(); + }); }); diff --git a/src/components/chat-composer.tsx b/src/components/chat-composer.tsx index 5b75e95..807f298 100644 --- a/src/components/chat-composer.tsx +++ b/src/components/chat-composer.tsx @@ -1,33 +1,53 @@ "use client"; import { + useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactElement, + type UIEvent, } from "react"; -import { Send } from "lucide-react"; +import { BarChart3, FileText, Plus, Send } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Spinner } from "@/components/ui/spinner"; import { Textarea } from "@/components/ui/textarea"; +import { chatPromptTemplates } from "@/domains/chat/prompt-templates"; const chatComposerId = "chat-composer"; +const placeholderPattern = /(\[[^\]\r\n]{1,80}\])/gu; +const placeholderSegmentPattern = /^\[[^\]\r\n]{1,80}\]$/u; export type ChatComposerProps = { + readonly canCreateDiagram?: boolean; readonly isDisabled?: boolean; + readonly isCreatingDiagram?: boolean; readonly isSending?: boolean; + readonly onCreateDiagram?: () => void; readonly onLoginClick?: () => void; readonly onSend?: (text: string) => void; }; export function ChatComposer({ + canCreateDiagram = false, isDisabled = false, + isCreatingDiagram = false, isSending = false, + onCreateDiagram, onLoginClick, onSend, }: ChatComposerProps): ReactElement { const [input, setInput] = useState(""); + const [textareaScrollTop, setTextareaScrollTop] = useState(0); + const textareaRef = useRef(null); const trimmedInput = input.trim(); const canSend = !isDisabled && !isSending && trimmedInput.length > 0; @@ -46,6 +66,20 @@ export function ChatComposer({ if (!canSend) return; onSend?.(trimmedInput); setInput(""); + setTextareaScrollTop(0); + } + + function handleTemplateSelect(prompt: string): void { + setInput(prompt); + setTextareaScrollTop(0); + requestAnimationFrame(() => { + textareaRef.current?.focus(); + textareaRef.current?.setSelectionRange(prompt.length, prompt.length); + }); + } + + function handleTextareaScroll(event: UIEvent): void { + setTextareaScrollTop(event.currentTarget.scrollTop); } return ( @@ -64,31 +98,52 @@ export function ChatComposer({ ) : ( <> -
+
+ {input.length > 0 && ( + + )}