Compare commits
1 Commits
d7967eaa75
...
codex/ios-
| Author | SHA1 | Date | |
|---|---|---|---|
| d03b4c4dd7 |
3
Tiltfile
3
Tiltfile
@@ -1,8 +1,5 @@
|
|||||||
update_settings(max_parallel_updates=4)
|
update_settings(max_parallel_updates=4)
|
||||||
|
|
||||||
load('ext://dotenv', 'dotenv')
|
|
||||||
dotenv() # defaults to .env in the current directory
|
|
||||||
|
|
||||||
local_resource(
|
local_resource(
|
||||||
"server",
|
"server",
|
||||||
cmd="npm ci --no-audit --no-fund",
|
cmd="npm ci --no-audit --no-fund",
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ 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:
|
||||||
|
|||||||
@@ -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` 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` uses Exa and returns ranked results with per-result summaries/snippets.
|
||||||
- `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,16 +135,6 @@ 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
|
||||||
@@ -161,7 +151,6 @@ 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.
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ 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.
|
||||||
|
|||||||
@@ -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).
|
||||||
- The iOS client preserves an explicit `/api` base path for proxied deployments.
|
- Previously saved `/api` API roots are normalized to the server root by the iOS client.
|
||||||
- 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`
|
||||||
|
|||||||
@@ -19,10 +19,9 @@ 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.1
|
MARKETING_VERSION: 1.0
|
||||||
CURRENT_PROJECT_VERSION: 2
|
CURRENT_PROJECT_VERSION: 1
|
||||||
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
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ public struct SplitView: View {
|
|||||||
@State private var viewModel = SybilViewModel()
|
@State private var viewModel = SybilViewModel()
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
@MainActor public init() {
|
public init() {
|
||||||
SybilFontRegistry.registerIfNeeded()
|
SybilFontRegistry.registerIfNeeded()
|
||||||
SybilTheme.applySystemAppearance()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
@@ -26,6 +25,7 @@ public struct SplitView: View {
|
|||||||
} else {
|
} else {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
SybilSidebarView(viewModel: viewModel)
|
SybilSidebarView(viewModel: viewModel)
|
||||||
|
.navigationTitle("Sybil")
|
||||||
} detail: {
|
} detail: {
|
||||||
SybilWorkspaceView(viewModel: viewModel)
|
SybilWorkspaceView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,6 @@ public struct SplitView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.sybil(.body))
|
.font(.sybil(.body))
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
.task {
|
.task {
|
||||||
await viewModel.bootstrap()
|
await viewModel.bootstrap()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,14 +21,12 @@ 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]
|
||||||
@@ -98,16 +96,6 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -564,7 +552,3 @@ private struct SearchCreateBody: Encodable {
|
|||||||
var title: String?
|
var title: String?
|
||||||
var query: String?
|
var query: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SearchChatCreateBody: Encodable {
|
|
||||||
var title: String?
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ struct SybilChatTranscriptView: View {
|
|||||||
|
|
||||||
ForEach(messages) { message in
|
ForEach(messages) { message in
|
||||||
MessageBubble(message: message, isSending: isSending)
|
MessageBubble(message: message, isSending: isSending)
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.id(message.id)
|
.id(message.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,8 +86,10 @@ private struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top) {
|
||||||
leadingSpacer
|
if isUser {
|
||||||
|
Spacer(minLength: 44)
|
||||||
|
}
|
||||||
|
|
||||||
if let toolCallMetadata {
|
if let toolCallMetadata {
|
||||||
ToolCallActivityChip(
|
ToolCallActivityChip(
|
||||||
@@ -135,24 +136,12 @@ private struct MessageBubble: View {
|
|||||||
.frame(maxWidth: isUser ? 420 : nil, alignment: isUser ? .trailing : .leading)
|
.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 {
|
if !isUser {
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ToolCallActivityChip: View {
|
private struct ToolCallActivityChip: View {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ extension Theme {
|
|||||||
.text {
|
.text {
|
||||||
FontFamily(.custom("Inter"))
|
FontFamily(.custom("Inter"))
|
||||||
FontSize(15)
|
FontSize(15)
|
||||||
ForegroundColor(SybilTheme.text)
|
|
||||||
}
|
}
|
||||||
.code {
|
.code {
|
||||||
FontFamilyVariant(.monospaced)
|
FontFamilyVariant(.monospaced)
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ struct SybilPhoneShellView: View {
|
|||||||
.navigationTitle("")
|
.navigationTitle("")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .principal) {
|
||||||
SybilWordmark(size: 18)
|
SybilWordmark(size: 19)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationDestination(for: PhoneRoute.self) { route in
|
.navigationDestination(for: PhoneRoute.self) { route in
|
||||||
|
|||||||
@@ -5,14 +5,11 @@ 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: 12) {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Results for")
|
Text("Results for")
|
||||||
.font(.sybil(.footnote))
|
.font(.sybil(.footnote))
|
||||||
@@ -26,40 +23,6 @@ struct SybilSearchResultsView: View {
|
|||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if isRunning || (search?.answerText?.isEmpty == false) || (search?.answerError?.isEmpty == false) {
|
if isRunning || (search?.answerText?.isEmpty == false) || (search?.answerError?.isEmpty == false) {
|
||||||
@@ -113,13 +76,6 @@ 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) {
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ struct SybilSidebarView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
SybilWordmark(size: 31)
|
||||||
|
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
sidebarActionButton(
|
sidebarActionButton(
|
||||||
title: "New chat",
|
title: "New chat",
|
||||||
@@ -172,13 +174,6 @@ struct SybilSidebarView: View {
|
|||||||
.padding(10)
|
.padding(10)
|
||||||
}
|
}
|
||||||
.background(SybilTheme.panelGradient)
|
.background(SybilTheme.panelGradient)
|
||||||
.navigationTitle("")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
|
||||||
SybilWordmark(size: 18)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sidebarActionButton(
|
private func sidebarActionButton(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import CoreText
|
import CoreText
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
|
||||||
|
|
||||||
enum SybilFontRegistry {
|
enum SybilFontRegistry {
|
||||||
static func registerIfNeeded() {
|
static func registerIfNeeded() {
|
||||||
@@ -79,23 +78,6 @@ enum SybilTheme {
|
|||||||
static let userBubble = Color(red: 0.29, green: 0.13, blue: 0.65)
|
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)
|
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 {
|
static var backgroundGradient: LinearGradient {
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ 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 = ""
|
||||||
@@ -203,20 +202,20 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var displayedMessages: [Message] {
|
var displayedMessages: [Message] {
|
||||||
let canonical = displayableMessages(selectedChat?.messages ?? [])
|
let canonical = 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 displayableMessages(pending.messages)
|
return pending.messages
|
||||||
}
|
}
|
||||||
return canonical
|
return canonical
|
||||||
}
|
}
|
||||||
|
|
||||||
if draftKind == .chat {
|
if draftKind == .chat {
|
||||||
return displayableMessages(pending.messages)
|
return pending.messages
|
||||||
}
|
}
|
||||||
|
|
||||||
return canonical
|
return canonical
|
||||||
@@ -474,36 +473,6 @@ 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
|
||||||
@@ -1005,10 +974,6 @@ 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
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct SybilWorkspaceView: View {
|
struct SybilWorkspaceView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
@FocusState private var composerFocused: Bool
|
@FocusState private var composerFocused: Bool
|
||||||
|
|
||||||
|
private var isCompact: Bool {
|
||||||
|
horizontalSizeClass == .compact
|
||||||
|
}
|
||||||
|
|
||||||
private var isSettingsSelected: Bool {
|
private var isSettingsSelected: Bool {
|
||||||
if case .settings = viewModel.selectedItem {
|
if case .settings = viewModel.selectedItem {
|
||||||
return true
|
return true
|
||||||
@@ -13,7 +19,7 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var showsHeader: Bool {
|
private var showsHeader: Bool {
|
||||||
viewModel.errorMessage != nil
|
!isCompact || viewModel.errorMessage != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -32,13 +38,8 @@ 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,
|
||||||
@@ -55,26 +56,58 @@ struct SybilWorkspaceView: View {
|
|||||||
composerBar
|
composerBar
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(viewModel.selectedTitle)
|
.navigationTitle(isCompact ? "" : viewModel.selectedTitle)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbarRole(.editor)
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if !isSettingsSelected {
|
if isCompact {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .principal) {
|
||||||
if viewModel.isSearchMode {
|
Text(viewModel.selectedTitle)
|
||||||
searchModeChip
|
.font(.sybil(.headline, weight: .semibold))
|
||||||
} else {
|
.foregroundStyle(SybilTheme.text)
|
||||||
providerModelMenu
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isCompact && !viewModel.isSearchMode && !isSettingsSelected {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
compactProviderModelMenu
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.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 {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
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 {
|
if let error = viewModel.errorMessage {
|
||||||
Text(error)
|
Text(error)
|
||||||
.font(.sybil(.footnote))
|
.font(.sybil(.footnote))
|
||||||
@@ -87,7 +120,7 @@ struct SybilWorkspaceView: View {
|
|||||||
.background(SybilTheme.panelGradient.opacity(0.58))
|
.background(SybilTheme.panelGradient.opacity(0.58))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var providerModelMenu: some View {
|
private var compactProviderModelMenu: some View {
|
||||||
Menu {
|
Menu {
|
||||||
Text("\(viewModel.provider.displayName) • \(viewModel.model)")
|
Text("\(viewModel.provider.displayName) • \(viewModel.model)")
|
||||||
.font(.sybil(.caption))
|
.font(.sybil(.caption))
|
||||||
@@ -131,39 +164,78 @@ struct SybilWorkspaceView: View {
|
|||||||
.accessibilityLabel("Provider and model")
|
.accessibilityLabel("Provider and model")
|
||||||
}
|
}
|
||||||
|
|
||||||
private var searchModeChip: some View {
|
private var providerControls: some View {
|
||||||
Label("Search", systemImage: "globe")
|
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)
|
||||||
.font(.sybil(.caption, weight: .medium))
|
.font(.sybil(.caption, weight: .medium))
|
||||||
.foregroundStyle(SybilTheme.accent)
|
.foregroundStyle(SybilTheme.text)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 7)
|
.padding(.vertical, 7)
|
||||||
.background(
|
.background(
|
||||||
Capsule()
|
RoundedRectangle(cornerRadius: 10)
|
||||||
.fill(SybilTheme.accent.opacity(0.10))
|
.fill(SybilTheme.surface.opacity(0.78))
|
||||||
.overlay(
|
.overlay(
|
||||||
Capsule()
|
RoundedRectangle(cornerRadius: 10)
|
||||||
.stroke(SybilTheme.accent.opacity(0.24), lineWidth: 1)
|
.stroke(SybilTheme.border.opacity(0.88), 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 {
|
private var composerBar: some View {
|
||||||
HStack(alignment: .bottom, spacing: 10) {
|
HStack(alignment: .bottom, spacing: 10) {
|
||||||
TextField(
|
ZStack(alignment: .topLeading) {
|
||||||
viewModel.isSearchMode ? "Search the web" : "Message Sybil",
|
ComposerTextView(
|
||||||
text: $viewModel.composer,
|
text: $viewModel.composer,
|
||||||
axis: .vertical
|
isFocused: $composerFocused,
|
||||||
)
|
isDisabled: viewModel.isSending
|
||||||
.focused($composerFocused)
|
) {
|
||||||
.textInputAutocapitalization(.sentences)
|
|
||||||
.autocorrectionDisabled(false)
|
|
||||||
.lineLimit(1 ... 6)
|
|
||||||
.submitLabel(.send)
|
|
||||||
.onSubmit {
|
|
||||||
Task {
|
Task {
|
||||||
await viewModel.sendComposer()
|
await viewModel.sendComposer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(minHeight: 24, maxHeight: 132)
|
||||||
|
|
||||||
|
if viewModel.composer.isEmpty {
|
||||||
|
Text(viewModel.isSearchMode ? "Search the web" : "Message Sybil")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted.opacity(0.72))
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.background(
|
.background(
|
||||||
@@ -211,3 +283,91 @@ struct SybilWorkspaceView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ComposerTextView: UIViewRepresentable {
|
||||||
|
@Binding var text: String
|
||||||
|
var isFocused: FocusState<Bool>.Binding
|
||||||
|
var isDisabled: Bool
|
||||||
|
var onSubmit: () -> Void
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> UITextView {
|
||||||
|
let textView = UITextView()
|
||||||
|
textView.delegate = context.coordinator
|
||||||
|
textView.backgroundColor = .clear
|
||||||
|
textView.font = .preferredFont(forTextStyle: .body)
|
||||||
|
textView.textColor = UIColor(SybilTheme.text)
|
||||||
|
textView.tintColor = UIColor(SybilTheme.primary)
|
||||||
|
textView.textContainerInset = .zero
|
||||||
|
textView.textContainer.lineFragmentPadding = 0
|
||||||
|
textView.isScrollEnabled = true
|
||||||
|
textView.alwaysBounceVertical = false
|
||||||
|
textView.keyboardDismissMode = .interactive
|
||||||
|
textView.returnKeyType = .send
|
||||||
|
textView.enablesReturnKeyAutomatically = true
|
||||||
|
textView.autocapitalizationType = .sentences
|
||||||
|
textView.autocorrectionType = .default
|
||||||
|
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
return textView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ textView: UITextView, context: Context) {
|
||||||
|
context.coordinator.parent = self
|
||||||
|
|
||||||
|
if textView.text != text {
|
||||||
|
textView.text = text
|
||||||
|
}
|
||||||
|
|
||||||
|
textView.isEditable = !isDisabled
|
||||||
|
textView.alpha = isDisabled ? 0.74 : 1
|
||||||
|
|
||||||
|
if isFocused.wrappedValue, !textView.isFirstResponder {
|
||||||
|
textView.becomeFirstResponder()
|
||||||
|
} else if !isFocused.wrappedValue, textView.isFirstResponder {
|
||||||
|
textView.resignFirstResponder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? {
|
||||||
|
let width = proposal.width ?? uiView.bounds.width
|
||||||
|
let fittingSize = uiView.sizeThatFits(
|
||||||
|
CGSize(width: width, height: CGFloat.greatestFiniteMagnitude)
|
||||||
|
)
|
||||||
|
return CGSize(width: width, height: min(max(fittingSize.height, 24), 132))
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(parent: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Coordinator: NSObject, UITextViewDelegate {
|
||||||
|
var parent: ComposerTextView
|
||||||
|
|
||||||
|
init(parent: ComposerTextView) {
|
||||||
|
self.parent = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
|
parent.text = textView.text
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||||
|
parent.isFocused.wrappedValue = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidEndEditing(_ textView: UITextView) {
|
||||||
|
parent.isFocused.wrappedValue = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func textView(
|
||||||
|
_ textView: UITextView,
|
||||||
|
shouldChangeTextIn range: NSRange,
|
||||||
|
replacementText replacement: String
|
||||||
|
) -> Bool {
|
||||||
|
if replacement == "\n" {
|
||||||
|
parent.onSubmit()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,24 +1,6 @@
|
|||||||
import Testing
|
import Testing
|
||||||
@testable import Sybil
|
@testable import Sybil
|
||||||
|
|
||||||
@MainActor
|
@Test func example() async throws {
|
||||||
@Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws {
|
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,6 @@ 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`
|
||||||
|
|||||||
@@ -1,24 +1,5 @@
|
|||||||
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),
|
||||||
@@ -32,18 +13,6 @@ 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>;
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
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;
|
||||||
@@ -23,8 +21,6 @@ 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(),
|
||||||
@@ -271,7 +267,8 @@ function normalizeIncomingMessages(messages: ChatMessage[]) {
|
|||||||
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
|
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
|
async function runWebSearchTool(input: unknown): 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",
|
||||||
@@ -295,7 +292,6 @@ async function runExaWebSearchTool(args: WebSearchArgs): 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) => ({
|
||||||
@@ -313,40 +309,6 @@ async function runExaWebSearchTool(args: WebSearchArgs): 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:") {
|
||||||
|
|||||||
@@ -108,13 +108,6 @@ 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);
|
||||||
@@ -160,57 +153,6 @@ 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> = {
|
||||||
@@ -428,54 +370,6 @@ 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() });
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
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) };
|
|
||||||
}
|
|
||||||
105
web/src/App.tsx
105
web/src/App.tsx
@@ -8,7 +8,6 @@ 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,
|
||||||
@@ -165,10 +164,6 @@ 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}`,
|
||||||
@@ -432,7 +427,6 @@ 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");
|
||||||
@@ -452,7 +446,6 @@ 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("");
|
||||||
@@ -644,12 +637,6 @@ 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;
|
||||||
@@ -682,27 +669,11 @@ 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 (isSending) return;
|
if (wasSending && !isSending) return;
|
||||||
if (wasSending) {
|
|
||||||
shouldAutoScrollRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!shouldAutoScrollRef.current) return;
|
if (!shouldAutoScrollRef.current) return;
|
||||||
transcriptEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
|
transcriptEndRef.current?.scrollIntoView({ behavior: isSending ? "smooth" : "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;
|
||||||
@@ -720,16 +691,22 @@ 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 = isChatReplyStreamingInView;
|
const isSendingActiveChat =
|
||||||
|
isSending &&
|
||||||
|
!isSearchMode &&
|
||||||
|
!!pendingChatState &&
|
||||||
|
!!pendingChatState.chatId &&
|
||||||
|
selectedItem?.kind === "chat" &&
|
||||||
|
selectedItem.id === pendingChatState.chatId;
|
||||||
const displayMessages = useMemo(() => {
|
const displayMessages = useMemo(() => {
|
||||||
if (!pendingChatState) return messages.filter(isDisplayableMessage);
|
if (!pendingChatState) return messages;
|
||||||
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.filter(isDisplayableMessage);
|
return pendingChatState.messages;
|
||||||
}
|
}
|
||||||
return messages.filter(isDisplayableMessage);
|
return messages;
|
||||||
}
|
}
|
||||||
return (isSearchMode ? messages : pendingChatState.messages).filter(isDisplayableMessage);
|
return isSearchMode ? messages : pendingChatState.messages;
|
||||||
}, [isSearchMode, messages, pendingChatState, selectedItem]);
|
}, [isSearchMode, messages, pendingChatState, selectedItem]);
|
||||||
|
|
||||||
const selectedChatSummary = useMemo(() => {
|
const selectedChatSummary = useMemo(() => {
|
||||||
@@ -854,8 +831,6 @@ 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(),
|
||||||
@@ -1174,47 +1149,6 @@ 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;
|
||||||
@@ -1443,7 +1377,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 [overflow-anchor:none]"
|
className="flex-1 overflow-y-auto px-4 pt-8 md:px-10 lg:px-14 pb-36 md:pb-44"
|
||||||
onScroll={() => {
|
onScroll={() => {
|
||||||
const container = transcriptContainerRef.current;
|
const container = transcriptContainerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -1454,17 +1388,8 @@ export default function App() {
|
|||||||
{!isSearchMode ? (
|
{!isSearchMode ? (
|
||||||
<ChatMessagesPanel messages={displayMessages} isLoading={isLoadingSelection} isSending={isSendingActiveChat} />
|
<ChatMessagesPanel messages={displayMessages} isLoading={isLoadingSelection} isSending={isSendingActiveChat} />
|
||||||
) : (
|
) : (
|
||||||
<SearchResultsPanel
|
<SearchResultsPanel search={selectedSearch} isLoading={isLoadingSelection} isRunning={isSearchRunning} />
|
||||||
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>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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 {
|
||||||
@@ -30,8 +29,6 @@ type Props = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
enableKeyboardNavigation?: boolean;
|
enableKeyboardNavigation?: boolean;
|
||||||
openLinksInNewTab?: boolean;
|
openLinksInNewTab?: boolean;
|
||||||
isStartingChat?: boolean;
|
|
||||||
onStartChat?: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SearchResultsPanel({
|
export function SearchResultsPanel({
|
||||||
@@ -41,8 +38,6 @@ 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);
|
||||||
@@ -138,13 +133,11 @@ 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 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
<div className="mb-5">
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm text-muted-foreground">Results for</p>
|
<p className="text-sm text-muted-foreground">Results for</p>
|
||||||
<h2 className="mt-1 break-words text-xl font-semibold text-violet-50">{search.query}</h2>
|
<h2 className="mt-1 break-words text-xl font-semibold text-violet-50">{search.query}</h2>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
@@ -152,18 +145,6 @@ export function SearchResultsPanel({
|
|||||||
{search.latencyMs ? ` • ${search.latencyMs} ms` : ""}
|
{search.latencyMs ? ` • ${search.latencyMs} ms` : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{(isRunning || !!search?.answerText || !!search?.answerError) && (
|
{(isRunning || !!search?.answerText || !!search?.answerError) && (
|
||||||
|
|||||||
@@ -239,14 +239,6 @@ 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" });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user