12 Commits

20 changed files with 371 additions and 134 deletions

View File

@@ -13,6 +13,8 @@ services:
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
XAI_API_KEY: ${XAI_API_KEY:-}
EXA_API_KEY: ${EXA_API_KEY:-}
CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa}
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-}
volumes:
- sybil_data:/data
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.
- 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`.
- `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).
- 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.
@@ -161,6 +161,7 @@ Behavior notes:
Search run notes:
- 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.
- 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.
- `xai`: same tool-enabled behavior as OpenAI.
- `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`):
- 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
- 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:
- OpenAI: `gpt-4.1-mini`
- Anthropic: `claude-3-5-sonnet-latest`

View File

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

View File

@@ -4,8 +4,9 @@ public struct SplitView: View {
@State private var viewModel = SybilViewModel()
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
public init() {
@MainActor public init() {
SybilFontRegistry.registerIfNeeded()
SybilTheme.applySystemAppearance()
}
public var body: some View {
@@ -25,7 +26,6 @@ public struct SplitView: View {
} else {
NavigationSplitView {
SybilSidebarView(viewModel: viewModel)
.navigationTitle("Sybil")
} detail: {
SybilWorkspaceView(viewModel: viewModel)
}
@@ -34,6 +34,7 @@ public struct SplitView: View {
}
}
.font(.sybil(.body))
.preferredColorScheme(.dark)
.task {
await viewModel.bootstrap()
}

View File

@@ -21,12 +21,14 @@ actor SybilAPIClient {
private let configuration: APIConfiguration
private let session: URLSession
@MainActor
private static let iso8601FormatterWithFractional: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
@MainActor
private static let iso8601Formatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]

View File

@@ -25,6 +25,7 @@ struct SybilChatTranscriptView: View {
ForEach(messages) { message in
MessageBubble(message: message, isSending: isSending)
.frame(maxWidth: .infinity)
.id(message.id)
}
@@ -86,10 +87,8 @@ private struct MessageBubble: View {
}
var body: some View {
HStack(alignment: .top) {
if isUser {
Spacer(minLength: 44)
}
HStack(alignment: .top, spacing: 0) {
leadingSpacer
if let toolCallMetadata {
ToolCallActivityChip(
@@ -136,12 +135,24 @@ private struct MessageBubble: View {
.frame(maxWidth: isUser ? 420 : nil, alignment: isUser ? .trailing : .leading)
}
trailingSpacer
}
.frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading)
}
@ViewBuilder
private var leadingSpacer: some View {
if isUser {
Spacer(minLength: 44)
}
}
@ViewBuilder
private var trailingSpacer: some View {
if !isUser {
Spacer(minLength: 0)
}
}
.frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading)
}
}
private struct ToolCallActivityChip: View {

View File

@@ -10,6 +10,7 @@ extension Theme {
.text {
FontFamily(.custom("Inter"))
FontSize(15)
ForegroundColor(SybilTheme.text)
}
.code {
FontFamilyVariant(.monospaced)

View File

@@ -30,8 +30,8 @@ struct SybilPhoneShellView: View {
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
SybilWordmark(size: 19)
ToolbarItem(placement: .topBarLeading) {
SybilWordmark(size: 18)
}
}
.navigationDestination(for: PhoneRoute.self) { route in

View File

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

View File

@@ -18,8 +18,6 @@ struct SybilSidebarView: View {
var body: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 14) {
SybilWordmark(size: 31)
VStack(spacing: 10) {
sidebarActionButton(
title: "New chat",
@@ -174,6 +172,13 @@ struct SybilSidebarView: View {
.padding(10)
}
.background(SybilTheme.panelGradient)
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
SybilWordmark(size: 18)
}
}
}
private func sidebarActionButton(

View File

@@ -1,6 +1,7 @@
import CoreText
import Foundation
import SwiftUI
import UIKit
enum SybilFontRegistry {
static func registerIfNeeded() {
@@ -78,6 +79,23 @@ enum SybilTheme {
static let userBubble = Color(red: 0.29, green: 0.13, blue: 0.65)
static let danger = Color(red: 0.96, green: 0.32, blue: 0.40)
@MainActor static func applySystemAppearance() {
let navAppearance = UINavigationBarAppearance()
navAppearance.configureWithOpaqueBackground()
navAppearance.backgroundColor = UIColor(red: 0.02, green: 0.02, blue: 0.05, alpha: 1)
navAppearance.shadowColor = UIColor(red: 0.24, green: 0.20, blue: 0.38, alpha: 0.9)
navAppearance.titleTextAttributes = [
.foregroundColor: UIColor(red: 0.96, green: 0.94, blue: 1.0, alpha: 1)
]
navAppearance.largeTitleTextAttributes = navAppearance.titleTextAttributes
UINavigationBar.appearance().prefersLargeTitles = false
UINavigationBar.appearance().standardAppearance = navAppearance
UINavigationBar.appearance().compactAppearance = navAppearance
UINavigationBar.appearance().scrollEdgeAppearance = navAppearance
UINavigationBar.appearance().compactScrollEdgeAppearance = navAppearance
}
static var backgroundGradient: LinearGradient {
LinearGradient(
colors: [

View File

@@ -3,13 +3,8 @@ import SwiftUI
struct SybilWorkspaceView: View {
@Bindable var viewModel: SybilViewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@FocusState private var composerFocused: Bool
private var isCompact: Bool {
horizontalSizeClass == .compact
}
private var isSettingsSelected: Bool {
if case .settings = viewModel.selectedItem {
return true
@@ -18,7 +13,7 @@ struct SybilWorkspaceView: View {
}
private var showsHeader: Bool {
!isCompact || viewModel.errorMessage != nil
viewModel.errorMessage != nil
}
var body: some View {
@@ -60,58 +55,26 @@ struct SybilWorkspaceView: View {
composerBar
}
}
.navigationTitle(isCompact ? "" : viewModel.selectedTitle)
.navigationTitle(viewModel.selectedTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbarRole(.editor)
.toolbar {
if isCompact {
ToolbarItem(placement: .principal) {
Text(viewModel.selectedTitle)
.font(.sybil(.headline, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
}
}
if isCompact && !viewModel.isSearchMode && !isSettingsSelected {
if !isSettingsSelected {
ToolbarItem(placement: .topBarTrailing) {
compactProviderModelMenu
if viewModel.isSearchMode {
searchModeChip
} else {
providerModelMenu
}
}
}
}
.background(SybilTheme.background)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onChange(of: viewModel.isSending) { _, isSending in
if !isSending, viewModel.showsComposer {
composerFocused = true
}
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 12) {
if !isCompact {
HStack(alignment: .top, spacing: 12) {
Spacer()
if !viewModel.isSearchMode && !isSettingsSelected {
providerControls
} else if viewModel.isSearchMode {
Label("Search mode", systemImage: "globe")
.font(.sybil(.caption, weight: .medium))
.foregroundStyle(SybilTheme.accent)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(SybilTheme.accent.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(SybilTheme.accent.opacity(0.24), lineWidth: 1)
)
)
}
}
}
if let error = viewModel.errorMessage {
Text(error)
.font(.sybil(.footnote))
@@ -124,7 +87,7 @@ struct SybilWorkspaceView: View {
.background(SybilTheme.panelGradient.opacity(0.58))
}
private var compactProviderModelMenu: some View {
private var providerModelMenu: some View {
Menu {
Text("\(viewModel.provider.displayName)\(viewModel.model)")
.font(.sybil(.caption))
@@ -168,57 +131,22 @@ struct SybilWorkspaceView: View {
.accessibilityLabel("Provider and model")
}
private var providerControls: some View {
HStack(spacing: 8) {
Menu {
ForEach(Provider.allCases, id: \.self) { candidate in
Button(candidate.displayName) {
viewModel.setProvider(candidate)
}
}
} label: {
Label(viewModel.provider.displayName, systemImage: "chevron.down")
.labelStyle(.titleAndIcon)
private var searchModeChip: some View {
Label("Search", systemImage: "globe")
.font(.sybil(.caption, weight: .medium))
.foregroundStyle(SybilTheme.text)
.foregroundStyle(SybilTheme.accent)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(SybilTheme.surface.opacity(0.78))
Capsule()
.fill(SybilTheme.accent.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(SybilTheme.border.opacity(0.88), lineWidth: 1)
Capsule()
.stroke(SybilTheme.accent.opacity(0.24), lineWidth: 1)
)
)
}
Menu {
ForEach(viewModel.providerModelOptions, id: \.self) { model in
Button(model) {
viewModel.setModel(model)
}
}
} label: {
Label(viewModel.model, systemImage: "chevron.down")
.labelStyle(.titleAndIcon)
.font(.sybil(.caption, weight: .medium))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(SybilTheme.surface.opacity(0.78))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(SybilTheme.border.opacity(0.88), lineWidth: 1)
)
)
}
}
}
private var composerBar: some View {
HStack(alignment: .bottom, spacing: 10) {
TextField(

View File

@@ -1,6 +1,24 @@
import Testing
@testable import Sybil
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
@MainActor
@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`
- `XAI_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
- `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 "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({
PORT: z.coerce.number().int().positive().default(8787),
@@ -13,6 +32,18 @@ const EnvSchema = z.object({
ANTHROPIC_API_KEY: z.string().optional(),
XAI_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>;

View File

@@ -1,7 +1,9 @@
import { convert as htmlToText } from "html-to-text";
import type OpenAI from "openai";
import { z } from "zod";
import { env } from "../env.js";
import { exaClient } from "../search/exa.js";
import { searchSearxng } from "../search/searxng.js";
import type { ChatMessage } from "./types.js";
const MAX_TOOL_ROUNDS = 4;
@@ -21,6 +23,8 @@ const WebSearchArgsSchema = z
})
.strict();
type WebSearchArgs = z.infer<typeof WebSearchArgsSchema>;
const FetchUrlArgsSchema = z
.object({
url: z.string().trim().url(),
@@ -267,8 +271,7 @@ function normalizeIncomingMessages(messages: ChatMessage[]) {
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
}
async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
const args = WebSearchArgsSchema.parse(input);
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
const exa = exaClient();
const response = await exa.search(args.query, {
type: args.type ?? "auto",
@@ -292,6 +295,7 @@ async function runWebSearchTool(input: unknown): Promise<ToolRunOutcome> {
const results = Array.isArray(response?.results) ? response.results : [];
return {
ok: true,
searchEngine: "exa",
query: args.query,
requestId: response?.requestId ?? null,
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) {
const parsed = new URL(urlRaw);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {

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

@@ -452,6 +452,7 @@ export default function App() {
const searchRunCounterRef = useRef(0);
const shouldAutoScrollRef = useRef(true);
const wasSendingRef = useRef(false);
const pendingReplyScrollRef = useRef(false);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const [sidebarQuery, setSidebarQuery] = useState("");
@@ -643,6 +644,12 @@ export default function App() {
}, [providerModelPreferences]);
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(() => {
shouldAutoScrollRef.current = true;
@@ -675,11 +682,27 @@ export default function App() {
if (draftKind === "search" || selectedItem?.kind === "search") return;
const wasSending = wasSendingRef.current;
wasSendingRef.current = isSending;
if (wasSending && !isSending) return;
if (isSending) return;
if (wasSending) {
shouldAutoScrollRef.current = false;
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]);
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(() => {
if (isSending) return;
const hasWorkspaceSelection = Boolean(selectedItem) || draftKind !== null;
@@ -697,13 +720,7 @@ export default function App() {
const messages = selectedChat?.messages ?? [];
const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search";
const isSearchRunning = isSending && isSearchMode;
const isSendingActiveChat =
isSending &&
!isSearchMode &&
!!pendingChatState &&
!!pendingChatState.chatId &&
selectedItem?.kind === "chat" &&
selectedItem.id === pendingChatState.chatId;
const isSendingActiveChat = isChatReplyStreamingInView;
const displayMessages = useMemo(() => {
if (!pendingChatState) return messages.filter(isDisplayableMessage);
if (pendingChatState.chatId) {
@@ -837,6 +854,8 @@ export default function App() {
}, [contextMenu]);
const handleSendChat = async (content: string) => {
pendingReplyScrollRef.current = true;
const optimisticUserMessage: Message = {
id: `temp-user-${Date.now()}`,
createdAt: new Date().toISOString(),
@@ -1424,7 +1443,7 @@ export default function App() {
<div
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={() => {
const container = transcriptContainerRef.current;
if (!container) return;
@@ -1443,6 +1462,9 @@ export default function App() {
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>