diff --git a/AGENTS.md b/AGENTS.md
index c5a7b13..bd8c5c2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,9 +1,2 @@
# AGENTS.md
-## Server Change Verification
-
-- Whenever you modify files under `/Users/buzzert/src/sybil-2/server`, you must verify startup locally before finalizing.
-- Verification means running `npm run dev` in `/Users/buzzert/src/sybil-2/server` and confirming:
- - migrations run (or report already up-to-date), and
- - the server reaches a listening state without crashing.
-- Include the verification result in your final response.
diff --git a/server/README.md b/server/README.md
index 9c35ce7..cfed71e 100644
--- a/server/README.md
+++ b/server/README.md
@@ -55,6 +55,10 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
- `GET /v1/searches/:searchId`
- `POST /v1/searches/:searchId/run`
+Search runs now execute both Exa `searchAndContents` and Exa `answer`, storing:
+- ranked search results (for result cards), and
+- a top-level answer block + citations.
+
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.
`POST /v1/chat-completions` body example:
diff --git a/server/prisma/migrations/20260214075925_add_search_answers/migration.sql b/server/prisma/migrations/20260214075925_add_search_answers/migration.sql
new file mode 100644
index 0000000..4061d43
--- /dev/null
+++ b/server/prisma/migrations/20260214075925_add_search_answers/migration.sql
@@ -0,0 +1,6 @@
+-- AlterTable
+ALTER TABLE "Search" ADD COLUMN "answerCitations" JSONB;
+ALTER TABLE "Search" ADD COLUMN "answerError" TEXT;
+ALTER TABLE "Search" ADD COLUMN "answerRawResponse" JSONB;
+ALTER TABLE "Search" ADD COLUMN "answerRequestId" TEXT;
+ALTER TABLE "Search" ADD COLUMN "answerText" TEXT;
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index 3b13edb..5bba200 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -113,6 +113,12 @@ model Search {
latencyMs Int?
error String?
+ answerText String?
+ answerRequestId String?
+ answerCitations Json?
+ answerRawResponse Json?
+ answerError String?
+
user User? @relation(fields: [userId], references: [id])
userId String?
diff --git a/server/src/routes.ts b/server/src/routes.ts
index 61db21e..495fb4b 100644
--- a/server/src/routes.ts
+++ b/server/src/routes.ts
@@ -141,25 +141,38 @@ export async function registerRoutes(app: FastifyInstance) {
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 exa = exaClient();
+ const [searchOutcome, answerOutcome] = await Promise.allSettled([
+ exa.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),
+ exa.answer(query, {
+ text: true,
+ model: "exa",
+ userLocation: "US",
+ }),
+ ]);
+
+ const searchResponse = searchOutcome.status === "fulfilled" ? searchOutcome.value : null;
+ const answerResponse = answerOutcome.status === "fulfilled" ? answerOutcome.value : null;
+ const searchError = searchOutcome.status === "rejected" ? searchOutcome.reason?.message ?? String(searchOutcome.reason) : null;
+ const answerError = answerOutcome.status === "rejected" ? answerOutcome.reason?.message ?? String(answerOutcome.reason) : null;
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) => ({
+ const rows = (searchResponse?.results ?? []).map((result: any, index: number) => ({
searchId,
rank: index,
title: result.title ?? null,
@@ -173,6 +186,12 @@ export async function registerRoutes(app: FastifyInstance) {
favicon: result.favicon ?? null,
image: result.image ?? null,
}));
+ const answerText =
+ typeof answerResponse?.answer === "string"
+ ? answerResponse.answer
+ : answerResponse?.answer
+ ? JSON.stringify(answerResponse.answer, null, 2)
+ : null;
await prisma.$transaction(async (tx) => {
await tx.search.update({
@@ -180,10 +199,15 @@ export async function registerRoutes(app: FastifyInstance) {
data: {
query,
title: normalizedTitle,
- requestId: response.requestId ?? null,
- rawResponse: response as any,
+ requestId: searchResponse?.requestId ?? null,
+ rawResponse: searchResponse as any,
latencyMs,
- error: null,
+ error: searchError,
+ answerText,
+ answerRequestId: answerResponse?.requestId ?? null,
+ answerCitations: (answerResponse?.citations as any) ?? null,
+ answerRawResponse: answerResponse as any,
+ answerError,
},
});
await tx.searchResult.deleteMany({ where: { searchId } });
@@ -192,6 +216,10 @@ export async function registerRoutes(app: FastifyInstance) {
}
});
+ if (searchError && answerError) {
+ throw app.httpErrors.badGateway(`Exa search and answer failed: ${searchError}; ${answerError}`);
+ }
+
const search = await prisma.search.findUnique({
where: { id: searchId },
include: { results: { orderBy: { rank: "asc" } } },
diff --git a/web/README.md b/web/README.md
index 9874742..fcf1390 100644
--- a/web/README.md
+++ b/web/README.md
@@ -36,7 +36,7 @@ Default dev URL: `http://localhost:5173`
- 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.
+ - Search mode: top AI answer block + 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 bd60ce4..3d53c7e 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -431,6 +431,10 @@ export default function App() {
requestId: null,
latencyMs: null,
error: null,
+ answerText: null,
+ answerRequestId: null,
+ answerCitations: null,
+ answerError: null,
results: [],
};
}
@@ -440,6 +444,10 @@ export default function App() {
query,
error: null,
latencyMs: null,
+ answerText: null,
+ answerRequestId: null,
+ answerCitations: null,
+ answerError: null,
results: [],
};
});
@@ -718,6 +726,38 @@ export default function App() {
) : null}
+ {(isSearchRunning || !!selectedSearch?.answerText || !!selectedSearch?.answerError) && (
+ Answer Generating answer... {selectedSearch.answerText} {selectedSearch.answerError}
{isSearchRunning ? "Searching Exa..." : "Loading search..."}
) : null} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 3cf86ce..eb712ff 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -54,6 +54,17 @@ export type SearchDetail = { requestId: string | null; latencyMs: number | null; error: string | null; + answerText: string | null; + answerRequestId: string | null; + answerCitations: Array<{ + id?: string; + url?: string; + title?: string | null; + publishedDate?: string | null; + author?: string | null; + text?: string | null; + }> | null; + answerError: string | null; results: SearchResultItem[]; };