adds search support with exa
This commit is contained in:
1
server/.gitignore
vendored
1
server/.gitignore
vendored
@@ -4,6 +4,5 @@ node_modules
|
|||||||
dev.db
|
dev.db
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
prisma/migrations
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist
|
dist
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
|
|||||||
- `OPENAI_API_KEY`
|
- `OPENAI_API_KEY`
|
||||||
- `ANTHROPIC_API_KEY`
|
- `ANTHROPIC_API_KEY`
|
||||||
- `XAI_API_KEY`
|
- `XAI_API_KEY`
|
||||||
|
- `EXA_API_KEY`
|
||||||
|
|
||||||
## API
|
## API
|
||||||
- `GET /health`
|
- `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/chats/:chatId/messages`
|
||||||
- `POST /v1/chat-completions`
|
- `POST /v1/chat-completions`
|
||||||
- `POST /v1/chat-completions/stream` (SSE)
|
- `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.
|
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.
|
||||||
|
|
||||||
|
|||||||
116
server/package-lock.json
generated
116
server/package-lock.json
generated
@@ -16,6 +16,7 @@
|
|||||||
"@fastify/swagger-ui": "^5.2.5",
|
"@fastify/swagger-ui": "^5.2.5",
|
||||||
"@prisma/client": "^6.6.0",
|
"@prisma/client": "^6.6.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"exa-js": "^2.4.0",
|
||||||
"fastify": "^5.7.2",
|
"fastify": "^5.7.2",
|
||||||
"openai": "^6.16.0",
|
"openai": "^6.16.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
@@ -960,6 +961,15 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/dateformat": {
|
||||||
"version": "4.6.3",
|
"version": "4.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
|
||||||
@@ -1084,6 +1094,61 @@
|
|||||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fast-copy": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
|
||||||
@@ -1518,6 +1583,26 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/on-exit-leak-free": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
"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": ">=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": {
|
"node_modules/ts-algebra": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||||
@@ -1960,6 +2051,22 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
@@ -1989,6 +2096,15 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,11 @@
|
|||||||
"@fastify/swagger-ui": "^5.2.5",
|
"@fastify/swagger-ui": "^5.2.5",
|
||||||
"@prisma/client": "^6.6.0",
|
"@prisma/client": "^6.6.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"exa-js": "^2.4.0",
|
||||||
"fastify": "^5.7.2",
|
"fastify": "^5.7.2",
|
||||||
"openai": "^6.16.0",
|
"openai": "^6.16.0",
|
||||||
"prisma": "^6.6.0",
|
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
|
"prisma": "^6.6.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
61
server/prisma/migrations/20260128083259_/migration.sql
Normal file
61
server/prisma/migrations/20260128083259_/migration.sql
Normal file
@@ -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");
|
||||||
@@ -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");
|
||||||
3
server/prisma/migrations/migration_lock.toml
Normal file
3
server/prisma/migrations/migration_lock.toml
Normal file
@@ -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"
|
||||||
@@ -22,6 +22,10 @@ enum MessageRole {
|
|||||||
tool
|
tool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SearchSource {
|
||||||
|
exa
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -31,6 +35,7 @@ model User {
|
|||||||
handle String? @unique
|
handle String? @unique
|
||||||
|
|
||||||
chats Chat[]
|
chats Chat[]
|
||||||
|
searches Search[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Chat {
|
model Chat {
|
||||||
@@ -92,3 +97,50 @@ model LlmCall {
|
|||||||
@@index([chatId, createdAt])
|
@@index([chatId, createdAt])
|
||||||
@@index([provider, model, 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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const EnvSchema = z.object({
|
|||||||
OPENAI_API_KEY: z.string().optional(),
|
OPENAI_API_KEY: z.string().optional(),
|
||||||
ANTHROPIC_API_KEY: z.string().optional(),
|
ANTHROPIC_API_KEY: z.string().optional(),
|
||||||
XAI_API_KEY: z.string().optional(),
|
XAI_API_KEY: z.string().optional(),
|
||||||
|
EXA_API_KEY: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof EnvSchema>;
|
export type Env = z.infer<typeof EnvSchema>;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { performance } from "node:perf_hooks";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { prisma } from "./db.js";
|
import { prisma } from "./db.js";
|
||||||
@@ -5,6 +6,7 @@ import { requireAdmin } from "./auth.js";
|
|||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
import { runMultiplex } from "./llm/multiplexer.js";
|
import { runMultiplex } from "./llm/multiplexer.js";
|
||||||
import { runMultiplexStream } from "./llm/streaming.js";
|
import { runMultiplexStream } from "./llm/streaming.js";
|
||||||
|
import { exaClient } from "./search/exa.js";
|
||||||
|
|
||||||
type IncomingChatMessage = {
|
type IncomingChatMessage = {
|
||||||
role: "system" | "user" | "assistant" | "tool";
|
role: "system" | "user" | "assistant" | "tool";
|
||||||
@@ -73,6 +75,141 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
return { chat };
|
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) => {
|
app.get("/v1/chats/:chatId", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const Params = z.object({ chatId: z.string() });
|
const Params = z.object({ chatId: z.string() });
|
||||||
|
|||||||
14
server/src/search/exa.ts
Normal file
14
server/src/search/exa.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -33,6 +33,10 @@ Default dev URL: `http://localhost:5173`
|
|||||||
|
|
||||||
## UI
|
## UI
|
||||||
|
|
||||||
- Left panel: conversation list + new chat.
|
- Left panel: mixed list of chat conversations and Exa searches.
|
||||||
- Right panel: selected transcript + model controls + composer.
|
- Right panel:
|
||||||
- Sending a message uses `POST /v1/chat-completions` and then refreshes chat history from the backend.
|
- 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`.
|
||||||
|
|||||||
585
web/src/App.tsx
585
web/src/App.tsx
@@ -1,25 +1,38 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
createChat,
|
createChat,
|
||||||
|
createSearch,
|
||||||
getChat,
|
getChat,
|
||||||
getConfiguredToken,
|
getConfiguredToken,
|
||||||
|
getSearch,
|
||||||
listChats,
|
listChats,
|
||||||
|
listSearches,
|
||||||
runCompletion,
|
runCompletion,
|
||||||
|
runSearch,
|
||||||
setAuthToken,
|
setAuthToken,
|
||||||
verifySession,
|
verifySession,
|
||||||
type ChatDetail,
|
type ChatDetail,
|
||||||
type ChatSummary,
|
type ChatSummary,
|
||||||
type CompletionRequestMessage,
|
type CompletionRequestMessage,
|
||||||
|
type SearchDetail,
|
||||||
|
type SearchResultItem,
|
||||||
|
type SearchSummary,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type Provider = "openai" | "anthropic" | "xai";
|
type Provider = "openai" | "anthropic" | "xai";
|
||||||
type AuthMode = "open" | "token";
|
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<Provider, string> = {
|
const PROVIDER_DEFAULT_MODELS: Record<Provider, string> = {
|
||||||
openai: "gpt-4.1-mini",
|
openai: "gpt-4.1-mini",
|
||||||
@@ -35,6 +48,33 @@ function getChatTitle(chat: Pick<ChatSummary, "title">, messages?: ChatDetail["m
|
|||||||
return "New chat";
|
return "New chat";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSearchTitle(search: Pick<SearchSummary, "title" | "query">) {
|
||||||
|
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) {
|
function formatDate(value: string) {
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
month: "short",
|
month: "short",
|
||||||
@@ -63,6 +103,20 @@ function normalizeAuthError(message: string) {
|
|||||||
return message;
|
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() {
|
export default function App() {
|
||||||
const initialToken = readStoredToken() ?? getConfiguredToken() ?? "";
|
const initialToken = readStoredToken() ?? getConfiguredToken() ?? "";
|
||||||
|
|
||||||
@@ -74,10 +128,12 @@ export default function App() {
|
|||||||
const [authError, setAuthError] = useState<string | null>(null);
|
const [authError, setAuthError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [chats, setChats] = useState<ChatSummary[]>([]);
|
const [chats, setChats] = useState<ChatSummary[]>([]);
|
||||||
const [selectedChatId, setSelectedChatId] = useState<string | null>(null);
|
const [searches, setSearches] = useState<SearchSummary[]>([]);
|
||||||
|
const [selectedItem, setSelectedItem] = useState<SidebarSelection | null>(null);
|
||||||
const [selectedChat, setSelectedChat] = useState<ChatDetail | null>(null);
|
const [selectedChat, setSelectedChat] = useState<ChatDetail | null>(null);
|
||||||
const [isLoadingChats, setIsLoadingChats] = useState(false);
|
const [selectedSearch, setSelectedSearch] = useState<SearchDetail | null>(null);
|
||||||
const [isLoadingChat, setIsLoadingChat] = useState(false);
|
const [isLoadingCollections, setIsLoadingCollections] = useState(false);
|
||||||
|
const [isLoadingSelection, setIsLoadingSelection] = useState(false);
|
||||||
const [isSending, setIsSending] = useState(false);
|
const [isSending, setIsSending] = useState(false);
|
||||||
const [composer, setComposer] = useState("");
|
const [composer, setComposer] = useState("");
|
||||||
const [provider, setProvider] = useState<Provider>("openai");
|
const [provider, setProvider] = useState<Provider>("openai");
|
||||||
@@ -85,6 +141,8 @@ export default function App() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]);
|
||||||
|
|
||||||
const completeSessionCheck = async (tokenCandidate: string | null) => {
|
const completeSessionCheck = async (tokenCandidate: string | null) => {
|
||||||
setAuthToken(tokenCandidate);
|
setAuthToken(tokenCandidate);
|
||||||
const session = await verifySession();
|
const session = await verifySession();
|
||||||
@@ -101,24 +159,34 @@ export default function App() {
|
|||||||
setAuthToken(null);
|
setAuthToken(null);
|
||||||
persistToken(null);
|
persistToken(null);
|
||||||
setChats([]);
|
setChats([]);
|
||||||
setSelectedChatId(null);
|
setSearches([]);
|
||||||
|
setSelectedItem(null);
|
||||||
setSelectedChat(null);
|
setSelectedChat(null);
|
||||||
|
setSelectedSearch(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshChats = async (preferredChatId?: string) => {
|
const refreshCollections = async (preferredSelection?: SidebarSelection) => {
|
||||||
setIsLoadingChats(true);
|
setIsLoadingCollections(true);
|
||||||
try {
|
try {
|
||||||
const nextChats = await listChats();
|
const [nextChats, nextSearches] = await Promise.all([listChats(), listSearches()]);
|
||||||
|
const nextItems = buildSidebarItems(nextChats, nextSearches);
|
||||||
setChats(nextChats);
|
setChats(nextChats);
|
||||||
|
setSearches(nextSearches);
|
||||||
|
|
||||||
setSelectedChatId((current) => {
|
setSelectedItem((current) => {
|
||||||
if (preferredChatId && nextChats.some((chat) => chat.id === preferredChatId)) {
|
const hasItem = (candidate: SidebarSelection | null) => {
|
||||||
return preferredChatId;
|
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 current;
|
||||||
}
|
}
|
||||||
return nextChats[0]?.id ?? null;
|
const first = nextItems[0];
|
||||||
|
return first ? { kind: first.kind, id: first.id } : null;
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
@@ -128,15 +196,16 @@ export default function App() {
|
|||||||
setError(message);
|
setError(message);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingChats(false);
|
setIsLoadingCollections(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshChat = async (chatId: string) => {
|
const refreshChat = async (chatId: string) => {
|
||||||
setIsLoadingChat(true);
|
setIsLoadingSelection(true);
|
||||||
try {
|
try {
|
||||||
const chat = await getChat(chatId);
|
const chat = await getChat(chatId);
|
||||||
setSelectedChat(chat);
|
setSelectedChat(chat);
|
||||||
|
setSelectedSearch(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
if (message.includes("bearer token")) {
|
if (message.includes("bearer token")) {
|
||||||
@@ -145,7 +214,25 @@ export default function App() {
|
|||||||
setError(message);
|
setError(message);
|
||||||
}
|
}
|
||||||
} finally {
|
} 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(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
void refreshChats();
|
void refreshCollections();
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
setSelectedChat(null);
|
setSelectedChat(null);
|
||||||
|
setSelectedSearch(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!selectedChatId) {
|
if (!selectedItem) {
|
||||||
setSelectedChat(null);
|
setSelectedChat(null);
|
||||||
|
setSelectedSearch(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void refreshChat(selectedChatId);
|
|
||||||
}, [isAuthenticated, selectedChatId]);
|
if (selectedItem.kind === "chat") {
|
||||||
|
void refreshChat(selectedItem.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void refreshSearch(selectedItem.id);
|
||||||
|
}, [isAuthenticated, selectedKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
transcriptEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
transcriptEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||||
}, [selectedChat?.messages.length, isSending]);
|
}, [selectedChat?.messages.length, selectedSearch?.results.length, isSending]);
|
||||||
|
|
||||||
const messages = selectedChat?.messages ?? [];
|
const messages = selectedChat?.messages ?? [];
|
||||||
|
|
||||||
const selectedChatTitle = useMemo(() => {
|
const selectedChatSummary = useMemo(() => {
|
||||||
if (!selectedChat) return "Sybil";
|
if (!selectedItem || selectedItem.kind !== "chat") return null;
|
||||||
return getChatTitle(selectedChat, selectedChat.messages);
|
return chats.find((chat) => chat.id === selectedItem.id) ?? null;
|
||||||
}, [selectedChat]);
|
}, [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 () => {
|
const handleCreateChat = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const chat = await createChat();
|
const chat = await createChat();
|
||||||
setSelectedChatId(chat.id);
|
setSelectedItem({ kind: "chat", id: chat.id });
|
||||||
setSelectedChat({
|
setSelectedChat({
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
title: chat.title,
|
title: chat.title,
|
||||||
@@ -203,7 +318,8 @@ export default function App() {
|
|||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
messages: [],
|
messages: [],
|
||||||
});
|
});
|
||||||
await refreshChats(chat.id);
|
setSelectedSearch(null);
|
||||||
|
await refreshCollections({ kind: "chat", id: chat.id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
if (message.includes("bearer token")) {
|
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 handleSend = async () => {
|
||||||
const content = composer.trim();
|
const content = composer.trim();
|
||||||
if (!content || isSending) return;
|
if (!content || isSending) return;
|
||||||
@@ -222,68 +474,12 @@ export default function App() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setIsSending(true);
|
setIsSending(true);
|
||||||
|
|
||||||
let chatId = selectedChatId;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!chatId) {
|
if (isSearchMode) {
|
||||||
const chat = await createChat();
|
await handleSendSearch(content);
|
||||||
chatId = chat.id;
|
} else {
|
||||||
setSelectedChatId(chatId);
|
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) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
if (message.includes("bearer token")) {
|
if (message.includes("bearer token")) {
|
||||||
@@ -291,8 +487,12 @@ export default function App() {
|
|||||||
} else {
|
} else {
|
||||||
setError(message);
|
setError(message);
|
||||||
}
|
}
|
||||||
if (chatId) {
|
|
||||||
await refreshChat(chatId);
|
if (selectedItem?.kind === "chat") {
|
||||||
|
await refreshChat(selectedItem.id);
|
||||||
|
}
|
||||||
|
if (selectedItem?.kind === "search") {
|
||||||
|
await refreshSearch(selectedItem.id);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSending(false);
|
setIsSending(false);
|
||||||
@@ -304,7 +504,7 @@ export default function App() {
|
|||||||
setAuthError(null);
|
setAuthError(null);
|
||||||
try {
|
try {
|
||||||
await completeSessionCheck(tokenCandidate);
|
await completeSessionCheck(tokenCandidate);
|
||||||
await refreshChats();
|
await refreshCollections();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
setAuthError(normalizeAuthError(message));
|
setAuthError(normalizeAuthError(message));
|
||||||
@@ -322,8 +522,10 @@ export default function App() {
|
|||||||
setAuthMode(null);
|
setAuthMode(null);
|
||||||
setAuthError(null);
|
setAuthError(null);
|
||||||
setChats([]);
|
setChats([]);
|
||||||
setSelectedChatId(null);
|
setSearches([]);
|
||||||
|
setSelectedItem(null);
|
||||||
setSelectedChat(null);
|
setSelectedChat(null);
|
||||||
|
setSelectedSearch(null);
|
||||||
setComposer("");
|
setComposer("");
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
@@ -390,35 +592,44 @@ export default function App() {
|
|||||||
<div className="h-full p-3 md:p-5">
|
<div className="h-full p-3 md:p-5">
|
||||||
<div className="mx-auto flex h-full w-full max-w-[1560px] overflow-hidden rounded-2xl border bg-background shadow-xl shadow-black/40">
|
<div className="mx-auto flex h-full w-full max-w-[1560px] overflow-hidden rounded-2xl border bg-background shadow-xl shadow-black/40">
|
||||||
<aside className="flex w-80 shrink-0 flex-col border-r bg-[#111827]">
|
<aside className="flex w-80 shrink-0 flex-col border-r bg-[#111827]">
|
||||||
<div className="p-3">
|
<div className="grid grid-cols-2 gap-2 p-3">
|
||||||
<Button className="w-full justify-start gap-2" onClick={handleCreateChat}>
|
<Button className="justify-start gap-2" onClick={handleCreateChat}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
New chat
|
New chat
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button className="justify-start gap-2" variant="secondary" onClick={handleCreateSearch}>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
New search
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
{isLoadingChats && chats.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading chats...</p> : null}
|
{isLoadingCollections && sidebarItems.length === 0 ? (
|
||||||
{!isLoadingChats && chats.length === 0 ? (
|
<p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p>
|
||||||
|
) : null}
|
||||||
|
{!isLoadingCollections && sidebarItems.length === 0 ? (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-sm text-muted-foreground">
|
<div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-sm text-muted-foreground">
|
||||||
<MessageSquare className="h-5 w-5" />
|
<MessageSquare className="h-5 w-5" />
|
||||||
Start your first conversation.
|
Start a chat or run your first search.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{chats.map((chat) => {
|
{sidebarItems.map((item) => {
|
||||||
const active = chat.id === selectedChatId;
|
const active = selectedItem?.kind === item.kind && selectedItem.id === item.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={chat.id}
|
key={`${item.kind}-${item.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mb-1 w-full rounded-lg px-3 py-2 text-left transition",
|
"mb-1 w-full rounded-lg px-3 py-2 text-left transition",
|
||||||
active ? "bg-slate-700 text-slate-50" : "text-slate-200 hover:bg-slate-800"
|
active ? "bg-slate-700 text-slate-50" : "text-slate-200 hover:bg-slate-800"
|
||||||
)}
|
)}
|
||||||
onClick={() => setSelectedChatId(chat.id)}
|
onClick={() => setSelectedItem({ kind: item.kind, id: item.id })}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<p className="truncate text-sm font-medium">{getChatTitle(chat)}</p>
|
<div className="flex items-center gap-2">
|
||||||
<p className={cn("mt-1 text-xs", active ? "text-slate-200" : "text-slate-400")}>{formatDate(chat.updatedAt)}</p>
|
{item.kind === "chat" ? <MessageSquare className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />}
|
||||||
|
<p className="truncate text-sm font-medium">{item.title}</p>
|
||||||
|
</div>
|
||||||
|
<p className={cn("mt-1 text-xs", active ? "text-slate-200" : "text-slate-400")}>{formatDate(item.updatedAt)}</p>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -428,32 +639,42 @@ export default function App() {
|
|||||||
<main className="flex min-w-0 flex-1 flex-col">
|
<main className="flex min-w-0 flex-1 flex-col">
|
||||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
|
<header className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-sm font-semibold md:text-base">{selectedChatTitle}</h1>
|
<h1 className="text-sm font-semibold md:text-base">{selectedTitle}</h1>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
|
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
|
||||||
|
{isSearchMode ? " • Exa Search" : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
|
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
|
||||||
<select
|
{!isSearchMode ? (
|
||||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
<>
|
||||||
value={provider}
|
<select
|
||||||
onChange={(event) => {
|
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||||
const nextProvider = event.currentTarget.value as Provider;
|
value={provider}
|
||||||
setProvider(nextProvider);
|
onChange={(event) => {
|
||||||
setModel(PROVIDER_DEFAULT_MODELS[nextProvider]);
|
const nextProvider = event.currentTarget.value as Provider;
|
||||||
}}
|
setProvider(nextProvider);
|
||||||
disabled={isSending}
|
setModel(PROVIDER_DEFAULT_MODELS[nextProvider]);
|
||||||
>
|
}}
|
||||||
<option value="openai">OpenAI</option>
|
disabled={isSending}
|
||||||
<option value="anthropic">Anthropic</option>
|
>
|
||||||
<option value="xai">xAI</option>
|
<option value="openai">OpenAI</option>
|
||||||
</select>
|
<option value="anthropic">Anthropic</option>
|
||||||
<Input
|
<option value="xai">xAI</option>
|
||||||
value={model}
|
</select>
|
||||||
onInput={(event) => setModel(event.currentTarget.value)}
|
<Input
|
||||||
placeholder="Model"
|
value={model}
|
||||||
disabled={isSending}
|
onInput={(event) => setModel(event.currentTarget.value)}
|
||||||
/>
|
placeholder="Model"
|
||||||
|
disabled={isSending}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-9 items-center rounded-md border border-input px-3 text-sm text-muted-foreground">
|
||||||
|
<Globe2 className="mr-2 h-4 w-4" />
|
||||||
|
Search mode
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={handleLogout}>
|
<Button variant="outline" size="sm" onClick={handleLogout}>
|
||||||
<LogOut className="mr-1 h-4 w-4" />
|
<LogOut className="mr-1 h-4 w-4" />
|
||||||
Logout
|
Logout
|
||||||
@@ -462,31 +683,91 @@ export default function App() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-3 py-6 md:px-10">
|
<div className="flex-1 overflow-y-auto px-3 py-6 md:px-10">
|
||||||
{isLoadingChat && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
|
{!isSearchMode ? (
|
||||||
{!isLoadingChat && messages.length === 0 ? (
|
<>
|
||||||
<div className="mx-auto flex max-w-3xl flex-col items-center gap-3 rounded-xl border border-dashed p-8 text-center">
|
{isLoadingSelection && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
|
||||||
<h2 className="text-lg font-semibold">How can I help today?</h2>
|
{!isLoadingSelection && messages.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">Ask a question to begin this conversation.</p>
|
<div className="mx-auto flex max-w-3xl flex-col items-center gap-3 rounded-xl border border-dashed p-8 text-center">
|
||||||
</div>
|
<h2 className="text-lg font-semibold">How can I help today?</h2>
|
||||||
) : null}
|
<p className="text-sm text-muted-foreground">Ask a question to begin this conversation.</p>
|
||||||
<div className="mx-auto max-w-3xl space-y-6">
|
|
||||||
{messages.map((message) => {
|
|
||||||
const isUser = message.role === "user";
|
|
||||||
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending;
|
|
||||||
return (
|
|
||||||
<div key={message.id} className={cn("flex", isUser ? "justify-end" : "justify-start")}>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"max-w-[85%] whitespace-pre-wrap rounded-2xl px-4 py-3 text-sm leading-6",
|
|
||||||
isUser ? "bg-slate-200 text-slate-900" : "bg-slate-800 text-slate-100"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isPendingAssistant ? "Thinking..." : message.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
) : null}
|
||||||
})}
|
<div className="mx-auto max-w-3xl space-y-6">
|
||||||
</div>
|
{messages.map((message) => {
|
||||||
|
const isUser = message.role === "user";
|
||||||
|
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending;
|
||||||
|
return (
|
||||||
|
<div key={message.id} className={cn("flex", isUser ? "justify-end" : "justify-start")}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"max-w-[85%] whitespace-pre-wrap rounded-2xl px-4 py-3 text-sm leading-6",
|
||||||
|
isUser ? "bg-slate-200 text-slate-900" : "bg-slate-800 text-slate-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPendingAssistant ? "Thinking..." : message.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto w-full max-w-4xl">
|
||||||
|
{selectedSearch?.query ? (
|
||||||
|
<div className="mb-5">
|
||||||
|
<p className="text-sm text-muted-foreground">Results for</p>
|
||||||
|
<h2 className="mt-1 text-xl font-semibold">{selectedSearch.query}</h2>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{selectedSearch.results.length} result{selectedSearch.results.length === 1 ? "" : "s"}
|
||||||
|
{selectedSearch.latencyMs ? ` • ${selectedSearch.latencyMs} ms` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLoadingSelection && !selectedSearch?.results.length ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading search...</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoadingSelection && !selectedSearch?.query ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed p-8 text-center">
|
||||||
|
<Search className="h-6 w-6 text-muted-foreground" />
|
||||||
|
<h2 className="text-lg font-semibold">Search the web</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Use the composer below to run a new Exa search.</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoadingSelection && !!selectedSearch?.query && selectedSearch.results.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No results found.</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{selectedSearch?.results.map((result) => {
|
||||||
|
const summary = summarizeResult(result);
|
||||||
|
return (
|
||||||
|
<article key={result.id} className="rounded-lg border border-border bg-[#0d1322] px-4 py-4 shadow-sm">
|
||||||
|
<p className="text-xs text-emerald-300/85">{formatHost(result.url)}</p>
|
||||||
|
<a
|
||||||
|
href={result.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="mt-1 block text-lg font-medium text-sky-300 hover:underline"
|
||||||
|
>
|
||||||
|
{result.title || result.url}
|
||||||
|
</a>
|
||||||
|
{(result.publishedDate || result.author) && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{[result.publishedDate, result.author].filter(Boolean).join(" • ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{summary ? <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-slate-200">{summary}</p> : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedSearch?.error ? <p className="mt-4 text-sm text-red-600">{selectedSearch.error}</p> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div ref={transcriptEndRef} />
|
<div ref={transcriptEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -502,14 +783,18 @@ export default function App() {
|
|||||||
void handleSend();
|
void handleSend();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Message Sybil"
|
placeholder={isSearchMode ? "Search the web" : "Message Sybil"}
|
||||||
className="resize-none border-0 shadow-none focus-visible:ring-0"
|
className="resize-none border-0 shadow-none focus-visible:ring-0"
|
||||||
disabled={isSending}
|
disabled={isSending}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between px-2 pb-1">
|
<div className="flex items-center justify-between px-2 pb-1">
|
||||||
{error ? <p className="text-xs text-red-600">{error}</p> : <span className="text-xs text-muted-foreground">Enter to send</span>}
|
{error ? (
|
||||||
|
<p className="text-xs text-red-600">{error}</p>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">{isSearchMode ? "Enter to search" : "Enter to send"}</span>
|
||||||
|
)}
|
||||||
<Button onClick={() => void handleSend()} size="icon" disabled={isSending || !composer.trim()}>
|
<Button onClick={() => void handleSend()} size="icon" disabled={isSending || !composer.trim()}>
|
||||||
<SendHorizontal className="h-4 w-4" />
|
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ export type ChatSummary = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SearchSummary = {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
query: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type Message = {
|
export type Message = {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -21,6 +29,34 @@ export type ChatDetail = {
|
|||||||
messages: Message[];
|
messages: Message[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SearchResultItem = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
rank: number;
|
||||||
|
title: string | null;
|
||||||
|
url: string;
|
||||||
|
publishedDate: string | null;
|
||||||
|
author: string | null;
|
||||||
|
text: string | null;
|
||||||
|
highlights: string[] | null;
|
||||||
|
highlightScores: number[] | null;
|
||||||
|
score: number | null;
|
||||||
|
favicon: string | null;
|
||||||
|
image: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchDetail = {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
query: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
requestId: string | null;
|
||||||
|
latencyMs: number | null;
|
||||||
|
error: string | null;
|
||||||
|
results: SearchResultItem[];
|
||||||
|
};
|
||||||
|
|
||||||
export type CompletionRequestMessage = {
|
export type CompletionRequestMessage = {
|
||||||
role: "system" | "user" | "assistant" | "tool";
|
role: "system" | "user" | "assistant" | "tool";
|
||||||
content: string;
|
content: string;
|
||||||
@@ -96,6 +132,42 @@ export async function getChat(chatId: string) {
|
|||||||
return data.chat;
|
return data.chat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listSearches() {
|
||||||
|
const data = await api<{ searches: SearchSummary[] }>("/v1/searches");
|
||||||
|
return data.searches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSearch(body?: { title?: string; query?: string }) {
|
||||||
|
const data = await api<{ search: SearchSummary }>("/v1/searches", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body ?? {}),
|
||||||
|
});
|
||||||
|
return data.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSearch(searchId: string) {
|
||||||
|
const data = await api<{ search: SearchDetail }>(`/v1/searches/${searchId}`);
|
||||||
|
return data.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSearch(
|
||||||
|
searchId: string,
|
||||||
|
body: {
|
||||||
|
query?: string;
|
||||||
|
title?: string;
|
||||||
|
type?: "auto" | "fast" | "deep" | "instant";
|
||||||
|
numResults?: number;
|
||||||
|
includeDomains?: string[];
|
||||||
|
excludeDomains?: string[];
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const data = await api<{ search: SearchDetail }>(`/v1/searches/${searchId}/run`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return data.search;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runCompletion(body: {
|
export async function runCompletion(body: {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
provider: "openai" | "anthropic" | "xai";
|
provider: "openai" | "anthropic" | "xai";
|
||||||
|
|||||||
Reference in New Issue
Block a user