From 393dac37a7fd5ef35ff96aade8d9659d2a711242 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 13 Feb 2026 23:49:55 -0800 Subject: [PATCH] adds search support with exa --- server/.gitignore | 1 - server/README.md | 5 + server/package-lock.json | 116 ++++ server/package.json | 3 +- .../migrations/20260128083259_/migration.sql | 61 ++ .../migration.sql | 43 ++ server/prisma/migrations/migration_lock.toml | 3 + server/prisma/schema.prisma | 52 ++ server/src/env.ts | 1 + server/src/routes.ts | 137 ++++ server/src/search/exa.ts | 14 + web/README.md | 10 +- web/src/App.tsx | 585 +++++++++++++----- web/src/lib/api.ts | 72 +++ 14 files changed, 948 insertions(+), 155 deletions(-) create mode 100644 server/prisma/migrations/20260128083259_/migration.sql create mode 100644 server/prisma/migrations/20260214074245_add_exa_search/migration.sql create mode 100644 server/prisma/migrations/migration_lock.toml create mode 100644 server/src/search/exa.ts diff --git a/server/.gitignore b/server/.gitignore index efdb682..71679b1 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -4,6 +4,5 @@ node_modules dev.db *.db *.db-journal -prisma/migrations .DS_Store dist diff --git a/server/README.md b/server/README.md index 3fb8c16..9c35ce7 100644 --- a/server/README.md +++ b/server/README.md @@ -39,6 +39,7 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev). - `OPENAI_API_KEY` - `ANTHROPIC_API_KEY` - `XAI_API_KEY` +- `EXA_API_KEY` ## API - `GET /health` @@ -49,6 +50,10 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev). - `POST /v1/chats/:chatId/messages` - `POST /v1/chat-completions` - `POST /v1/chat-completions/stream` (SSE) +- `GET /v1/searches` +- `POST /v1/searches` +- `GET /v1/searches/:searchId` +- `POST /v1/searches/:searchId/run` When `chatId` is provided to completion endpoints, you can send full conversation context. The server now stores only new non-assistant messages to avoid duplicate history rows. diff --git a/server/package-lock.json b/server/package-lock.json index 66630a8..a3ef01f 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -16,6 +16,7 @@ "@fastify/swagger-ui": "^5.2.5", "@prisma/client": "^6.6.0", "dotenv": "^17.2.3", + "exa-js": "^2.4.0", "fastify": "^5.7.2", "openai": "^6.16.0", "pino-pretty": "^13.1.3", @@ -960,6 +961,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/dateformat": { "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", @@ -1084,6 +1094,61 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/exa-js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/exa-js/-/exa-js-2.4.0.tgz", + "integrity": "sha512-zOFClWWZnh9wyUN3xiBgbhuT8DsS62uZJY+P9toN4KgxyCRQma7aU89/7UCtrXNwq5kEFAACw4eualXcTKjiAQ==", + "license": "MIT", + "dependencies": { + "cross-fetch": "~4.1.0", + "dotenv": "~16.4.7", + "openai": "^5.0.1", + "zod": "^3.22.0", + "zod-to-json-schema": "^3.20.0" + } + }, + "node_modules/exa-js/node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/exa-js/node_modules/openai": { + "version": "5.23.2", + "resolved": "https://registry.npmjs.org/openai/-/openai-5.23.2.tgz", + "integrity": "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/exa-js/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/fast-copy": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", @@ -1518,6 +1583,26 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -1890,6 +1975,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-algebra": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", @@ -1960,6 +2051,22 @@ "node": ">= 0.8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -1989,6 +2096,15 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } } } } diff --git a/server/package.json b/server/package.json index 33fe40b..c2cd11f 100644 --- a/server/package.json +++ b/server/package.json @@ -24,10 +24,11 @@ "@fastify/swagger-ui": "^5.2.5", "@prisma/client": "^6.6.0", "dotenv": "^17.2.3", + "exa-js": "^2.4.0", "fastify": "^5.7.2", "openai": "^6.16.0", - "prisma": "^6.6.0", "pino-pretty": "^13.1.3", + "prisma": "^6.6.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/server/prisma/migrations/20260128083259_/migration.sql b/server/prisma/migrations/20260128083259_/migration.sql new file mode 100644 index 0000000..803ac0c --- /dev/null +++ b/server/prisma/migrations/20260128083259_/migration.sql @@ -0,0 +1,61 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "handle" TEXT +); + +-- CreateTable +CREATE TABLE "Chat" ( + "id" TEXT NOT NULL PRIMARY KEY, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "title" TEXT, + "userId" TEXT, + CONSTRAINT "Chat_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Message" ( + "id" TEXT NOT NULL PRIMARY KEY, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "chatId" TEXT NOT NULL, + "role" TEXT NOT NULL, + "content" TEXT NOT NULL, + "name" TEXT, + "metadata" JSONB, + CONSTRAINT "Message_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "LlmCall" ( + "id" TEXT NOT NULL PRIMARY KEY, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "chatId" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "model" TEXT NOT NULL, + "request" JSONB NOT NULL, + "response" JSONB, + "inputTokens" INTEGER, + "outputTokens" INTEGER, + "totalTokens" INTEGER, + "latencyMs" INTEGER, + "error" TEXT, + CONSTRAINT "LlmCall_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_handle_key" ON "User"("handle"); + +-- CreateIndex +CREATE INDEX "Chat_userId_idx" ON "Chat"("userId"); + +-- CreateIndex +CREATE INDEX "Message_chatId_createdAt_idx" ON "Message"("chatId", "createdAt"); + +-- CreateIndex +CREATE INDEX "LlmCall_chatId_createdAt_idx" ON "LlmCall"("chatId", "createdAt"); + +-- CreateIndex +CREATE INDEX "LlmCall_provider_model_createdAt_idx" ON "LlmCall"("provider", "model", "createdAt"); diff --git a/server/prisma/migrations/20260214074245_add_exa_search/migration.sql b/server/prisma/migrations/20260214074245_add_exa_search/migration.sql new file mode 100644 index 0000000..3f57b50 --- /dev/null +++ b/server/prisma/migrations/20260214074245_add_exa_search/migration.sql @@ -0,0 +1,43 @@ +-- CreateTable +CREATE TABLE "Search" ( + "id" TEXT NOT NULL PRIMARY KEY, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "title" TEXT, + "query" TEXT, + "source" TEXT NOT NULL DEFAULT 'exa', + "requestId" TEXT, + "rawResponse" JSONB, + "latencyMs" INTEGER, + "error" TEXT, + "userId" TEXT, + CONSTRAINT "Search_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "SearchResult" ( + "id" TEXT NOT NULL PRIMARY KEY, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "searchId" TEXT NOT NULL, + "rank" INTEGER NOT NULL, + "title" TEXT, + "url" TEXT NOT NULL, + "publishedDate" TEXT, + "author" TEXT, + "text" TEXT, + "highlights" JSONB, + "highlightScores" JSONB, + "score" REAL, + "favicon" TEXT, + "image" TEXT, + CONSTRAINT "SearchResult_searchId_fkey" FOREIGN KEY ("searchId") REFERENCES "Search" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "Search_updatedAt_idx" ON "Search"("updatedAt"); + +-- CreateIndex +CREATE INDEX "Search_userId_idx" ON "Search"("userId"); + +-- CreateIndex +CREATE INDEX "SearchResult_searchId_rank_idx" ON "SearchResult"("searchId", "rank"); diff --git a/server/prisma/migrations/migration_lock.toml b/server/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/server/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index b6888e3..3b13edb 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -22,6 +22,10 @@ enum MessageRole { tool } +enum SearchSource { + exa +} + model User { id String @id @default(cuid()) createdAt DateTime @default(now()) @@ -31,6 +35,7 @@ model User { handle String? @unique chats Chat[] + searches Search[] } model Chat { @@ -92,3 +97,50 @@ model LlmCall { @@index([chatId, createdAt]) @@index([provider, model, createdAt]) } + +model Search { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + title String? + query String? + + source SearchSource @default(exa) + + requestId String? + rawResponse Json? + latencyMs Int? + error String? + + user User? @relation(fields: [userId], references: [id]) + userId String? + + results SearchResult[] + + @@index([updatedAt]) + @@index([userId]) +} + +model SearchResult { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + + search Search @relation(fields: [searchId], references: [id], onDelete: Cascade) + searchId String + + rank Int + + title String? + url String + publishedDate String? + author String? + text String? + highlights Json? + highlightScores Json? + score Float? + favicon String? + image String? + + @@index([searchId, rank]) +} diff --git a/server/src/env.ts b/server/src/env.ts index 1f66166..e56e9e8 100644 --- a/server/src/env.ts +++ b/server/src/env.ts @@ -12,6 +12,7 @@ const EnvSchema = z.object({ OPENAI_API_KEY: z.string().optional(), ANTHROPIC_API_KEY: z.string().optional(), XAI_API_KEY: z.string().optional(), + EXA_API_KEY: z.string().optional(), }); export type Env = z.infer; diff --git a/server/src/routes.ts b/server/src/routes.ts index 66b687d..61db21e 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -1,3 +1,4 @@ +import { performance } from "node:perf_hooks"; import { z } from "zod"; import type { FastifyInstance } from "fastify"; import { prisma } from "./db.js"; @@ -5,6 +6,7 @@ import { requireAdmin } from "./auth.js"; import { env } from "./env.js"; import { runMultiplex } from "./llm/multiplexer.js"; import { runMultiplexStream } from "./llm/streaming.js"; +import { exaClient } from "./search/exa.js"; type IncomingChatMessage = { role: "system" | "user" | "assistant" | "tool"; @@ -73,6 +75,141 @@ export async function registerRoutes(app: FastifyInstance) { return { chat }; }); + app.get("/v1/searches", async (req) => { + requireAdmin(req); + const searches = await prisma.search.findMany({ + orderBy: { updatedAt: "desc" }, + take: 100, + select: { id: true, title: true, query: true, createdAt: true, updatedAt: true }, + }); + return { searches }; + }); + + app.post("/v1/searches", async (req) => { + requireAdmin(req); + const Body = z.object({ title: z.string().optional(), query: z.string().optional() }); + const body = Body.parse(req.body ?? {}); + const title = body.title?.trim() || body.query?.trim()?.slice(0, 80); + const query = body.query?.trim() || null; + const search = await prisma.search.create({ + data: { + title: title || null, + query, + }, + select: { id: true, title: true, query: true, createdAt: true, updatedAt: true }, + }); + return { search }; + }); + + app.get("/v1/searches/:searchId", async (req) => { + requireAdmin(req); + const Params = z.object({ searchId: z.string() }); + const { searchId } = Params.parse(req.params); + + const search = await prisma.search.findUnique({ + where: { id: searchId }, + include: { results: { orderBy: { rank: "asc" } } }, + }); + if (!search) return app.httpErrors.notFound("search not found"); + return { search }; + }); + + app.post("/v1/searches/:searchId/run", async (req) => { + requireAdmin(req); + const Params = z.object({ searchId: z.string() }); + const Body = z.object({ + query: z.string().trim().min(1).optional(), + title: z.string().trim().min(1).optional(), + type: z.enum(["auto", "fast", "deep", "instant"]).optional(), + numResults: z.number().int().min(1).max(25).optional(), + includeDomains: z.array(z.string().trim().min(1)).max(50).optional(), + excludeDomains: z.array(z.string().trim().min(1)).max(50).optional(), + }); + + const { searchId } = Params.parse(req.params); + const body = Body.parse(req.body ?? {}); + + const existing = await prisma.search.findUnique({ + where: { id: searchId }, + select: { id: true, query: true }, + }); + if (!existing) return app.httpErrors.notFound("search not found"); + + const query = body.query?.trim() || existing.query?.trim(); + if (!query) return app.httpErrors.badRequest("query is required"); + + const startedAt = performance.now(); + + try { + const response = await exaClient().searchAndContents(query, { + type: body.type ?? "auto", + numResults: body.numResults ?? 10, + includeDomains: body.includeDomains, + excludeDomains: body.excludeDomains, + text: { maxCharacters: 1200 }, + highlights: { + query, + maxCharacters: 320, + numSentences: 2, + highlightsPerUrl: 2, + }, + moderation: true, + userLocation: "US", + } as any); + + const latencyMs = Math.round(performance.now() - startedAt); + const normalizedTitle = body.title?.trim() || query.slice(0, 80); + const rows = (response.results ?? []).map((result: any, index: number) => ({ + searchId, + rank: index, + title: result.title ?? null, + url: result.url, + publishedDate: result.publishedDate ?? null, + author: result.author ?? null, + text: result.text ?? null, + highlights: Array.isArray(result.highlights) ? (result.highlights as any) : null, + highlightScores: Array.isArray(result.highlightScores) ? (result.highlightScores as any) : null, + score: typeof result.score === "number" ? result.score : null, + favicon: result.favicon ?? null, + image: result.image ?? null, + })); + + await prisma.$transaction(async (tx) => { + await tx.search.update({ + where: { id: searchId }, + data: { + query, + title: normalizedTitle, + requestId: response.requestId ?? null, + rawResponse: response as any, + latencyMs, + error: null, + }, + }); + await tx.searchResult.deleteMany({ where: { searchId } }); + if (rows.length) { + await tx.searchResult.createMany({ data: rows as any }); + } + }); + + const search = await prisma.search.findUnique({ + where: { id: searchId }, + include: { results: { orderBy: { rank: "asc" } } }, + }); + if (!search) return app.httpErrors.notFound("search not found"); + return { search }; + } catch (err: any) { + await prisma.search.update({ + where: { id: searchId }, + data: { + latencyMs: Math.round(performance.now() - startedAt), + error: err?.message ?? String(err), + }, + }); + throw err; + } + }); + app.get("/v1/chats/:chatId", async (req) => { requireAdmin(req); const Params = z.object({ chatId: z.string() }); diff --git a/server/src/search/exa.ts b/server/src/search/exa.ts new file mode 100644 index 0000000..ef12c82 --- /dev/null +++ b/server/src/search/exa.ts @@ -0,0 +1,14 @@ +import { Exa } from "exa-js"; +import { env } from "../env.js"; + +let client: Exa | null = null; + +export function exaClient() { + if (!env.EXA_API_KEY) { + throw new Error("EXA_API_KEY not set"); + } + if (!client) { + client = new Exa(env.EXA_API_KEY); + } + return client; +} diff --git a/web/README.md b/web/README.md index d516ddd..9874742 100644 --- a/web/README.md +++ b/web/README.md @@ -33,6 +33,10 @@ Default dev URL: `http://localhost:5173` ## UI -- Left panel: conversation list + new chat. -- Right panel: selected transcript + model controls + composer. -- Sending a message uses `POST /v1/chat-completions` and then refreshes chat history from the backend. +- Left panel: mixed list of chat conversations and Exa searches. +- Right panel: + - Chat mode: transcript + provider/model controls. + - Search mode: Google-style Exa results view. +- Composer adapts to the active item: + - Chat sends `POST /v1/chat-completions`. + - Search sends `POST /v1/searches/:searchId/run`. diff --git a/web/src/App.tsx b/web/src/App.tsx index 43592ab..6ec2f2c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,25 +1,38 @@ import { useEffect, useMemo, useRef, useState } from "preact/hooks"; -import { LogOut, MessageSquare, Plus, SendHorizontal, ShieldCheck } from "lucide-preact"; +import { Globe2, LogOut, MessageSquare, Plus, Search, SendHorizontal, ShieldCheck } from "lucide-preact"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Separator } from "@/components/ui/separator"; import { createChat, + createSearch, getChat, getConfiguredToken, + getSearch, listChats, + listSearches, runCompletion, + runSearch, setAuthToken, verifySession, type ChatDetail, type ChatSummary, type CompletionRequestMessage, + type SearchDetail, + type SearchResultItem, + type SearchSummary, } from "@/lib/api"; import { cn } from "@/lib/utils"; type Provider = "openai" | "anthropic" | "xai"; type AuthMode = "open" | "token"; +type SidebarSelection = { kind: "chat" | "search"; id: string }; +type SidebarItem = SidebarSelection & { + title: string; + updatedAt: string; + createdAt: string; +}; const PROVIDER_DEFAULT_MODELS: Record = { openai: "gpt-4.1-mini", @@ -35,6 +48,33 @@ function getChatTitle(chat: Pick, messages?: ChatDetail["m return "New chat"; } +function getSearchTitle(search: Pick) { + if (search.title?.trim()) return search.title.trim(); + if (search.query?.trim()) return search.query.trim().slice(0, 64); + return "New search"; +} + +function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] { + const items: SidebarItem[] = [ + ...chats.map((chat) => ({ + kind: "chat" as const, + id: chat.id, + title: getChatTitle(chat), + updatedAt: chat.updatedAt, + createdAt: chat.createdAt, + })), + ...searches.map((search) => ({ + kind: "search" as const, + id: search.id, + title: getSearchTitle(search), + updatedAt: search.updatedAt, + createdAt: search.createdAt, + })), + ]; + + return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); +} + function formatDate(value: string) { return new Intl.DateTimeFormat(undefined, { month: "short", @@ -63,6 +103,20 @@ function normalizeAuthError(message: string) { return message; } +function summarizeResult(result: SearchResultItem) { + const highlights = Array.isArray(result.highlights) ? result.highlights.filter(Boolean) : []; + if (highlights.length) return highlights.join(" ").slice(0, 420); + return (result.text ?? "").slice(0, 420); +} + +function formatHost(url: string) { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + export default function App() { const initialToken = readStoredToken() ?? getConfiguredToken() ?? ""; @@ -74,10 +128,12 @@ export default function App() { const [authError, setAuthError] = useState(null); const [chats, setChats] = useState([]); - const [selectedChatId, setSelectedChatId] = useState(null); + const [searches, setSearches] = useState([]); + const [selectedItem, setSelectedItem] = useState(null); const [selectedChat, setSelectedChat] = useState(null); - const [isLoadingChats, setIsLoadingChats] = useState(false); - const [isLoadingChat, setIsLoadingChat] = useState(false); + const [selectedSearch, setSelectedSearch] = useState(null); + const [isLoadingCollections, setIsLoadingCollections] = useState(false); + const [isLoadingSelection, setIsLoadingSelection] = useState(false); const [isSending, setIsSending] = useState(false); const [composer, setComposer] = useState(""); const [provider, setProvider] = useState("openai"); @@ -85,6 +141,8 @@ export default function App() { const [error, setError] = useState(null); const transcriptEndRef = useRef(null); + const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]); + const completeSessionCheck = async (tokenCandidate: string | null) => { setAuthToken(tokenCandidate); const session = await verifySession(); @@ -101,24 +159,34 @@ export default function App() { setAuthToken(null); persistToken(null); setChats([]); - setSelectedChatId(null); + setSearches([]); + setSelectedItem(null); setSelectedChat(null); + setSelectedSearch(null); }; - const refreshChats = async (preferredChatId?: string) => { - setIsLoadingChats(true); + const refreshCollections = async (preferredSelection?: SidebarSelection) => { + setIsLoadingCollections(true); try { - const nextChats = await listChats(); + const [nextChats, nextSearches] = await Promise.all([listChats(), listSearches()]); + const nextItems = buildSidebarItems(nextChats, nextSearches); setChats(nextChats); + setSearches(nextSearches); - setSelectedChatId((current) => { - if (preferredChatId && nextChats.some((chat) => chat.id === preferredChatId)) { - return preferredChatId; + setSelectedItem((current) => { + const hasItem = (candidate: SidebarSelection | null) => { + if (!candidate) return false; + return nextItems.some((item) => item.kind === candidate.kind && item.id === candidate.id); + }; + + if (preferredSelection && hasItem(preferredSelection)) { + return preferredSelection; } - if (current && nextChats.some((chat) => chat.id === current)) { + if (hasItem(current)) { return current; } - return nextChats[0]?.id ?? null; + const first = nextItems[0]; + return first ? { kind: first.kind, id: first.id } : null; }); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -128,15 +196,16 @@ export default function App() { setError(message); } } finally { - setIsLoadingChats(false); + setIsLoadingCollections(false); } }; const refreshChat = async (chatId: string) => { - setIsLoadingChat(true); + setIsLoadingSelection(true); try { const chat = await getChat(chatId); setSelectedChat(chat); + setSelectedSearch(null); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { @@ -145,7 +214,25 @@ export default function App() { setError(message); } } finally { - setIsLoadingChat(false); + setIsLoadingSelection(false); + } + }; + + const refreshSearch = async (searchId: string) => { + setIsLoadingSelection(true); + try { + const search = await getSearch(searchId); + setSelectedSearch(search); + setSelectedChat(null); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes("bearer token")) { + handleAuthFailure(message); + } else { + setError(message); + } + } finally { + setIsLoadingSelection(false); } }; @@ -165,37 +252,65 @@ export default function App() { useEffect(() => { if (!isAuthenticated) return; - void refreshChats(); + void refreshCollections(); }, [isAuthenticated]); + const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null; + useEffect(() => { if (!isAuthenticated) { setSelectedChat(null); + setSelectedSearch(null); return; } - if (!selectedChatId) { + if (!selectedItem) { setSelectedChat(null); + setSelectedSearch(null); return; } - void refreshChat(selectedChatId); - }, [isAuthenticated, selectedChatId]); + + if (selectedItem.kind === "chat") { + void refreshChat(selectedItem.id); + return; + } + void refreshSearch(selectedItem.id); + }, [isAuthenticated, selectedKey]); useEffect(() => { transcriptEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); - }, [selectedChat?.messages.length, isSending]); + }, [selectedChat?.messages.length, selectedSearch?.results.length, isSending]); const messages = selectedChat?.messages ?? []; - const selectedChatTitle = useMemo(() => { - if (!selectedChat) return "Sybil"; - return getChatTitle(selectedChat, selectedChat.messages); - }, [selectedChat]); + const selectedChatSummary = useMemo(() => { + if (!selectedItem || selectedItem.kind !== "chat") return null; + return chats.find((chat) => chat.id === selectedItem.id) ?? null; + }, [chats, selectedItem]); + + const selectedSearchSummary = useMemo(() => { + if (!selectedItem || selectedItem.kind !== "search") return null; + return searches.find((search) => search.id === selectedItem.id) ?? null; + }, [searches, selectedItem]); + + const selectedTitle = useMemo(() => { + if (!selectedItem) return "Sybil"; + if (selectedItem.kind === "chat") { + if (selectedChat) return getChatTitle(selectedChat, selectedChat.messages); + if (selectedChatSummary) return getChatTitle(selectedChatSummary); + return "New chat"; + } + if (selectedSearch) return getSearchTitle(selectedSearch); + if (selectedSearchSummary) return getSearchTitle(selectedSearchSummary); + return "New search"; + }, [selectedChat, selectedChatSummary, selectedItem, selectedSearch, selectedSearchSummary]); + + const isSearchMode = selectedItem?.kind === "search"; const handleCreateChat = async () => { setError(null); try { const chat = await createChat(); - setSelectedChatId(chat.id); + setSelectedItem({ kind: "chat", id: chat.id }); setSelectedChat({ id: chat.id, title: chat.title, @@ -203,7 +318,8 @@ export default function App() { updatedAt: chat.updatedAt, messages: [], }); - await refreshChats(chat.id); + setSelectedSearch(null); + await refreshCollections({ kind: "chat", id: chat.id }); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { @@ -214,6 +330,142 @@ export default function App() { } }; + const handleCreateSearch = async () => { + setError(null); + try { + const search = await createSearch(); + setSelectedItem({ kind: "search", id: search.id }); + setSelectedSearch({ + id: search.id, + title: search.title, + query: search.query, + createdAt: search.createdAt, + updatedAt: search.updatedAt, + requestId: null, + latencyMs: null, + error: null, + results: [], + }); + setSelectedChat(null); + await refreshCollections({ kind: "search", id: search.id }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes("bearer token")) { + handleAuthFailure(message); + } else { + setError(message); + } + } + }; + + const handleSendChat = async (content: string) => { + let chatId = selectedItem?.kind === "chat" ? selectedItem.id : null; + + if (!chatId) { + const chat = await createChat(); + chatId = chat.id; + setSelectedItem({ kind: "chat", id: chatId }); + setSelectedChat({ + id: chat.id, + title: chat.title, + createdAt: chat.createdAt, + updatedAt: chat.updatedAt, + messages: [], + }); + setSelectedSearch(null); + } + + if (!chatId) { + throw new Error("Unable to initialize chat"); + } + + let baseChat = selectedChat; + if (!baseChat || baseChat.id !== chatId) { + baseChat = await getChat(chatId); + } + + const optimisticUserMessage = { + id: `temp-user-${Date.now()}`, + createdAt: new Date().toISOString(), + role: "user" as const, + content, + name: null, + }; + + const optimisticAssistantMessage = { + id: `temp-assistant-${Date.now()}`, + createdAt: new Date().toISOString(), + role: "assistant" as const, + content: "", + name: null, + }; + + setSelectedChat((current) => { + if (!current || current.id !== chatId) return current; + return { + ...current, + messages: [...current.messages, optimisticUserMessage, optimisticAssistantMessage], + }; + }); + + const requestMessages: CompletionRequestMessage[] = [ + ...baseChat.messages.map((message) => ({ + role: message.role, + content: message.content, + ...(message.name ? { name: message.name } : {}), + })), + { + role: "user", + content, + }, + ]; + + await runCompletion({ + chatId, + provider, + model: model.trim(), + messages: requestMessages, + }); + + await Promise.all([refreshCollections({ kind: "chat", id: chatId }), refreshChat(chatId)]); + }; + + const handleSendSearch = async (query: string) => { + let searchId = selectedItem?.kind === "search" ? selectedItem.id : null; + + if (!searchId) { + const search = await createSearch(); + searchId = search.id; + setSelectedItem({ kind: "search", id: searchId }); + } + + if (!searchId) { + throw new Error("Unable to initialize search"); + } + + setSelectedSearch((current) => { + if (!current || current.id !== searchId) return current; + return { + ...current, + title: query.slice(0, 80), + query, + error: null, + results: [], + }; + }); + + const search = await runSearch(searchId, { + query, + title: query.slice(0, 80), + type: "auto", + numResults: 10, + }); + + setSelectedSearch(search); + setSelectedChat(null); + await refreshCollections({ kind: "search", id: searchId }); + }; + const handleSend = async () => { const content = composer.trim(); if (!content || isSending) return; @@ -222,68 +474,12 @@ export default function App() { setError(null); setIsSending(true); - let chatId = selectedChatId; - try { - if (!chatId) { - const chat = await createChat(); - chatId = chat.id; - setSelectedChatId(chatId); + if (isSearchMode) { + await handleSendSearch(content); + } else { + await handleSendChat(content); } - - if (!chatId) { - throw new Error("Unable to initialize chat"); - } - - let baseChat = selectedChat; - if (!baseChat || baseChat.id !== chatId) { - baseChat = await getChat(chatId); - } - - const optimisticUserMessage = { - id: `temp-user-${Date.now()}`, - createdAt: new Date().toISOString(), - role: "user" as const, - content, - name: null, - }; - - const optimisticAssistantMessage = { - id: `temp-assistant-${Date.now()}`, - createdAt: new Date().toISOString(), - role: "assistant" as const, - content: "", - name: null, - }; - - setSelectedChat((current) => { - if (!current || current.id !== chatId) return current; - return { - ...current, - messages: [...current.messages, optimisticUserMessage, optimisticAssistantMessage], - }; - }); - - const requestMessages: CompletionRequestMessage[] = [ - ...baseChat.messages.map((message) => ({ - role: message.role, - content: message.content, - ...(message.name ? { name: message.name } : {}), - })), - { - role: "user", - content, - }, - ]; - - await runCompletion({ - chatId, - provider, - model: model.trim(), - messages: requestMessages, - }); - - await Promise.all([refreshChats(chatId), refreshChat(chatId)]); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { @@ -291,8 +487,12 @@ export default function App() { } else { setError(message); } - if (chatId) { - await refreshChat(chatId); + + if (selectedItem?.kind === "chat") { + await refreshChat(selectedItem.id); + } + if (selectedItem?.kind === "search") { + await refreshSearch(selectedItem.id); } } finally { setIsSending(false); @@ -304,7 +504,7 @@ export default function App() { setAuthError(null); try { await completeSessionCheck(tokenCandidate); - await refreshChats(); + await refreshCollections(); } catch (err) { const message = err instanceof Error ? err.message : String(err); setAuthError(normalizeAuthError(message)); @@ -322,8 +522,10 @@ export default function App() { setAuthMode(null); setAuthError(null); setChats([]); - setSelectedChatId(null); + setSearches([]); + setSelectedItem(null); setSelectedChat(null); + setSelectedSearch(null); setComposer(""); setError(null); }; @@ -390,35 +592,44 @@ export default function App() {