9 Commits

19 changed files with 620 additions and 58 deletions

View File

@@ -13,6 +13,8 @@ services:
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
XAI_API_KEY: ${XAI_API_KEY:-} XAI_API_KEY: ${XAI_API_KEY:-}
EXA_API_KEY: ${EXA_API_KEY:-} EXA_API_KEY: ${EXA_API_KEY:-}
CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa}
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-}
volumes: volumes:
- sybil_data:/data - sybil_data:/data
expose: expose:

View File

@@ -114,7 +114,7 @@ Behavior notes:
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset. - Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
- For `openai` and `xai`, backend enables tool use during chat completion with an internal system instruction. - For `openai` and `xai`, backend enables tool use during chat completion with an internal system instruction.
- Available tool calls for chat: `web_search` and `fetch_url`. - Available tool calls for chat: `web_search` and `fetch_url`.
- `web_search` uses Exa and returns ranked results with per-result summaries/snippets. - `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`.
- `fetch_url` fetches a URL and returns plaintext page content (HTML converted to text server-side). - `fetch_url` fetches a URL and returns plaintext page content (HTML converted to text server-side).
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`), then stores the assistant output. - When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`), then stores the assistant output.
- `anthropic` currently runs without server-managed tool calls. - `anthropic` currently runs without server-managed tool calls.
@@ -135,6 +135,16 @@ Behavior notes:
### `GET /v1/searches/:searchId` ### `GET /v1/searches/:searchId`
- Response: `{ "search": SearchDetail }` - Response: `{ "search": SearchDetail }`
### `POST /v1/searches/:searchId/chat`
- Body: `{ "title"?: string }`
- Response: `{ "chat": ChatSummary }`
- Not found: `404 { "message": "search not found" }`
Behavior notes:
- Creates a new chat seeded with a hidden `system` message containing the search query, answer text, answer citations, and top search results.
- Clients should include existing `system` messages when sending the chat history to `/v1/chat-completions` or `/v1/chat-completions/stream`; they may hide those messages in the transcript UI.
- The default chat title is `Search: <query-or-title>`, unless `title` is supplied.
### `POST /v1/searches/:searchId/run` ### `POST /v1/searches/:searchId/run`
- Body: - Body:
```json ```json
@@ -151,6 +161,7 @@ Behavior notes:
Search run notes: Search run notes:
- Backend executes Exa search and Exa answer. - Backend executes Exa search and Exa answer.
- Search mode is independent from chat `web_search` tool configuration and remains Exa-only.
- Persists answer text/citations + ranked results. - Persists answer text/citations + ranked results.
- If both search and answer fail, endpoint returns an error. - If both search and answer fail, endpoint returns an error.

View File

@@ -105,6 +105,7 @@ Event order:
- `openai`: backend may execute internal tool calls (`web_search`, `fetch_url`) before producing final text. - `openai`: backend may execute internal tool calls (`web_search`, `fetch_url`) before producing final text.
- `xai`: same tool-enabled behavior as OpenAI. - `xai`: same tool-enabled behavior as OpenAI.
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`. - `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`.
- `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.
Tool-enabled streaming notes (`openai`/`xai`): Tool-enabled streaming notes (`openai`/`xai`):
- Stream still emits standard `meta`, `delta`, `done|error` events. - Stream still emits standard `meta`, `delta`, `done|error` events.

View File

@@ -35,7 +35,7 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
## Practical Notes ## Practical Notes
- Default API URL is `http://127.0.0.1:8787` (configurable in-app). - Default API URL is `http://127.0.0.1:8787` (configurable in-app).
- Previously saved `/api` API roots are normalized to the server root by the iOS client. - The iOS client preserves an explicit `/api` base path for proxied deployments.
- Provider fallback models: - Provider fallback models:
- OpenAI: `gpt-4.1-mini` - OpenAI: `gpt-4.1-mini`
- Anthropic: `claude-3-5-sonnet-latest` - Anthropic: `claude-3-5-sonnet-latest`

View File

@@ -19,9 +19,10 @@ targets:
TARGETED_DEVICE_FAMILY: "1,2" TARGETED_DEVICE_FAMILY: "1,2"
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
MARKETING_VERSION: 1.0 MARKETING_VERSION: 1.1
CURRENT_PROJECT_VERSION: 1 CURRENT_PROJECT_VERSION: 2
INFOPLIST_KEY_CFBundleDisplayName: Sybil INFOPLIST_KEY_CFBundleDisplayName: Sybil
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
INFOPLIST_KEY_UILaunchScreen_Generation: YES INFOPLIST_KEY_UILaunchScreen_Generation: YES
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait

View File

@@ -21,12 +21,14 @@ actor SybilAPIClient {
private let configuration: APIConfiguration private let configuration: APIConfiguration
private let session: URLSession private let session: URLSession
@MainActor
private static let iso8601FormatterWithFractional: ISO8601DateFormatter = { private static let iso8601FormatterWithFractional: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter() let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter return formatter
}() }()
@MainActor
private static let iso8601Formatter: ISO8601DateFormatter = { private static let iso8601Formatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter() let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime] formatter.formatOptions = [.withInternetDateTime]
@@ -96,6 +98,16 @@ actor SybilAPIClient {
return response.search return response.search
} }
func createChatFromSearch(searchID: String, title: String? = nil) async throws -> ChatSummary {
let response = try await request(
"/v1/searches/\(searchID)/chat",
method: "POST",
body: AnyEncodable(SearchChatCreateBody(title: title)),
responseType: ChatCreateResponse.self
)
return response.chat
}
func deleteSearch(searchID: String) async throws { func deleteSearch(searchID: String) async throws {
_ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self) _ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self)
} }
@@ -552,3 +564,7 @@ private struct SearchCreateBody: Encodable {
var title: String? var title: String?
var query: String? var query: String?
} }
private struct SearchChatCreateBody: Encodable {
var title: String?
}

View File

@@ -5,23 +5,60 @@ struct SybilSearchResultsView: View {
var search: SearchDetail? var search: SearchDetail?
var isLoading: Bool var isLoading: Bool
var isRunning: Bool var isRunning: Bool
var isStartingChat: Bool = false
var onStartChat: (() -> Void)? = nil
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
if let query = search?.query, !query.isEmpty { if let query = search?.query, !query.isEmpty {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 12) {
Text("Results for") VStack(alignment: .leading, spacing: 4) {
.font(.sybil(.footnote)) Text("Results for")
.foregroundStyle(SybilTheme.textMuted) .font(.sybil(.footnote))
Text(query) .foregroundStyle(SybilTheme.textMuted)
.font(.sybil(.title3, weight: .semibold)) Text(query)
.foregroundStyle(SybilTheme.text) .font(.sybil(.title3, weight: .semibold))
.fixedSize(horizontal: false, vertical: true) .foregroundStyle(SybilTheme.text)
.fixedSize(horizontal: false, vertical: true)
Text(resultCountLabel) Text(resultCountLabel)
.font(.sybil(.caption)) .font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
}
if let onStartChat {
Button {
onStartChat()
} label: {
HStack(spacing: 8) {
if isStartingChat {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.text)
} else {
Image(systemName: "bubble.left.and.text.bubble.right")
.font(.system(size: 14, weight: .semibold))
}
Text(isStartingChat ? "Starting chat..." : "Chat with results")
.font(.sybil(.caption, weight: .semibold))
}
.foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(SybilTheme.primary.opacity(0.14))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(SybilTheme.primary.opacity(0.30), lineWidth: 1)
)
)
}
.buttonStyle(.plain)
.disabled(!canStartChat)
.opacity(canStartChat ? 1 : 0.55)
}
} }
} }
@@ -76,6 +113,13 @@ struct SybilSearchResultsView: View {
return "\(count) result\(count == 1 ? "" : "s")" return "\(count) result\(count == 1 ? "" : "s")"
} }
private var canStartChat: Bool {
guard let search, !isLoading, !isRunning, !isStartingChat else {
return false
}
return search.answerText?.isEmpty == false || !search.results.isEmpty
}
@ViewBuilder @ViewBuilder
private var answerCard: some View { private var answerCard: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {

View File

@@ -72,11 +72,6 @@ final class SybilSettingsStore {
return nil return nil
} }
let path = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
if path.lowercased() == "api" {
components.path = ""
}
return components.url return components.url
} }
} }

View File

@@ -87,6 +87,7 @@ final class SybilViewModel {
var isLoadingCollections = false var isLoadingCollections = false
var isLoadingSelection = false var isLoadingSelection = false
var isSending = false var isSending = false
var isCreatingSearchChat = false
var errorMessage: String? var errorMessage: String?
var composer = "" var composer = ""
@@ -202,20 +203,20 @@ final class SybilViewModel {
} }
var displayedMessages: [Message] { var displayedMessages: [Message] {
let canonical = selectedChat?.messages ?? [] let canonical = displayableMessages(selectedChat?.messages ?? [])
guard let pending = pendingChatState else { guard let pending = pendingChatState else {
return canonical return canonical
} }
if let pendingID = pending.chatID { if let pendingID = pending.chatID {
if case let .chat(selectedID) = selectedItem, selectedID == pendingID { if case let .chat(selectedID) = selectedItem, selectedID == pendingID {
return pending.messages return displayableMessages(pending.messages)
} }
return canonical return canonical
} }
if draftKind == .chat { if draftKind == .chat {
return pending.messages return displayableMessages(pending.messages)
} }
return canonical return canonical
@@ -473,6 +474,36 @@ final class SybilViewModel {
isSending = false isSending = false
} }
func startChatFromSelectedSearch() async {
guard let search = selectedSearch, !isCreatingSearchChat, !isSending else {
return
}
isCreatingSearchChat = true
errorMessage = nil
do {
let client = try client()
let chat = try await client.createChatFromSearch(searchID: search.id)
draftKind = nil
pendingChatState = nil
composer = ""
chats.removeAll(where: { $0.id == chat.id })
chats.insert(chat, at: 0)
selectedItem = .chat(chat.id)
selectedSearch = nil
await refreshCollections(preferredSelection: .chat(chat.id))
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Create chat from search failed", error: error)
}
isCreatingSearchChat = false
}
private func loadInitialData(using client: SybilAPIClient) async { private func loadInitialData(using client: SybilAPIClient) async {
isLoadingCollections = true isLoadingCollections = true
errorMessage = nil errorMessage = nil
@@ -974,6 +1005,10 @@ final class SybilViewModel {
} }
} }
private func displayableMessages(_ messages: [Message]) -> [Message] {
messages.filter { $0.role != .system }
}
private func chatTitle(title: String?, messages: [Message]?) -> String { private func chatTitle(title: String?, messages: [Message]?) -> String {
if let title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { if let title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
return title return title

View File

@@ -32,8 +32,13 @@ struct SybilWorkspaceView: View {
SybilSearchResultsView( SybilSearchResultsView(
search: viewModel.selectedSearch, search: viewModel.selectedSearch,
isLoading: viewModel.isLoadingSelection, isLoading: viewModel.isLoadingSelection,
isRunning: viewModel.isSending isRunning: viewModel.isSending,
) isStartingChat: viewModel.isCreatingSearchChat
) {
Task {
await viewModel.startChatFromSelectedSearch()
}
}
} else { } else {
SybilChatTranscriptView( SybilChatTranscriptView(
messages: viewModel.displayedMessages, messages: viewModel.displayedMessages,
@@ -66,11 +71,6 @@ struct SybilWorkspaceView: View {
} }
.background(SybilTheme.background) .background(SybilTheme.background)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onChange(of: viewModel.isSending) { _, isSending in
if !isSending, viewModel.showsComposer {
composerFocused = true
}
}
} }
private var header: some View { private var header: some View {

View File

@@ -1,6 +1,24 @@
import Testing import Testing
@testable import Sybil @testable import Sybil
@Test func example() async throws { @MainActor
// Write your test here and use APIs like `#expect(...)` to check expected conditions. @Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws {
let defaults = UserDefaults(suiteName: #function)!
defaults.removePersistentDomain(forName: #function)
let settings = SybilSettingsStore(defaults: defaults)
settings.apiBaseURL = "https://sybil.bajor.cloud/api/"
#expect(settings.normalizedAPIBaseURL?.absoluteString == "https://sybil.bajor.cloud/api")
}
@MainActor
@Test func normalizedAPIBaseURLTrimsWhitespaceAndTrailingSlashes() async throws {
let defaults = UserDefaults(suiteName: #function)!
defaults.removePersistentDomain(forName: #function)
let settings = SybilSettingsStore(defaults: defaults)
settings.apiBaseURL = " http://127.0.0.1:8787/// "
#expect(settings.normalizedAPIBaseURL?.absoluteString == "http://127.0.0.1:8787")
} }

View File

@@ -44,6 +44,8 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
- `ANTHROPIC_API_KEY` - `ANTHROPIC_API_KEY`
- `XAI_API_KEY` - `XAI_API_KEY`
- `EXA_API_KEY` - `EXA_API_KEY`
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default, or `searxng` for chat tool calls only)
- `SEARXNG_BASE_URL` (required when `CHAT_WEB_SEARCH_ENGINE=searxng`; instance must allow `format=json`)
## API ## API
- `GET /health` - `GET /health`

View File

@@ -1,5 +1,24 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { config as loadDotenv } from "dotenv";
import { z } from "zod"; import { z } from "zod";
import "dotenv/config";
loadDotenv({ quiet: true });
loadDotenv({ path: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.env"), quiet: true });
const OptionalUrlSchema = z.preprocess(
(value) => (typeof value === "string" && value.trim() === "" ? undefined : value),
z.string().trim().url().optional()
);
const ChatWebSearchEngineSchema = z.preprocess(
(value) => {
if (typeof value !== "string") return value;
const trimmed = value.trim();
return trimmed ? trimmed.toLowerCase() : undefined;
},
z.enum(["exa", "searxng"]).default("exa")
);
const EnvSchema = z.object({ const EnvSchema = z.object({
PORT: z.coerce.number().int().positive().default(8787), PORT: z.coerce.number().int().positive().default(8787),
@@ -13,6 +32,18 @@ const EnvSchema = z.object({
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(), EXA_API_KEY: z.string().optional(),
// Chat-mode web_search tool configuration. Search mode remains Exa-only for now.
CHAT_WEB_SEARCH_ENGINE: ChatWebSearchEngineSchema,
SEARXNG_BASE_URL: OptionalUrlSchema,
}).superRefine((value, ctx) => {
if (value.CHAT_WEB_SEARCH_ENGINE === "searxng" && !value.SEARXNG_BASE_URL) {
ctx.addIssue({
code: "custom",
path: ["SEARXNG_BASE_URL"],
message: "SEARXNG_BASE_URL is required when CHAT_WEB_SEARCH_ENGINE=searxng",
});
}
}); });
export type Env = z.infer<typeof EnvSchema>; export type Env = z.infer<typeof EnvSchema>;

View File

@@ -1,7 +1,9 @@
import { convert as htmlToText } from "html-to-text"; import { convert as htmlToText } from "html-to-text";
import type OpenAI from "openai"; import type OpenAI from "openai";
import { z } from "zod"; import { z } from "zod";
import { env } from "../env.js";
import { exaClient } from "../search/exa.js"; import { exaClient } from "../search/exa.js";
import { searchSearxng } from "../search/searxng.js";
import type { ChatMessage } from "./types.js"; import type { ChatMessage } from "./types.js";
const MAX_TOOL_ROUNDS = 4; const MAX_TOOL_ROUNDS = 4;
@@ -21,6 +23,8 @@ const WebSearchArgsSchema = z
}) })
.strict(); .strict();
type WebSearchArgs = z.infer<typeof WebSearchArgsSchema>;
const FetchUrlArgsSchema = z const FetchUrlArgsSchema = z
.object({ .object({
url: z.string().trim().url(), url: z.string().trim().url(),
@@ -267,8 +271,7 @@ function normalizeIncomingMessages(messages: ChatMessage[]) {
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized]; return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
} }
async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> { async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
const args = WebSearchArgsSchema.parse(input);
const exa = exaClient(); const exa = exaClient();
const response = await exa.search(args.query, { const response = await exa.search(args.query, {
type: args.type ?? "auto", type: args.type ?? "auto",
@@ -292,6 +295,7 @@ async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
const results = Array.isArray(response?.results) ? response.results : []; const results = Array.isArray(response?.results) ? response.results : [];
return { return {
ok: true, ok: true,
searchEngine: "exa",
query: args.query, query: args.query,
requestId: response?.requestId ?? null, requestId: response?.requestId ?? null,
results: results.map((result: any, index: number) => ({ results: results.map((result: any, index: number) => ({
@@ -309,6 +313,40 @@ async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
}; };
} }
async function runSearxngWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
const response = await searchSearxng(args.query, {
numResults: args.numResults ?? DEFAULT_WEB_RESULTS,
includeDomains: args.includeDomains,
excludeDomains: args.excludeDomains,
});
return {
ok: true,
searchEngine: "searxng",
query: args.query,
requestId: response.requestId,
results: response.results.map((result, index) => ({
rank: index + 1,
title: result.title,
url: result.url,
publishedDate: result.publishedDate,
author: null,
summary: result.summary,
text: result.text,
highlights: result.summary ? [clipText(result.summary, 280)] : [],
engines: result.engines,
})),
};
}
async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
const args = WebSearchArgsSchema.parse(input);
if (env.CHAT_WEB_SEARCH_ENGINE === "searxng") {
return runSearxngWebSearchTool(args);
}
return runExaWebSearchTool(args);
}
function assertSafeFetchUrl(urlRaw: string) { function assertSafeFetchUrl(urlRaw: string) {
const parsed = new URL(urlRaw); const parsed = new URL(urlRaw);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {

View File

@@ -108,6 +108,13 @@ function mapSearchResultPreview(result: any, index: number) {
}; };
} }
function truncateContextPart(value: string | null | undefined, maxLength: number) {
const trimmed = value?.trim();
if (!trimmed) return null;
if (trimmed.length <= maxLength) return trimmed;
return `${trimmed.slice(0, maxLength - 1).trimEnd()}...`;
}
function parseAnswerText(answerResponse: any) { function parseAnswerText(answerResponse: any) {
if (typeof answerResponse?.answer === "string") return answerResponse.answer; if (typeof answerResponse?.answer === "string") return answerResponse.answer;
if (answerResponse?.answer) return JSON.stringify(answerResponse.answer, null, 2); if (answerResponse?.answer) return JSON.stringify(answerResponse.answer, null, 2);
@@ -153,6 +160,57 @@ function normalizeUrlForMatch(input: string | null | undefined) {
} }
} }
function buildSearchChatContext(search: any) {
const query = truncateContextPart(search.query, 500) ?? truncateContextPart(search.title, 500) ?? "Untitled search";
const lines: string[] = [
"You are Sybil. The user started this chat from a saved web search. Use the search answer and result context below when answering follow-up questions. If the context is insufficient, say so and use available tools when appropriate.",
"",
`Search query: ${query}`,
];
const answer = truncateContextPart(search.answerText, 6000);
if (answer) {
lines.push("", "Search answer:", answer);
}
if (Array.isArray(search.answerCitations) && search.answerCitations.length) {
lines.push("", "Answer citations:");
for (const [index, citation] of search.answerCitations.slice(0, 8).entries()) {
const title = truncateContextPart(citation?.title, 160);
const url = truncateContextPart(citation?.url ?? citation?.id, 400);
if (title || url) {
lines.push(`${index + 1}. ${[title, url].filter(Boolean).join(" - ")}`);
}
}
}
if (Array.isArray(search.results) && search.results.length) {
lines.push("", "Search results:");
for (const result of search.results.slice(0, 10)) {
const title = truncateContextPart(result.title, 180) ?? result.url;
const url = truncateContextPart(result.url, 500);
const published = truncateContextPart(result.publishedDate, 80);
const author = truncateContextPart(result.author, 120);
const text = truncateContextPart(result.text, 1000);
const highlights = Array.isArray(result.highlights)
? result.highlights
.map((highlight: unknown) => truncateContextPart(typeof highlight === "string" ? highlight : null, 360))
.filter(Boolean)
: [];
lines.push(`${result.rank + 1}. ${title}`);
if (url) lines.push(` URL: ${url}`);
if (published || author) lines.push(` Source detail: ${[published, author].filter(Boolean).join(" - ")}`);
if (text) lines.push(` Text: ${text}`);
for (const highlight of highlights.slice(0, 2)) {
lines.push(` Highlight: ${highlight}`);
}
}
}
return lines.join("\n");
}
function buildSseHeaders(originHeader: string | undefined) { function buildSseHeaders(originHeader: string | undefined) {
const origin = originHeader && originHeader !== "null" ? originHeader : "*"; const origin = originHeader && originHeader !== "null" ? originHeader : "*";
const headers: Record<string, string> = { const headers: Record<string, string> = {
@@ -370,6 +428,54 @@ export async function registerRoutes(app: FastifyInstance) {
return { search }; return { search };
}); });
app.post("/v1/searches/:searchId/chat", async (req) => {
requireAdmin(req);
const Params = z.object({ searchId: z.string() });
const Body = z.object({ title: z.string().optional() });
const { searchId } = Params.parse(req.params);
const body = Body.parse(req.body ?? {});
const search = await prisma.search.findUnique({
where: { id: searchId },
include: { results: { orderBy: { rank: "asc" } } },
});
if (!search) return app.httpErrors.notFound("search not found");
const fallbackTitle = search.query?.trim() || search.title?.trim() || "Search results";
const title = body.title?.trim() || `Search: ${fallbackTitle.slice(0, 72)}`;
const context = buildSearchChatContext(search);
const chat = await prisma.chat.create({
data: {
title,
messages: {
create: {
role: "system" as any,
content: context,
metadata: {
kind: "search_context",
searchId: search.id,
query: search.query,
resultCount: search.results.length,
},
},
},
},
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
},
});
return { chat };
});
app.post("/v1/searches/:searchId/run", async (req) => { app.post("/v1/searches/:searchId/run", async (req) => {
requireAdmin(req); requireAdmin(req);
const Params = z.object({ searchId: z.string() }); const Params = z.object({ searchId: z.string() });

View File

@@ -0,0 +1,160 @@
import { env } from "../env.js";
const SEARXNG_TIMEOUT_MS = 12_000;
const DEFAULT_SEARXNG_CATEGORIES = "general";
export type SearxngSearchOptions = {
numResults: number;
includeDomains?: string[];
excludeDomains?: string[];
};
export type SearxngSearchResult = {
title: string | null;
url: string | null;
publishedDate: string | null;
summary: string | null;
text: string | null;
engines: string[];
};
export type SearxngSearchResponse = {
query: string;
requestId: null;
results: SearxngSearchResult[];
};
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 requireSearxngBaseUrl() {
if (!env.SEARXNG_BASE_URL) {
throw new Error("SEARXNG_BASE_URL not set");
}
return env.SEARXNG_BASE_URL.endsWith("/") ? env.SEARXNG_BASE_URL : `${env.SEARXNG_BASE_URL}/`;
}
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: SearxngSearchResult[], options: SearxngSearchOptions) {
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 buildSearxngQuery(query: string, options: SearxngSearchOptions) {
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: SearxngSearchOptions) {
const url = new URL("search", requireSearxngBaseUrl());
url.searchParams.set("q", buildSearxngQuery(query, options));
url.searchParams.set("categories", DEFAULT_SEARXNG_CATEGORIES);
url.searchParams.set("language", "auto");
url.searchParams.set("safesearch", "1");
url.searchParams.set("format", "json");
return url;
}
async function fetchSearxng(url: URL, accept: string) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), SEARXNG_TIMEOUT_MS);
try {
return await fetch(url, {
redirect: "follow",
signal: controller.signal,
headers: {
"User-Agent": "SybilBot/1.0 (+https://sybil.local)",
Accept: accept,
},
});
} finally {
clearTimeout(timeout);
}
}
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 mapJsonResult(result: any): SearxngSearchResult {
const summary = stringOrNull(result?.content) ?? stringOrNull(result?.snippet);
const text = summary ? clipText(summary, 700) : null;
return {
title: stringOrNull(result?.title),
url: stringOrNull(result?.url),
publishedDate: stringOrNull(result?.publishedDate) ?? stringOrNull(result?.published_date),
summary: summary ? clipText(summary, 1_400) : null,
text,
engines: stringArray(result?.engines ?? (typeof result?.engine === "string" ? [result.engine] : [])),
};
}
export async function searchSearxng(query: string, options: SearxngSearchOptions): Promise<SearxngSearchResponse> {
const url = buildSearchUrl(query, options);
const response = await fetchSearxng(url, "application/json");
if (!response.ok) {
await response.arrayBuffer();
throw new Error(`SearXNG JSON search failed with status ${response.status}. Verify search.formats includes json.`);
}
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
if (!contentType.includes("application/json")) {
await response.arrayBuffer();
throw new Error(`SearXNG JSON search returned ${contentType || "unknown content type"}.`);
}
const data: any = await response.json();
const results = Array.isArray(data?.results) ? data.results.map(mapJsonResult) : [];
return { query, requestId: null, results: filterResultsByDomains(results, options).slice(0, options.numResults) };
}

View File

@@ -8,6 +8,7 @@ import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
import { SearchResultsPanel } from "@/components/search/search-results-panel"; import { SearchResultsPanel } from "@/components/search/search-results-panel";
import { import {
createChat, createChat,
createChatFromSearch,
createSearch, createSearch,
deleteChat, deleteChat,
deleteSearch, deleteSearch,
@@ -164,6 +165,10 @@ function isToolCallLogMessage(message: Message) {
return asToolLogMetadata(message.metadata) !== null; return asToolLogMetadata(message.metadata) !== null;
} }
function isDisplayableMessage(message: Message) {
return message.role !== "system";
}
function buildOptimisticToolMessage(event: ToolCallEvent): Message { function buildOptimisticToolMessage(event: ToolCallEvent): Message {
return { return {
id: `temp-tool-${event.toolCallId}`, id: `temp-tool-${event.toolCallId}`,
@@ -427,6 +432,7 @@ export default function App() {
const [isLoadingCollections, setIsLoadingCollections] = useState(false); const [isLoadingCollections, setIsLoadingCollections] = useState(false);
const [isLoadingSelection, setIsLoadingSelection] = useState(false); const [isLoadingSelection, setIsLoadingSelection] = useState(false);
const [isSending, setIsSending] = useState(false); const [isSending, setIsSending] = useState(false);
const [isStartingSearchChat, setIsStartingSearchChat] = useState(false);
const [pendingChatState, setPendingChatState] = useState<{ chatId: string | null; messages: Message[] } | null>(null); const [pendingChatState, setPendingChatState] = useState<{ chatId: string | null; messages: Message[] } | null>(null);
const [composer, setComposer] = useState(""); const [composer, setComposer] = useState("");
const [provider, setProvider] = useState<Provider>("openai"); const [provider, setProvider] = useState<Provider>("openai");
@@ -446,6 +452,7 @@ export default function App() {
const searchRunCounterRef = useRef(0); const searchRunCounterRef = useRef(0);
const shouldAutoScrollRef = useRef(true); const shouldAutoScrollRef = useRef(true);
const wasSendingRef = useRef(false); const wasSendingRef = useRef(false);
const pendingReplyScrollRef = useRef(false);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null); const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const [sidebarQuery, setSidebarQuery] = useState(""); const [sidebarQuery, setSidebarQuery] = useState("");
@@ -637,6 +644,12 @@ export default function App() {
}, [providerModelPreferences]); }, [providerModelPreferences]);
const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null; const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null;
const isChatReplyStreamingInView =
isSending &&
draftKind !== "search" &&
selectedItem?.kind !== "search" &&
!!pendingChatState &&
(!pendingChatState.chatId || (selectedItem?.kind === "chat" && selectedItem.id === pendingChatState.chatId));
useEffect(() => { useEffect(() => {
shouldAutoScrollRef.current = true; shouldAutoScrollRef.current = true;
@@ -669,11 +682,27 @@ export default function App() {
if (draftKind === "search" || selectedItem?.kind === "search") return; if (draftKind === "search" || selectedItem?.kind === "search") return;
const wasSending = wasSendingRef.current; const wasSending = wasSendingRef.current;
wasSendingRef.current = isSending; wasSendingRef.current = isSending;
if (wasSending && !isSending) return; if (isSending) return;
if (wasSending) {
shouldAutoScrollRef.current = false;
return;
}
if (!shouldAutoScrollRef.current) return; if (!shouldAutoScrollRef.current) return;
transcriptEndRef.current?.scrollIntoView({ behavior: isSending ? "smooth" : "auto", block: "end" }); transcriptEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
}, [draftKind, selectedChat?.messages.length, isSending, selectedItem?.kind, selectedKey]); }, [draftKind, selectedChat?.messages.length, isSending, selectedItem?.kind, selectedKey]);
useEffect(() => {
if (!isChatReplyStreamingInView || !pendingReplyScrollRef.current) return;
pendingReplyScrollRef.current = false;
shouldAutoScrollRef.current = true;
window.requestAnimationFrame(() => {
const container = transcriptContainerRef.current;
if (!container) return;
container.scrollTo({ top: container.scrollHeight, behavior: "smooth" });
});
}, [isChatReplyStreamingInView, pendingChatState?.chatId]);
useEffect(() => { useEffect(() => {
if (isSending) return; if (isSending) return;
const hasWorkspaceSelection = Boolean(selectedItem) || draftKind !== null; const hasWorkspaceSelection = Boolean(selectedItem) || draftKind !== null;
@@ -691,22 +720,16 @@ export default function App() {
const messages = selectedChat?.messages ?? []; const messages = selectedChat?.messages ?? [];
const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search"; const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search";
const isSearchRunning = isSending && isSearchMode; const isSearchRunning = isSending && isSearchMode;
const isSendingActiveChat = const isSendingActiveChat = isChatReplyStreamingInView;
isSending &&
!isSearchMode &&
!!pendingChatState &&
!!pendingChatState.chatId &&
selectedItem?.kind === "chat" &&
selectedItem.id === pendingChatState.chatId;
const displayMessages = useMemo(() => { const displayMessages = useMemo(() => {
if (!pendingChatState) return messages; if (!pendingChatState) return messages.filter(isDisplayableMessage);
if (pendingChatState.chatId) { if (pendingChatState.chatId) {
if (selectedItem?.kind === "chat" && selectedItem.id === pendingChatState.chatId) { if (selectedItem?.kind === "chat" && selectedItem.id === pendingChatState.chatId) {
return pendingChatState.messages; return pendingChatState.messages.filter(isDisplayableMessage);
} }
return messages; return messages.filter(isDisplayableMessage);
} }
return isSearchMode ? messages : pendingChatState.messages; return (isSearchMode ? messages : pendingChatState.messages).filter(isDisplayableMessage);
}, [isSearchMode, messages, pendingChatState, selectedItem]); }, [isSearchMode, messages, pendingChatState, selectedItem]);
const selectedChatSummary = useMemo(() => { const selectedChatSummary = useMemo(() => {
@@ -831,6 +854,8 @@ export default function App() {
}, [contextMenu]); }, [contextMenu]);
const handleSendChat = async (content: string) => { const handleSendChat = async (content: string) => {
pendingReplyScrollRef.current = true;
const optimisticUserMessage: Message = { const optimisticUserMessage: Message = {
id: `temp-user-${Date.now()}`, id: `temp-user-${Date.now()}`,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@@ -1149,6 +1174,47 @@ export default function App() {
await refreshCollections({ kind: "search", id: searchId }); await refreshCollections({ kind: "search", id: searchId });
}; };
const handleStartChatFromSearch = async () => {
if (!selectedSearch || isStartingSearchChat || isSending) return;
setError(null);
setIsStartingSearchChat(true);
try {
const chat = await createChatFromSearch(selectedSearch.id);
setDraftKind(null);
setPendingChatState(null);
setComposer("");
setChats((current) => {
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
return [chat, ...withoutExisting];
});
setSelectedItem({ kind: "chat", id: chat.id });
setSelectedChat({
id: chat.id,
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel,
messages: [],
});
setSelectedSearch(null);
await refreshCollections({ kind: "chat", id: chat.id });
await refreshChat(chat.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setError(message);
}
} finally {
setIsStartingSearchChat(false);
}
};
const handleSend = async () => { const handleSend = async () => {
const content = composer.trim(); const content = composer.trim();
if (!content || isSending) return; if (!content || isSending) return;
@@ -1377,7 +1443,7 @@ export default function App() {
<div <div
ref={transcriptContainerRef} ref={transcriptContainerRef}
className="flex-1 overflow-y-auto px-4 pt-8 md:px-10 lg:px-14 pb-36 md:pb-44" className="flex-1 overflow-y-auto px-4 pt-8 md:px-10 lg:px-14 pb-36 md:pb-44 [overflow-anchor:none]"
onScroll={() => { onScroll={() => {
const container = transcriptContainerRef.current; const container = transcriptContainerRef.current;
if (!container) return; if (!container) return;
@@ -1388,8 +1454,17 @@ export default function App() {
{!isSearchMode ? ( {!isSearchMode ? (
<ChatMessagesPanel messages={displayMessages} isLoading={isLoadingSelection} isSending={isSendingActiveChat} /> <ChatMessagesPanel messages={displayMessages} isLoading={isLoadingSelection} isSending={isSendingActiveChat} />
) : ( ) : (
<SearchResultsPanel search={selectedSearch} isLoading={isLoadingSelection} isRunning={isSearchRunning} /> <SearchResultsPanel
search={selectedSearch}
isLoading={isLoadingSelection}
isRunning={isSearchRunning}
isStartingChat={isStartingSearchChat}
onStartChat={selectedSearch ? handleStartChatFromSearch : undefined}
/>
)} )}
{isChatReplyStreamingInView ? (
<div className="mx-auto mt-6 h-[52vh] min-h-72 max-h-[36rem] max-w-4xl" aria-hidden="true" />
) : null}
<div ref={transcriptEndRef} /> <div ref={transcriptEndRef} />
</div> </div>

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "preact/hooks";
import type { SearchDetail } from "@/lib/api"; import type { SearchDetail } from "@/lib/api";
import { MarkdownContent } from "@/components/markdown/markdown-content"; import { MarkdownContent } from "@/components/markdown/markdown-content";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MessageSquare } from "lucide-preact";
function formatHost(url: string) { function formatHost(url: string) {
try { try {
@@ -29,6 +30,8 @@ type Props = {
className?: string; className?: string;
enableKeyboardNavigation?: boolean; enableKeyboardNavigation?: boolean;
openLinksInNewTab?: boolean; openLinksInNewTab?: boolean;
isStartingChat?: boolean;
onStartChat?: () => void;
}; };
export function SearchResultsPanel({ export function SearchResultsPanel({
@@ -38,6 +41,8 @@ export function SearchResultsPanel({
className, className,
enableKeyboardNavigation = false, enableKeyboardNavigation = false,
openLinksInNewTab = true, openLinksInNewTab = true,
isStartingChat = false,
onStartChat,
}: Props) { }: Props) {
const ANSWER_COLLAPSED_HEIGHT_CLASS = "h-[3rem]"; const ANSWER_COLLAPSED_HEIGHT_CLASS = "h-[3rem]";
const [isAnswerExpanded, setIsAnswerExpanded] = useState(false); const [isAnswerExpanded, setIsAnswerExpanded] = useState(false);
@@ -133,17 +138,31 @@ export function SearchResultsPanel({
const isAnswerLoading = isRunning && !hasAnswerText; const isAnswerLoading = isRunning && !hasAnswerText;
const hasCitations = citationEntries.length > 0; const hasCitations = citationEntries.length > 0;
const isExpandable = hasAnswerText && (canExpandAnswer || hasCitations); const isExpandable = hasAnswerText && (canExpandAnswer || hasCitations);
const canStartChat = !!search && !isLoading && !isRunning && !isStartingChat && (!!search.answerText || search.results.length > 0);
return ( return (
<div className={className ?? "mx-auto w-full max-w-4xl"}> <div className={className ?? "mx-auto w-full max-w-4xl"}>
{search?.query ? ( {search?.query ? (
<div className="mb-5"> <div className="mb-5 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<p className="text-sm text-muted-foreground">Results for</p> <div className="min-w-0">
<h2 className="mt-1 break-words text-xl font-semibold text-violet-50">{search.query}</h2> <p className="text-sm text-muted-foreground">Results for</p>
<p className="mt-1 text-xs text-muted-foreground"> <h2 className="mt-1 break-words text-xl font-semibold text-violet-50">{search.query}</h2>
{search.results.length} result{search.results.length === 1 ? "" : "s"} <p className="mt-1 text-xs text-muted-foreground">
{search.latencyMs ? `${search.latencyMs} ms` : ""} {search.results.length} result{search.results.length === 1 ? "" : "s"}
</p> {search.latencyMs ? `${search.latencyMs} ms` : ""}
</p>
</div>
{onStartChat ? (
<button
type="button"
className="inline-flex h-10 shrink-0 items-center justify-center gap-2 rounded-lg border border-violet-300/24 bg-violet-300/10 px-3 text-sm font-medium text-violet-50 transition hover:bg-violet-300/16 disabled:cursor-not-allowed disabled:opacity-50"
onClick={onStartChat}
disabled={!canStartChat}
>
<MessageSquare className="h-4 w-4" />
{isStartingChat ? "Starting chat..." : "Chat with results"}
</button>
) : null}
</div> </div>
) : null} ) : null}

View File

@@ -239,6 +239,14 @@ export async function getSearch(searchId: string) {
return data.search; return data.search;
} }
export async function createChatFromSearch(searchId: string, body?: { title?: string }) {
const data = await api<{ chat: ChatSummary }>(`/v1/searches/${searchId}/chat`, {
method: "POST",
body: JSON.stringify(body ?? {}),
});
return data.chat;
}
export async function deleteSearch(searchId: string) { export async function deleteSearch(searchId: string) {
await api<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" }); await api<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
} }