Compare commits

...

11 Commits

24 changed files with 1647 additions and 399 deletions

26
dist/default.conf vendored
View File

@@ -12,17 +12,43 @@ server {
location /api/ {
proxy_pass http://server:8787/;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location = /sw.js {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
expires -1;
try_files $uri =404;
}
location = /manifest.webmanifest {
default_type application/manifest+json;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
expires -1;
try_files $uri =404;
}
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
expires -1;
try_files $uri =404;
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
expires -1;
try_files $uri $uri/ /index.html;
}
}

View File

@@ -17,6 +17,7 @@ services:
HERMES_AGENT_API_KEY: ${HERMES_AGENT_API_KEY:-}
HERMES_AGENT_MODEL: ${HERMES_AGENT_MODEL:-}
EXA_API_KEY: ${EXA_API_KEY:-}
BRAVE_SEARCH_API_KEY: ${BRAVE_SEARCH_API_KEY:-}
CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa}
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-}
CHAT_MAX_TOOL_ROUNDS: ${CHAT_MAX_TOOL_ROUNDS:-100}

View File

@@ -186,6 +186,7 @@ Behavior notes:
- If the chat already has a non-empty title, server returns the existing chat unchanged.
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
- When no title exists at write time, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
- If the title provider is unavailable or rejects the request, server still persists a deterministic title derived from the first line of `content` instead of leaving the chat untitled.
### `DELETE /v1/chats/:chatId`
- Response: `{ "deleted": true }`
@@ -304,7 +305,8 @@ Behavior notes:
- For `openai`, Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
- Available Sybil-managed tool calls for `openai`, `anthropic`, `xai`, and `gemini`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`.
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE`: `exa` (default), `brave` (requires `BRAVE_SEARCH_API_KEY`), or `searxng` (requires `SEARXNG_BASE_URL`; the instance must allow `format=json`).
- Brave searches are queued and evenly paced according to the shortest window in Brave's `X-RateLimit-Policy` response header. The backend also honors `X-RateLimit-Remaining`/`X-RateLimit-Reset` and retries `429` responses up to three times with reset-aware exponential backoff; quota resets beyond the bounded retry window fail immediately.
- `fetch_url` fetches a URL with browser-like navigation headers and returns plaintext page content (HTML converted to text server-side).
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
- `shell_exec` runs arbitrary non-interactive shell commands on the same configured devbox, starting in `CHAT_CODEX_REMOTE_WORKDIR`. It uses `bash -lc` when bash exists, otherwise `sh -lc`, closes SSH stdin, and does not run inside the Sybil server container.
@@ -322,6 +324,16 @@ Behavior notes:
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests emit an initiated SSE `tool_call` event before execution, then persist each completed or failed tool call as its terminal SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
## Streaming Chat
### `POST /v1/chat-completions/stream`
- The request accepts the chat-completion fields above plus optional `persist` and `clientRequestId` fields.
- `clientRequestId` is only valid for a persisted request with a `chatId`, may be up to 128 characters, and should be a stable unique value generated once per user submission.
- Retrying with the same `chatId` and `clientRequestId` replays the matching active or completed stream rather than starting a duplicate provider call.
- The server persists the ID in `metadata.clientRequestId` on the submitted user message and completed assistant message.
- The complete request, SSE event, persistence, retry, and attach contracts are defined in `docs/api/streaming-chat.md`.
## Searches
### `GET /v1/searches`

View File

@@ -21,6 +21,7 @@ Authentication:
{
"chatId": "optional-chat-id",
"persist": true,
"clientRequestId": "optional-client-generated-id",
"provider": "openai|anthropic|xai|gemini|hermes-agent",
"model": "string",
"messages": [
@@ -61,6 +62,9 @@ Notes:
- If `persist` is `true` and `chatId` is omitted, backend creates a new chat.
- If `chatId` is provided, backend validates it exists.
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
- `clientRequestId` is optional and is only valid for a persisted stream with a `chatId`. Clients should generate one stable, unique value per user submission and reuse it when retrying a disconnected request.
- A retry with the same `chatId` and `clientRequestId` attaches to and replays the matching active stream. If that submission already completed, the endpoint replays `meta` and `done` without invoking the provider again. This makes retrying the initial streaming `POST` idempotent.
- `clientRequestId` values may be up to 128 characters. The server stores the value in `metadata.clientRequestId` on the submitted user message and completed assistant message.
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
@@ -70,7 +74,7 @@ Notes:
Persisted chat streams with a `chatId` are backend-owned active runs:
- Once started, the backend keeps the stream running even if the HTTP client disconnects or refreshes.
- While running, `GET /v1/active-runs` includes the `chatId`.
- Starting a second persisted stream for the same active `chatId` returns `409`.
- Starting a second persisted stream for the same active `chatId` returns `409`, unless its `clientRequestId` matches the active submission, in which case the existing stream is replayed.
- Clients can reattach with `POST /v1/chats/:chatId/stream/attach`.
## Attach Endpoint
@@ -182,7 +186,8 @@ Terminal tool-call event:
- `xai` and `hermes-agent`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts.
- `openai`: Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`, and emits normalized `tool_call` SSE events when Anthropic `tool_use` blocks are executed. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks.
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. This only affects chat-mode tool calls, not search-mode endpoints.
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE`: `exa` (default), `brave` (requires `BRAVE_SEARCH_API_KEY`), or `searxng` (requires `SEARXNG_BASE_URL`; the instance must allow `format=json`). This only affects chat-mode tool calls, not search-mode endpoints.
- Brave searches are queued and evenly paced according to the shortest window in Brave's `X-RateLimit-Policy` response header. The backend also honors `X-RateLimit-Remaining`/`X-RateLimit-Reset` and retries `429` responses up to three times with reset-aware exponential backoff; quota resets beyond the bounded retry window fail immediately.
- `codex_exec` is available only when `CHAT_CODEX_TOOL_ENABLED=true`. It SSHes to `CHAT_CODEX_REMOTE_HOST`, creates/uses `CHAT_CODEX_REMOTE_WORKDIR`, and runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` there with SSH stdin closed. Prefer `CHAT_CODEX_SSH_KEY_PATH` with a read-only mounted private key; `CHAT_CODEX_SSH_PRIVATE_KEY_B64` is also supported.
- `shell_exec` is available only when `CHAT_SHELL_TOOL_ENABLED=true`. It uses the same devbox SSH configuration, starts in `CHAT_CODEX_REMOTE_WORKDIR`, and runs non-interactive shell commands there with SSH stdin closed, not inside the Sybil server container.
- `CHAT_MAX_TOOL_ROUNDS` controls how many model/tool result cycles may occur before the backend returns a tool-call limit message; default is 100.

View File

@@ -48,7 +48,8 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
- `HERMES_AGENT_API_KEY` (enables the Hermes Agent provider; set to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth)
- `HERMES_AGENT_MODEL` (optional fallback/override model id; defaults client-side to `hermes-agent`)
- `EXA_API_KEY`
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default, or `searxng` for chat tool calls only)
- `BRAVE_SEARCH_API_KEY` (required when `CHAT_WEB_SEARCH_ENGINE=brave`)
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default; `brave` and `searxng` are also supported for chat tool calls only)
- `SEARXNG_BASE_URL` (required when `CHAT_WEB_SEARCH_ENGINE=searxng`; instance must allow `format=json`)
- `CHAT_MAX_TOOL_ROUNDS` (`100` by default; maximum model/tool result cycles per chat completion)
- `CHAT_CODEX_TOOL_ENABLED` (`false` by default; enables the `codex_exec` chat tool for managed-tool providers)

View File

@@ -24,7 +24,7 @@ const ChatWebSearchEngineSchema = z.preprocess(
const trimmed = value.trim();
return trimmed ? trimmed.toLowerCase() : undefined;
},
z.enum(["exa", "searxng"]).default("exa")
z.enum(["exa", "searxng", "brave"]).default("exa")
);
const BooleanFlagSchema = z.preprocess((value) => {
@@ -71,6 +71,7 @@ const EnvSchema = z.object({
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
EXA_API_KEY: z.string().optional(),
BRAVE_SEARCH_API_KEY: OptionalTrimmedStringSchema,
// Chat-mode web_search tool configuration. Search mode remains Exa-only for now.
CHAT_WEB_SEARCH_ENGINE: ChatWebSearchEngineSchema,
@@ -100,6 +101,14 @@ const EnvSchema = z.object({
});
}
if (value.CHAT_WEB_SEARCH_ENGINE === "brave" && !value.BRAVE_SEARCH_API_KEY) {
ctx.addIssue({
code: "custom",
path: ["BRAVE_SEARCH_API_KEY"],
message: "BRAVE_SEARCH_API_KEY is required when CHAT_WEB_SEARCH_ENGINE=brave",
});
}
if ((value.CHAT_CODEX_TOOL_ENABLED || value.CHAT_SHELL_TOOL_ENABLED) && !value.CHAT_CODEX_REMOTE_HOST) {
ctx.addIssue({
code: "custom",

View File

@@ -7,6 +7,7 @@ import { convert as htmlToText } from "html-to-text";
import { z } from "zod";
import { buildBrowserLikeNavigationHeaders } from "../browser-fetch-headers.js";
import { env } from "../env.js";
import { searchBrave } from "../search/brave.js";
import { exaClient } from "../search/exa.js";
import { searchSearxng } from "../search/searxng.js";
import type { ChatMessage } from "./types.js";
@@ -507,11 +508,33 @@ async function runSearxngWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutc
};
}
async function runBraveWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
const response = await searchBrave(args.query, {
numResults: args.numResults ?? DEFAULT_WEB_RESULTS,
includeDomains: args.includeDomains,
excludeDomains: args.excludeDomains,
});
return {
ok: true,
searchEngine: "brave",
query: args.query,
requestId: response.requestId,
results: response.results.map((result, index) => ({
rank: index + 1,
...result,
})),
};
}
async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
const args = WebSearchArgsSchema.parse(input);
if (env.CHAT_WEB_SEARCH_ENGINE === "searxng") {
return runSearxngWebSearchTool(args);
}
if (env.CHAT_WEB_SEARCH_ENGINE === "brave") {
return runBraveWebSearchTool(args);
}
return runExaWebSearchTool(args);
}

View File

@@ -119,7 +119,12 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
if (shouldPersist && chatId && call) {
await prisma.$transaction(async (tx) => {
await tx.message.create({
data: { chatId, role: "assistant" as any, content: text },
data: {
chatId,
role: "assistant" as any,
content: text,
metadata: req.clientRequestId ? ({ clientRequestId: req.clientRequestId } as any) : undefined,
},
});
await tx.llmCall.update({
where: { id: call.id },

View File

@@ -33,6 +33,7 @@ export type ChatMessage = {
export type MultiplexRequest = {
chatId?: string;
persist?: boolean;
clientRequestId?: string;
provider: Provider;
model: string;
messages: ChatMessage[];

View File

@@ -88,7 +88,7 @@ function withRequestUserLocation<T extends { userLocation?: string }>(body: T, r
return body.userLocation ? body : { ...body, userLocation: inferRequestUserLocation(req) };
}
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[], clientRequestId?: string) {
const incoming = messages.filter((m) => m.role !== "assistant");
if (!incoming.length) return;
@@ -109,14 +109,21 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM
const toInsert = sharedPrefix === existingNonAssistant.length ? incoming.slice(existingNonAssistant.length) : incoming;
if (!toInsert.length) return;
const finalUserMessageIndex = toInsert.map((message) => message.role).lastIndexOf("user");
await prisma.message.createMany({
data: toInsert.map((m) => ({
chatId,
role: m.role as any,
content: m.content,
name: m.name,
metadata: m.attachments?.length ? ({ attachments: m.attachments } as any) : undefined,
})),
data: toInsert.map((m, index) => {
const metadata = {
...(m.attachments?.length ? { attachments: m.attachments } : {}),
...(clientRequestId && index === finalUserMessageIndex ? { clientRequestId } : {}),
};
return {
chatId,
role: m.role as any,
content: m.content,
name: m.name,
metadata: Object.keys(metadata).length ? (metadata as any) : undefined,
};
}),
});
}
@@ -169,6 +176,7 @@ const CompletionStreamBody = z
.object({
chatId: z.string().optional(),
persist: z.boolean().optional(),
clientRequestId: z.string().trim().min(1).max(128).optional(),
provider: ProviderSchema,
model: z.string().min(1),
messages: z.array(CompletionMessageSchema),
@@ -186,6 +194,13 @@ const CompletionStreamBody = z
path: ["chatId"],
});
}
if (value.clientRequestId && (value.persist === false || !value.chatId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "clientRequestId requires a persisted stream with chatId",
path: ["clientRequestId"],
});
}
});
function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttachment[]) {
@@ -399,6 +414,7 @@ function buildSseHeaders(originHeader: string | undefined) {
type SearchRunRequest = z.infer<typeof SearchRunBody>;
const activeChatStreams = new Map<string, ActiveSseStream>();
const activeChatStreamRequestIds = new Map<string, string>();
const activeSearchStreams = new Map<string, ActiveSseStream>();
const STARRED_PROJECT_ID = "starred";
@@ -554,6 +570,7 @@ function writeSseEvent(reply: FastifyReply, event: SseStreamEvent) {
}
async function streamActiveRun(req: FastifyRequest, reply: FastifyReply, stream: ActiveSseStream) {
if (reply.raw.destroyed || reply.raw.writableEnded) return reply;
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
reply.raw.flushHeaders?.();
@@ -588,10 +605,24 @@ function mapChatStreamEvent(ev: StreamEvent): SseStreamEvent {
return { event: ev.type, data: ev };
}
function startActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>) {
function registerActiveChatStream(chatId: string, clientRequestId?: string) {
const stream = new ActiveSseStream();
activeChatStreams.set(chatId, stream);
if (clientRequestId) {
activeChatStreamRequestIds.set(chatId, clientRequestId);
} else {
activeChatStreamRequestIds.delete(chatId);
}
return stream;
}
function clearActiveChatStream(chatId: string, stream: ActiveSseStream) {
if (activeChatStreams.get(chatId) !== stream) return;
activeChatStreams.delete(chatId);
activeChatStreamRequestIds.delete(chatId);
}
function executeActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>, stream: ActiveSseStream) {
void (async () => {
let sawTerminalEvent = false;
try {
@@ -611,13 +642,54 @@ function startActiveChatStream(chatId: string, body: z.infer<typeof CompletionSt
} catch (err) {
stream.complete({ event: "error", data: { message: getErrorMessage(err) } });
} finally {
activeChatStreams.delete(chatId);
clearActiveChatStream(chatId, stream);
}
})();
}
function startActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>) {
const stream = registerActiveChatStream(chatId, body.clientRequestId);
executeActiveChatStream(chatId, body, stream);
return stream;
}
function getMetadataClientRequestId(metadata: unknown) {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null;
const clientRequestId = (metadata as Record<string, unknown>).clientRequestId;
return typeof clientRequestId === "string" ? clientRequestId : null;
}
async function findCompletedChatSubmission(chatId: string, clientRequestId: string) {
const assistantMessages = await prisma.message.findMany({
where: { chatId, role: "assistant" as any },
orderBy: { createdAt: "desc" },
select: { content: true, metadata: true },
});
return assistantMessages.find((message) => getMetadataClientRequestId(message.metadata) === clientRequestId) ?? null;
}
function completeChatSubmissionStream(
stream: ActiveSseStream,
chatId: string,
body: z.infer<typeof CompletionStreamBody>,
assistantText: string
) {
stream.emit("meta", {
type: "meta",
chatId,
callId: null,
provider: body.provider,
model: body.model,
});
stream.complete({
event: "done",
data: {
type: "done",
text: assistantText,
},
});
}
async function executeSearchRunStream(searchId: string, body: SearchRunRequest, stream: ActiveSseStream) {
const startedAt = performance.now();
const query = body.query?.trim();
@@ -935,7 +1007,18 @@ export async function registerRoutes(app: FastifyInstance) {
if (existing.title?.trim()) return { chat: serializeChatLike(existing) };
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
const suggestedRaw = await generateChatTitle(body.content);
let suggestedRaw = "";
try {
suggestedRaw = await generateChatTitle(body.content);
} catch (err) {
req.log.warn(
{
chatId: body.chatId,
err: getErrorMessage(err),
},
"chat title generation failed; using fallback"
);
}
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
await prisma.chat.updateMany({
@@ -1353,15 +1436,39 @@ export async function registerRoutes(app: FastifyInstance) {
if (!exists) return app.httpErrors.notFound("chat not found");
}
// Store only new non-assistant messages to avoid duplicate history entries.
if (body.persist !== false && body.chatId) {
await storeNonAssistantMessages(body.chatId, body.messages);
}
if (body.persist !== false && body.chatId) {
if (activeChatStreams.has(body.chatId)) {
const activeStream = activeChatStreams.get(body.chatId);
if (activeStream) {
if (body.clientRequestId && activeChatStreamRequestIds.get(body.chatId) === body.clientRequestId) {
return streamActiveRun(req, reply, activeStream);
}
return app.httpErrors.conflict("chat completion already running");
}
if (body.clientRequestId) {
const reservedStream = registerActiveChatStream(body.chatId, body.clientRequestId);
try {
const completedSubmission = await findCompletedChatSubmission(body.chatId, body.clientRequestId);
if (completedSubmission) {
completeChatSubmissionStream(reservedStream, body.chatId, body, completedSubmission.content);
clearActiveChatStream(body.chatId, reservedStream);
return streamActiveRun(req, reply, reservedStream);
}
// Store only new non-assistant messages to avoid duplicate history entries.
await storeNonAssistantMessages(body.chatId, body.messages, body.clientRequestId);
const configuredBody = await applyStoredChatSettings(body);
executeActiveChatStream(body.chatId, configuredBody, reservedStream);
return streamActiveRun(req, reply, reservedStream);
} catch (err) {
reservedStream.complete({ event: "error", data: { message: getErrorMessage(err) } });
clearActiveChatStream(body.chatId, reservedStream);
throw err;
}
}
// Legacy requests without an idempotency key retain the original behavior.
await storeNonAssistantMessages(body.chatId, body.messages);
const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body));
return streamActiveRun(req, reply, stream);
}

305
server/src/search/brave.ts Normal file
View File

@@ -0,0 +1,305 @@
import { buildBrowserLikeRequestHeaders } from "../browser-fetch-headers.js";
import { env } from "../env.js";
const BRAVE_WEB_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search";
const BRAVE_SEARCH_TIMEOUT_MS = 12_000;
const DEFAULT_BRAVE_REQUEST_INTERVAL_MS = 1_000;
const RATE_LIMIT_INTERVAL_SAFETY_RATIO = 0.05;
const MIN_RATE_LIMIT_INTERVAL_SAFETY_MS = 2;
const RATE_LIMIT_RESET_SAFETY_MS = 50;
const MAX_RATE_LIMIT_RETRIES = 3;
const MAX_RATE_LIMIT_RETRY_DELAY_MS = 8_000;
type RateLimitPolicy = {
limit: number;
windowSeconds: number;
};
let requestIntervalMs = addIntervalSafety(DEFAULT_BRAVE_REQUEST_INTERVAL_MS);
let lastRequestAtMs = 0;
let nextRequestAtMs = 0;
let quotaUnavailableUntilMs = 0;
let requestQueue = Promise.resolve();
export type BraveSearchOptions = {
numResults: number;
includeDomains?: string[];
excludeDomains?: string[];
};
export type BraveSearchResult = {
title: string | null;
url: string | null;
publishedDate: string | null;
author: string | null;
summary: string | null;
text: string | null;
highlights: string[];
};
export type BraveSearchResponse = {
query: string;
requestId: string | null;
results: BraveSearchResult[];
};
function clipText(input: string, maxCharacters: number) {
return input.length <= maxCharacters ? input : `${input.slice(0, maxCharacters)}...`;
}
function compactWhitespace(input: string) {
return input.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/\s+/g, " ").trim();
}
function requireBraveSearchApiKey() {
if (!env.BRAVE_SEARCH_API_KEY) {
throw new Error("BRAVE_SEARCH_API_KEY not set");
}
return env.BRAVE_SEARCH_API_KEY;
}
function sleep(milliseconds: number) {
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
}
function addIntervalSafety(intervalMs: number) {
return intervalMs + Math.max(MIN_RATE_LIMIT_INTERVAL_SAFETY_MS, Math.ceil(intervalMs * RATE_LIMIT_INTERVAL_SAFETY_RATIO));
}
function parseCommaSeparatedNumbers(value: string | null) {
if (!value) return [];
return value.split(",").map((part) => Number(part.trim())).map((number) => (Number.isFinite(number) ? number : null));
}
function parseRateLimitPolicy(value: string | null): RateLimitPolicy[] {
if (!value) return [];
return value.split(",").flatMap((part) => {
const match = part.trim().match(/^(\d+)\s*;\s*w=(\d+)$/i);
if (!match) return [];
const limit = Number(match[1]);
const windowSeconds = Number(match[2]);
return limit > 0 && windowSeconds > 0 ? [{ limit, windowSeconds }] : [];
});
}
function getBurstPolicyIndex(policies: RateLimitPolicy[]) {
if (!policies.length) return null;
let burstIndex = 0;
for (let index = 1; index < policies.length; index += 1) {
if (policies[index]!.windowSeconds < policies[burstIndex]!.windowSeconds) burstIndex = index;
}
return burstIndex;
}
function updateRateLimitState(headers: Headers) {
const policies = parseRateLimitPolicy(headers.get("x-ratelimit-policy"));
const burstIndex = getBurstPolicyIndex(policies);
if (burstIndex === null) return;
const burstPolicy = policies[burstIndex]!;
const learnedIntervalMs = addIntervalSafety(Math.ceil((burstPolicy.windowSeconds * 1_000) / burstPolicy.limit));
if (learnedIntervalMs < requestIntervalMs && lastRequestAtMs > 0) {
nextRequestAtMs = Math.min(nextRequestAtMs, lastRequestAtMs + learnedIntervalMs);
}
requestIntervalMs = learnedIntervalMs;
const remaining = parseCommaSeparatedNumbers(headers.get("x-ratelimit-remaining"));
const resetSeconds = parseCommaSeparatedNumbers(headers.get("x-ratelimit-reset"));
for (let index = 0; index < policies.length; index += 1) {
if ((remaining[index] ?? null) === null || remaining[index]! >= 1 || (resetSeconds[index] ?? 0) <= 0) continue;
const unavailableUntilMs = Date.now() + resetSeconds[index]! * 1_000 + RATE_LIMIT_RESET_SAFETY_MS;
if (index === burstIndex) {
nextRequestAtMs = Math.max(nextRequestAtMs, unavailableUntilMs);
} else {
quotaUnavailableUntilMs = Math.max(quotaUnavailableUntilMs, unavailableUntilMs);
}
}
}
function assertLongTermQuotaAvailable() {
if (quotaUnavailableUntilMs <= Date.now()) {
quotaUnavailableUntilMs = 0;
return;
}
const resetSeconds = Math.ceil((quotaUnavailableUntilMs - Date.now()) / 1_000);
throw new Error(`Brave Search API long-term quota is exhausted; reset is expected in ${resetSeconds} seconds.`);
}
async function waitForRateLimitSlot() {
const reservation = requestQueue.then(async () => {
while (true) {
assertLongTermQuotaAvailable();
const waitMs = nextRequestAtMs - Date.now();
if (waitMs <= 0) break;
await sleep(waitMs);
}
lastRequestAtMs = Date.now();
nextRequestAtMs = lastRequestAtMs + requestIntervalMs;
});
requestQueue = reservation.catch(() => undefined);
await reservation;
}
function get429RetryDelayMs(headers: Headers, retryNumber: number) {
const remaining = parseCommaSeparatedNumbers(headers.get("x-ratelimit-remaining"));
const resetSeconds = parseCommaSeparatedNumbers(headers.get("x-ratelimit-reset"));
const exhaustedResetSeconds = resetSeconds.filter((reset, index): reset is number => reset !== null && (remaining[index] ?? 0) < 1);
const headerDelayMs = exhaustedResetSeconds.length ? Math.max(...exhaustedResetSeconds) * 1_000 : 0;
const exponentialDelayMs = 2 ** retryNumber * 1_000;
const delayMs = Math.max(headerDelayMs + RATE_LIMIT_RESET_SAFETY_MS, exponentialDelayMs);
return delayMs <= MAX_RATE_LIMIT_RETRY_DELAY_MS ? delayMs : null;
}
async function fetchBrave(url: URL) {
const apiKey = requireBraveSearchApiKey();
for (let attempt = 0; attempt <= MAX_RATE_LIMIT_RETRIES; attempt += 1) {
await waitForRateLimitSlot();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), BRAVE_SEARCH_TIMEOUT_MS);
let response: Response;
try {
response = await fetch(url, {
signal: controller.signal,
headers: {
...buildBrowserLikeRequestHeaders("application/json"),
"X-Subscription-Token": apiKey,
},
});
} finally {
clearTimeout(timeout);
}
updateRateLimitState(response.headers);
if (response.status !== 429 || attempt === MAX_RATE_LIMIT_RETRIES) return response;
const retryDelayMs = get429RetryDelayMs(response.headers, attempt);
await response.arrayBuffer();
if (retryDelayMs === null) {
throw new Error("Brave Search API rate limit quota is exhausted beyond the retry window.");
}
await sleep(retryDelayMs);
}
throw new Error("Brave Search API request failed after rate-limit retries.");
}
function normalizeDomain(input: string) {
const trimmed = input.trim().toLowerCase();
if (!trimmed) return null;
try {
const parsed = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
return parsed.hostname.replace(/^www\./, "");
} catch {
return trimmed.split(/[/?#]/, 1)[0]?.replace(/^www\./, "") || null;
}
}
function normalizeDomains(input: string[] | undefined) {
return Array.from(new Set((input ?? []).map(normalizeDomain).filter((domain): domain is string => Boolean(domain))));
}
function hostnameMatchesDomain(urlRaw: string | null, domain: string) {
if (!urlRaw) return false;
try {
const hostname = new URL(urlRaw).hostname.toLowerCase().replace(/^www\./, "");
return hostname === domain || hostname.endsWith(`.${domain}`);
} catch {
return false;
}
}
function filterResultsByDomains(results: BraveSearchResult[], options: BraveSearchOptions) {
const includeDomains = normalizeDomains(options.includeDomains);
const excludeDomains = normalizeDomains(options.excludeDomains);
return results.filter((result) => {
if (includeDomains.length && !includeDomains.some((domain) => hostnameMatchesDomain(result.url, domain))) return false;
if (excludeDomains.some((domain) => hostnameMatchesDomain(result.url, domain))) return false;
return true;
});
}
function buildBraveQuery(query: string, options: BraveSearchOptions) {
const includeDomains = normalizeDomains(options.includeDomains);
const excludeDomains = normalizeDomains(options.excludeDomains);
const includeClause =
includeDomains.length === 0
? ""
: includeDomains.length === 1
? `site:${includeDomains[0]}`
: `(${includeDomains.map((domain) => `site:${domain}`).join(" OR ")})`;
const excludeClause = excludeDomains.map((domain) => `-site:${domain}`).join(" ");
return [query, includeClause, excludeClause].filter(Boolean).join(" ");
}
function buildSearchUrl(query: string, options: BraveSearchOptions) {
const url = new URL(BRAVE_WEB_SEARCH_URL);
url.searchParams.set("q", buildBraveQuery(query, options));
url.searchParams.set("count", String(options.numResults));
url.searchParams.set("safesearch", "moderate");
url.searchParams.set("result_filter", "web");
url.searchParams.set("text_decorations", "false");
url.searchParams.set("extra_snippets", "true");
return url;
}
function stringOrNull(value: unknown) {
if (typeof value !== "string") return null;
const normalized = compactWhitespace(value);
return normalized || null;
}
function stringArray(value: unknown) {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string").map(compactWhitespace).filter(Boolean);
}
function mapWebResult(result: any): BraveSearchResult {
const description = stringOrNull(result?.description);
const extraSnippets = stringArray(result?.extra_snippets);
const snippets = [description, ...extraSnippets].filter((snippet): snippet is string => Boolean(snippet));
const combinedText = snippets.join("\n\n");
return {
title: stringOrNull(result?.title),
url: stringOrNull(result?.url),
publishedDate: stringOrNull(result?.page_age),
author: stringOrNull(result?.profile?.name) ?? stringOrNull(result?.article?.author),
summary: description ? clipText(description, 1_400) : null,
text: combinedText ? clipText(combinedText, 700) : null,
highlights: snippets.slice(0, 3).map((snippet) => clipText(snippet, 280)),
};
}
export async function searchBrave(query: string, options: BraveSearchOptions): Promise<BraveSearchResponse> {
const url = buildSearchUrl(query, options);
const response = await fetchBrave(url);
if (!response.ok) {
await response.arrayBuffer();
throw new Error(`Brave Search API request failed with status ${response.status}.`);
}
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
if (!contentType.includes("application/json")) {
await response.arrayBuffer();
throw new Error(`Brave Search API returned ${contentType || "unknown content type"}.`);
}
const data: any = await response.json();
const results = Array.isArray(data?.web?.results) ? data.web.results.map(mapWebResult) : [];
return {
query,
requestId: response.headers.get("x-request-id"),
results: filterResultsByDomains(results, options).slice(0, options.numResults),
};
}
export function resetBraveRateLimitStateForTests() {
requestIntervalMs = addIntervalSafety(DEFAULT_BRAVE_REQUEST_INTERVAL_MS);
lastRequestAtMs = 0;
nextRequestAtMs = 0;
quotaUnavailableUntilMs = 0;
requestQueue = Promise.resolve();
}

View File

@@ -0,0 +1,284 @@
import assert from "node:assert/strict";
import test from "node:test";
import { env } from "../src/env.js";
import { resetBraveRateLimitStateForTests, searchBrave } from "../src/search/brave.js";
test("searchBrave authenticates, builds filters, and normalizes web results", async () => {
const originalFetch = globalThis.fetch;
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
resetBraveRateLimitStateForTests();
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
fetchCalls.push({ input, init });
return new Response(
JSON.stringify({
web: {
results: [
{
title: " Brave result ",
url: "https://docs.example.com/article",
description: "Main\n snippet",
extra_snippets: ["Extra snippet one", "Extra snippet two"],
page_age: "2026-07-18T12:00:00Z",
profile: { name: "Example Docs" },
},
{
title: "Excluded result",
url: "https://blocked.example.com/article",
description: "Should be filtered",
},
],
},
}),
{
status: 200,
headers: {
"content-type": "application/json; charset=utf-8",
"x-request-id": "brave-request-1",
},
}
);
}) as typeof fetch;
try {
const response = await searchBrave("latest docs", {
numResults: 5,
includeDomains: ["https://example.com/path"],
excludeDomains: ["blocked.example.com"],
});
assert.equal(fetchCalls.length, 1);
const requestUrl = new URL(String(fetchCalls[0]?.input));
assert.equal(requestUrl.origin + requestUrl.pathname, "https://api.search.brave.com/res/v1/web/search");
assert.equal(requestUrl.searchParams.get("q"), "latest docs site:example.com -site:blocked.example.com");
assert.equal(requestUrl.searchParams.get("count"), "5");
assert.equal(requestUrl.searchParams.get("safesearch"), "moderate");
assert.equal(requestUrl.searchParams.get("result_filter"), "web");
assert.equal(requestUrl.searchParams.get("text_decorations"), "false");
assert.equal(requestUrl.searchParams.get("extra_snippets"), "true");
assert.equal((fetchCalls[0]?.init?.headers as Record<string, string>)["X-Subscription-Token"], "test-brave-key");
assert.deepEqual(response, {
query: "latest docs",
requestId: "brave-request-1",
results: [
{
title: "Brave result",
url: "https://docs.example.com/article",
publishedDate: "2026-07-18T12:00:00Z",
author: "Example Docs",
summary: "Main snippet",
text: "Main snippet\n\nExtra snippet one\n\nExtra snippet two",
highlights: ["Main snippet", "Extra snippet one", "Extra snippet two"],
},
],
});
} finally {
globalThis.fetch = originalFetch;
env.BRAVE_SEARCH_API_KEY = originalApiKey;
}
});
test("searchBrave rejects requests without an API key", async () => {
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
resetBraveRateLimitStateForTests();
env.BRAVE_SEARCH_API_KEY = undefined;
try {
await assert.rejects(() => searchBrave("test", { numResults: 1 }), /BRAVE_SEARCH_API_KEY not set/);
} finally {
env.BRAVE_SEARCH_API_KEY = originalApiKey;
}
});
test("searchBrave reports non-JSON responses", async () => {
const originalFetch = globalThis.fetch;
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
resetBraveRateLimitStateForTests();
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
globalThis.fetch = (async () =>
new Response("upstream error", {
status: 200,
headers: { "content-type": "text/plain" },
})) as typeof fetch;
try {
await assert.rejects(
() => searchBrave("test", { numResults: 1 }),
/Brave Search API returned text\/plain/
);
} finally {
globalThis.fetch = originalFetch;
env.BRAVE_SEARCH_API_KEY = originalApiKey;
}
});
test("searchBrave evenly paces concurrent bursts using Brave's shortest policy window", async () => {
const originalFetch = globalThis.fetch;
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
const requestStartedAt: number[] = [];
resetBraveRateLimitStateForTests();
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
globalThis.fetch = (async () => {
requestStartedAt.push(Date.now());
return new Response(JSON.stringify({ web: { results: [] } }), {
status: 200,
headers: {
"content-type": "application/json",
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
"x-ratelimit-remaining": "1, 1999",
"x-ratelimit-reset": "1, 2678400",
},
});
}) as typeof fetch;
try {
await Promise.all([
searchBrave("burst one", { numResults: 1 }),
searchBrave("burst two", { numResults: 1 }),
searchBrave("burst three", { numResults: 1 }),
]);
assert.equal(requestStartedAt.length, 3);
assert.ok(requestStartedAt[1]! - requestStartedAt[0]! >= 1_000);
assert.ok(requestStartedAt[2]! - requestStartedAt[1]! >= 1_000);
} finally {
globalThis.fetch = originalFetch;
env.BRAVE_SEARCH_API_KEY = originalApiKey;
resetBraveRateLimitStateForTests();
}
});
test("searchBrave adapts its pacing to a 50 request-per-second Search plan", async () => {
const originalFetch = globalThis.fetch;
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
const requestStartedAt: number[] = [];
resetBraveRateLimitStateForTests();
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
globalThis.fetch = (async () => {
requestStartedAt.push(Date.now());
return new Response(JSON.stringify({ web: { results: [] } }), {
status: 200,
headers: {
"content-type": "application/json",
"x-ratelimit-policy": "50;w=1, 0;w=2678400",
"x-ratelimit-remaining": "49, 0",
"x-ratelimit-reset": "1, 2678400",
},
});
}) as typeof fetch;
try {
await searchBrave("learn upgraded policy", { numResults: 1 });
await Promise.all(Array.from({ length: 8 }, (_, index) => searchBrave(`fast burst ${index}`, { numResults: 1 })));
assert.equal(requestStartedAt.length, 9);
const burstStartedAt = requestStartedAt.slice(1);
for (let index = 1; index < burstStartedAt.length; index += 1) {
assert.ok(burstStartedAt[index]! - burstStartedAt[index - 1]! >= 18);
}
assert.ok(burstStartedAt.at(-1)! - burstStartedAt[0]! < 500);
} finally {
globalThis.fetch = originalFetch;
env.BRAVE_SEARCH_API_KEY = originalApiKey;
resetBraveRateLimitStateForTests();
}
});
test("searchBrave retries 429 responses after the burst window resets", async () => {
const originalFetch = globalThis.fetch;
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
let fetchCount = 0;
resetBraveRateLimitStateForTests();
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
globalThis.fetch = (async () => {
fetchCount += 1;
const rateLimitHeaders = {
"content-type": "application/json",
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
"x-ratelimit-remaining": fetchCount === 1 ? "0, 1999" : "1, 1998",
"x-ratelimit-reset": "1, 2678400",
};
if (fetchCount === 1) {
return new Response(JSON.stringify({ error: { detail: "Rate limit exceeded" } }), {
status: 429,
headers: rateLimitHeaders,
});
}
return new Response(JSON.stringify({ web: { results: [] } }), { status: 200, headers: rateLimitHeaders });
}) as typeof fetch;
try {
const startedAt = Date.now();
await searchBrave("retry burst", { numResults: 1 });
assert.equal(fetchCount, 2);
assert.ok(Date.now() - startedAt >= 1_000);
} finally {
globalThis.fetch = originalFetch;
env.BRAVE_SEARCH_API_KEY = originalApiKey;
resetBraveRateLimitStateForTests();
}
});
test("searchBrave does not wait for exhausted long-term quotas", async () => {
const originalFetch = globalThis.fetch;
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
resetBraveRateLimitStateForTests();
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
globalThis.fetch = (async () =>
new Response(JSON.stringify({ error: { detail: "Quota exceeded" } }), {
status: 429,
headers: {
"content-type": "application/json",
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
"x-ratelimit-remaining": "0, 0",
"x-ratelimit-reset": "1, 100000",
},
})) as typeof fetch;
try {
const startedAt = Date.now();
await assert.rejects(
() => searchBrave("quota exhausted", { numResults: 1 }),
/rate limit quota is exhausted beyond the retry window/
);
assert.ok(Date.now() - startedAt < 1_000);
} finally {
globalThis.fetch = originalFetch;
env.BRAVE_SEARCH_API_KEY = originalApiKey;
resetBraveRateLimitStateForTests();
}
});
test("searchBrave blocks locally after a successful request exhausts the long-term quota", async () => {
const originalFetch = globalThis.fetch;
const originalApiKey = env.BRAVE_SEARCH_API_KEY;
let fetchCount = 0;
resetBraveRateLimitStateForTests();
env.BRAVE_SEARCH_API_KEY = "test-brave-key";
globalThis.fetch = (async () => {
fetchCount += 1;
return new Response(JSON.stringify({ web: { results: [] } }), {
status: 200,
headers: {
"content-type": "application/json",
"x-ratelimit-policy": "1;w=1, 2000;w=2678400",
"x-ratelimit-remaining": "0, 0",
"x-ratelimit-reset": "1, 100000",
},
});
}) as typeof fetch;
try {
await searchBrave("last allowed query", { numResults: 1 });
await assert.rejects(
() => searchBrave("over quota query", { numResults: 1 }),
/long-term quota is exhausted/
);
assert.equal(fetchCount, 1);
} finally {
globalThis.fetch = originalFetch;
env.BRAVE_SEARCH_API_KEY = originalApiKey;
resetBraveRateLimitStateForTests();
}
});

View File

@@ -7,6 +7,7 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "node --test --experimental-strip-types tests/*.test.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {

View File

@@ -3,10 +3,24 @@ self.addEventListener("install", () => {
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
event.waitUntil(
(async () => {
await self.clients.claim();
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
await Promise.all(
windows.map(async (client) => {
try {
await client.navigate(client.url);
} catch {
// The client may have closed while the new worker was activating.
}
})
);
})()
);
});
self.addEventListener("fetch", (event) => {
if (event.request.mode !== "navigate") return;
event.respondWith(fetch(event.request));
event.respondWith(fetch(new Request(event.request, { cache: "no-store" })));
});

File diff suppressed because it is too large Load Diff

View File

@@ -478,6 +478,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
) : message.content.trim() ? (
<MarkdownContent
markdown={message.content}
openLinksInNewTab
className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-violet-50")}
/>
) : null}

View File

@@ -10,6 +10,7 @@ type Props = {
className?: string;
mode?: MarkdownMode;
resolveCitationIndex?: (href: string) => number | undefined;
openLinksInNewTab?: boolean;
};
function replaceMarkdownLinksWithCitationTokens(markdown: string, resolveCitationIndex?: (href: string) => number | undefined) {
@@ -28,17 +29,30 @@ markdownRenderer.table = (token) => {
return `<div class="md-table-scroll">${renderTable(token)}</div>`;
};
function renderMarkdown(markdown: string) {
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string;
return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
function setNewTabLinkAttributes(currentNode: Element) {
if (currentNode.tagName !== "A") return;
currentNode.setAttribute("target", "_blank");
currentNode.setAttribute("rel", "noopener noreferrer");
}
export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex }: Props) {
function renderMarkdown(markdown: string, openLinksInNewTab: boolean) {
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string;
if (!openLinksInNewTab) return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
DOMPurify.addHook("afterSanitizeAttributes", setNewTabLinkAttributes);
try {
return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
} finally {
DOMPurify.removeHook("afterSanitizeAttributes", setNewTabLinkAttributes);
}
}
export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex, openLinksInNewTab = false }: Props) {
const html = useMemo(() => {
const prepared =
mode === "citationTokens" ? replaceMarkdownLinksWithCitationTokens(markdown, resolveCitationIndex) : markdown;
return renderMarkdown(prepared);
}, [markdown, mode, resolveCitationIndex]);
return renderMarkdown(prepared, openLinksInNewTab);
}, [markdown, mode, openLinksInNewTab, resolveCitationIndex]);
return <div className={cn("md-content", className)} dangerouslySetInnerHTML={{ __html: html }} />;
}

View File

@@ -450,6 +450,7 @@ async function readSseStream(response: Response, dispatch: (eventName: string, p
let buffer = "";
let eventName = "message";
let dataLines: string[] = [];
let sawTerminalEvent = false;
const flushEvent = () => {
if (!dataLines.length) {
@@ -466,6 +467,9 @@ async function readSseStream(response: Response, dispatch: (eventName: string, p
}
dispatch(eventName, payload);
if (eventName === "done" || eventName === "error") {
sawTerminalEvent = true;
}
dataLines = [];
eventName = "message";
@@ -505,6 +509,10 @@ async function readSseStream(response: Response, dispatch: (eventName: string, p
}
}
flushEvent();
if (!sawTerminalEvent) {
throw new Error("Stream disconnected before completion");
}
}
export async function runSearchStream(
@@ -528,87 +536,14 @@ export async function runSearchStream(
signal: options?.signal,
});
if (!response.ok) {
const fallback = `${response.status} ${response.statusText}`;
let message = fallback;
try {
const body = (await response.json()) as { message?: string };
if (body.message) message = body.message;
} catch {
// keep fallback message
}
throw new Error(message);
}
if (!response.body) {
throw new Error("No response stream");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let eventName = "message";
let dataLines: string[] = [];
const flushEvent = () => {
if (!dataLines.length) {
eventName = "message";
return;
}
const dataText = dataLines.join("\n");
let payload: any = null;
try {
payload = JSON.parse(dataText);
} catch {
payload = { message: dataText };
}
await readSseStream(response, (eventName, payload) => {
if (eventName === "search_results") handlers.onSearchResults?.(payload);
else if (eventName === "search_error") handlers.onSearchError?.(payload);
else if (eventName === "answer") handlers.onAnswer?.(payload);
else if (eventName === "answer_error") handlers.onAnswerError?.(payload);
else if (eventName === "done") handlers.onDone?.(payload);
else if (eventName === "error") handlers.onError?.(payload);
dataLines = [];
eventName = "message";
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex >= 0) {
const rawLine = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
if (!line) {
flushEvent();
} else if (line.startsWith("event:")) {
eventName = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
newlineIndex = buffer.indexOf("\n");
}
}
buffer += decoder.decode();
if (buffer.length) {
const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
if (line.startsWith("event:")) {
eventName = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
}
flushEvent();
});
}
export async function attachSearchStream(searchId: string, handlers: RunSearchStreamHandlers, options?: { signal?: AbortSignal }) {
@@ -654,6 +589,7 @@ export async function runCompletionStream(
body: {
chatId?: string | null;
persist?: boolean;
clientRequestId?: string;
provider: Provider;
model: string;
messages: CompletionRequestMessage[];
@@ -679,86 +615,13 @@ export async function runCompletionStream(
signal: options?.signal,
});
if (!response.ok) {
const fallback = `${response.status} ${response.statusText}`;
let message = fallback;
try {
const body = (await response.json()) as { message?: string };
if (body.message) message = body.message;
} catch {
// keep fallback message
}
throw new Error(message);
}
if (!response.body) {
throw new Error("No response stream");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let eventName = "message";
let dataLines: string[] = [];
const flushEvent = () => {
if (!dataLines.length) {
eventName = "message";
return;
}
const dataText = dataLines.join("\n");
let payload: any = null;
try {
payload = JSON.parse(dataText);
} catch {
payload = { message: dataText };
}
await readSseStream(response, (eventName, payload) => {
if (eventName === "meta") handlers.onMeta?.(payload);
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
else if (eventName === "delta") handlers.onDelta?.(payload);
else if (eventName === "done") handlers.onDone?.(payload);
else if (eventName === "error") handlers.onError?.(payload);
dataLines = [];
eventName = "message";
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex >= 0) {
const rawLine = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
if (!line) {
flushEvent();
} else if (line.startsWith("event:")) {
eventName = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
newlineIndex = buffer.indexOf("\n");
}
}
buffer += decoder.decode();
if (buffer.length) {
const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
if (line.startsWith("event:")) {
eventName = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
}
flushEvent();
});
}
export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) {

View File

@@ -0,0 +1,24 @@
import type { Provider } from "./api";
type PersistedChatModel = {
lastUsedProvider: Provider | null;
lastUsedModel: string | null;
};
export type ChatModelSelection = {
provider: Provider;
model: string;
};
export function getChatModelSelection(chat: PersistedChatModel | null): ChatModelSelection | null {
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
return {
provider: chat.lastUsedProvider,
model: chat.lastUsedModel.trim(),
};
}
export function getChatModelSelectionSyncKey(chatId: string | null, selection: ChatModelSelection | null) {
if (!chatId || !selection) return null;
return JSON.stringify([chatId, selection.provider, selection.model]);
}

View File

@@ -0,0 +1,31 @@
export type SidebarSelection = { kind: "chat" | "search"; id: string };
type WorkspaceSelectionItem = { type: SidebarSelection["kind"]; id: string };
type ResolveSidebarSelectionOptions = {
initialSelection?: SidebarSelection;
selectFallback?: boolean;
};
export function resolveSidebarSelectionAfterRefresh(
current: SidebarSelection | null,
workspaceItems: WorkspaceSelectionItem[],
{ initialSelection, selectFallback = false }: ResolveSidebarSelectionOptions = {}
): SidebarSelection | null {
const hasItem = (candidate: SidebarSelection | null | undefined) => {
if (!candidate) return false;
return workspaceItems.some((item) => item.type === candidate.kind && item.id === candidate.id);
};
if (hasItem(current)) {
return current;
}
if (hasItem(initialSelection)) {
return initialSelection ?? null;
}
if (!selectFallback) {
return null;
}
const first = workspaceItems[0];
return first ? { kind: first.type, id: first.id } : null;
}

View File

@@ -2,8 +2,11 @@ export function registerServiceWorker() {
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
window.addEventListener("load", () => {
void navigator.serviceWorker.register("/sw.js").catch((error: unknown) => {
console.warn("Sybil service worker registration failed", error);
});
void navigator.serviceWorker
.register("/sw.js", { updateViaCache: "none" })
.then((registration) => registration.update())
.catch((error: unknown) => {
console.warn("Sybil service worker registration failed", error);
});
});
}

View File

@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getChatModelSelection,
getChatModelSelectionSyncKey,
} from "../src/lib/chat-model-selection.ts";
test("chat model selections are normalized from persisted metadata", () => {
assert.deepEqual(
getChatModelSelection({
lastUsedProvider: "anthropic",
lastUsedModel: " claude-sonnet-4-5 ",
}),
{
provider: "anthropic",
model: "claude-sonnet-4-5",
}
);
});
test("unrelated chat updates do not change the model synchronization key", () => {
const beforeSettingsSave = getChatModelSelection({
lastUsedProvider: "openai",
lastUsedModel: "gpt-4.1-mini",
});
const afterSettingsSave = getChatModelSelection({
lastUsedProvider: "openai",
lastUsedModel: "gpt-4.1-mini",
});
assert.equal(
getChatModelSelectionSyncKey("chat-1", beforeSettingsSave),
getChatModelSelectionSyncKey("chat-1", afterSettingsSave)
);
});
test("switching chats or persisted models changes the synchronization key", () => {
const original = { provider: "openai", model: "gpt-4.1-mini" };
const updated = { provider: "gemini", model: "gemini-3.5-flash" };
assert.notEqual(
getChatModelSelectionSyncKey("chat-1", original),
getChatModelSelectionSyncKey("chat-2", original)
);
assert.notEqual(
getChatModelSelectionSyncKey("chat-1", original),
getChatModelSelectionSyncKey("chat-1", updated)
);
});

View File

@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveSidebarSelectionAfterRefresh } from "../src/lib/sidebar-selection.ts";
const workspaceItems = [
{ type: "chat", id: "completed-chat" },
{ type: "chat", id: "selected-chat" },
{ type: "search", id: "selected-search" },
];
test("a collection refresh preserves the current thread selection", () => {
assert.deepEqual(
resolveSidebarSelectionAfterRefresh({ kind: "chat", id: "selected-chat" }, workspaceItems),
{ kind: "chat", id: "selected-chat" }
);
});
test("an initial route selection cannot override a current thread selection", () => {
assert.deepEqual(
resolveSidebarSelectionAfterRefresh(
{ kind: "search", id: "selected-search" },
workspaceItems,
{ initialSelection: { kind: "chat", id: "completed-chat" }, selectFallback: true }
),
{ kind: "search", id: "selected-search" }
);
});
test("a collection refresh preserves an intentionally empty selection", () => {
assert.equal(resolveSidebarSelectionAfterRefresh(null, workspaceItems), null);
});
test("initial load can select the URL thread or fall back to the first item", () => {
assert.deepEqual(
resolveSidebarSelectionAfterRefresh(null, workspaceItems, {
initialSelection: { kind: "search", id: "selected-search" },
selectFallback: true,
}),
{ kind: "search", id: "selected-search" }
);
assert.deepEqual(resolveSidebarSelectionAfterRefresh(null, workspaceItems, { selectFallback: true }), {
kind: "chat",
id: "completed-chat",
});
});

View File

@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/chat-model-selection.ts","./src/lib/sidebar-selection.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}