14 Commits

44 changed files with 2383 additions and 270 deletions

View File

@@ -12,6 +12,9 @@ services:
OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
XAI_API_KEY: ${XAI_API_KEY:-} XAI_API_KEY: ${XAI_API_KEY:-}
HERMES_AGENT_API_BASE_URL: ${HERMES_AGENT_API_BASE_URL:-http://127.0.0.1:8642/v1}
HERMES_AGENT_API_KEY: ${HERMES_AGENT_API_KEY:-}
HERMES_AGENT_MODEL: ${HERMES_AGENT_MODEL:-}
EXA_API_KEY: ${EXA_API_KEY:-} EXA_API_KEY: ${EXA_API_KEY:-}
CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa} CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa}
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-} SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-}

View File

@@ -33,11 +33,14 @@ Chat upload limits:
"providers": { "providers": {
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null }, "openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null }, "anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null } "xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
"hermes-agent": { "models": ["hermes-agent"], "loadedAt": null, "error": null }
} }
} }
``` ```
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation. - OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
## Active Runs ## Active Runs
@@ -55,6 +58,42 @@ Behavior notes:
- Clients should use this after app start or page refresh to restore per-row generating indicators. - Clients should use this after app start or page refresh to restore per-row generating indicators.
- The lists are not durable across server restarts. - The lists are not durable across server restarts.
## Workspace Items
### `GET /v1/workspace-items`
- Response: `{ "items": WorkspaceItem[] }`
- `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending:
```json
{
"items": [
{
"type": "chat",
"id": "chat-id",
"title": "optional title",
"createdAt": "2026-02-14T00:00:00.000Z",
"updatedAt": "2026-02-14T00:00:00.000Z",
"initiatedProvider": "openai",
"initiatedModel": "gpt-4.1-mini",
"lastUsedProvider": "openai",
"lastUsedModel": "gpt-4.1-mini"
},
{
"type": "search",
"id": "search-id",
"title": "optional title",
"query": "search query",
"createdAt": "2026-02-14T00:00:00.000Z",
"updatedAt": "2026-02-14T00:00:00.000Z"
}
]
}
```
Behavior notes:
- This endpoint is intended for combined conversation/search lists such as sidebars.
- The legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections.
- The response currently combines up to 100 chats and up to 100 searches.
## Chats ## Chats
### `GET /v1/chats` ### `GET /v1/chats`
@@ -65,7 +104,7 @@ Behavior notes:
```json ```json
{ {
"title": "optional title", "title": "optional title",
"provider": "optional openai|anthropic|xai", "provider": "optional openai|anthropic|xai|hermes-agent",
"model": "optional model id", "model": "optional model id",
"messages": [ "messages": [
{ {
@@ -152,7 +191,7 @@ Notes:
```json ```json
{ {
"chatId": "optional-chat-id", "chatId": "optional-chat-id",
"provider": "openai|anthropic|xai", "provider": "openai|anthropic|xai|hermes-agent",
"model": "string", "model": "string",
"messages": [ "messages": [
{ {
@@ -206,11 +245,12 @@ Behavior notes:
- Text files are forwarded as explicit text blocks rather than provider-managed file references. Large text attachments should already be truncated client-side before submission. - Text files are forwarded as explicit text blocks rather than provider-managed file references. Large text attachments should already be truncated client-side before submission.
- For `openai`, backend calls OpenAI's Responses API and enables internal tool use with an internal system instruction. - For `openai`, backend calls OpenAI's Responses API and enables internal tool use with an internal system instruction.
- For `xai`, backend calls xAI's OpenAI-compatible Chat Completions API and enables internal tool use with the same internal system instruction. - For `xai`, backend calls xAI's OpenAI-compatible Chat Completions API and enables internal tool use with the same internal system instruction.
- For `hermes-agent`, backend calls the configured Hermes Agent OpenAI-compatible Chat Completions API without adding Sybil-managed tool definitions; Hermes Agent handles its own tools server-side.
- For `openai`, image attachments are sent as Responses `input_image` items and text attachments are sent as `input_text` items. - For `openai`, image attachments are sent as Responses `input_image` items and text attachments are sent as `input_text` items.
- For `xai`, image attachments are sent as Chat Completions content parts alongside text. - For `xai` and `hermes-agent`, image attachments are sent as Chat Completions content parts alongside text.
- For `openai`, Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds. - For `openai`, Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks. - For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
- Available tool calls for chat: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available. - Available Sybil-managed tool calls for `openai` and `xai`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. - `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`.
- `fetch_url` fetches a URL and returns plaintext page content (HTML converted to text server-side). - `fetch_url` fetches a URL and returns plaintext page content (HTML converted to text server-side).
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed. - `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
@@ -311,9 +351,9 @@ Behavior notes:
"title": null, "title": null,
"createdAt": "...", "createdAt": "...",
"updatedAt": "...", "updatedAt": "...",
"initiatedProvider": "openai|anthropic|xai|null", "initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
"initiatedModel": "string|null", "initiatedModel": "string|null",
"lastUsedProvider": "openai|anthropic|xai|null", "lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
"lastUsedModel": "string|null" "lastUsedModel": "string|null"
} }
``` ```
@@ -359,9 +399,9 @@ Behavior notes:
"title": null, "title": null,
"createdAt": "...", "createdAt": "...",
"updatedAt": "...", "updatedAt": "...",
"initiatedProvider": "openai|anthropic|xai|null", "initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
"initiatedModel": "string|null", "initiatedModel": "string|null",
"lastUsedProvider": "openai|anthropic|xai|null", "lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
"lastUsedModel": "string|null", "lastUsedModel": "string|null",
"messages": [Message] "messages": [Message]
} }

View File

@@ -21,7 +21,7 @@ Authentication:
{ {
"chatId": "optional-chat-id", "chatId": "optional-chat-id",
"persist": true, "persist": true,
"provider": "openai|anthropic|xai", "provider": "openai|anthropic|xai|hermes-agent",
"model": "string", "model": "string",
"messages": [ "messages": [
{ {
@@ -152,8 +152,9 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
- `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text. - `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text.
- `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text. - `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text.
- `hermes-agent`: backend uses the configured Hermes Agent OpenAI-compatible Chat Completions API. Sybil does not add its own tool definitions for this provider; Hermes Agent handles its own tools server-side. Custom Hermes stream events are normalized away unless they produce text deltas in this SSE contract.
- `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items. - `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items.
- `xai`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts. - `xai` and `hermes-agent`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts.
- `openai`: Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds. - `openai`: Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks. - `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks.
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. This only affects chat-mode tool calls, not search-mode endpoints. - `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, 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.

View File

@@ -51,3 +51,4 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
- OpenAI: `gpt-4.1-mini` - OpenAI: `gpt-4.1-mini`
- Anthropic: `claude-3-5-sonnet-latest` - Anthropic: `claude-3-5-sonnet-latest`
- xAI: `grok-3-mini` - xAI: `grok-3-mini`
- Hermes Agent: `hermes-agent`

17
ios/Apps/Sybil/Info.plist Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIApplicationShortcutItems</key>
<array>
<dict>
<key>UIApplicationShortcutItemType</key>
<string>net.buzzert.sybil2.quick-question</string>
<key>UIApplicationShortcutItemTitle</key>
<string>Quick question</string>
<key>UIApplicationShortcutItemIconSymbolName</key>
<string>sparkles</string>
</dict>
</array>
</dict>
</plist>

View File

@@ -5,6 +5,8 @@ import UIKit
@main @main
struct SybilApp: App struct SybilApp: App
{ {
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
SplitView() SplitView()
@@ -14,3 +16,79 @@ struct SybilApp: App
} }
} }
} }
@MainActor
final class SybilAppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
SybilHomeScreenQuickActionHandler.configureQuickActions()
return true
}
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
let configuration = UISceneConfiguration(
name: "Default Configuration",
sessionRole: connectingSceneSession.role
)
configuration.delegateClass = SybilSceneDelegate.self
return configuration
}
func application(
_ application: UIApplication,
performActionFor shortcutItem: UIApplicationShortcutItem,
completionHandler: @escaping (Bool) -> Void
) {
completionHandler(SybilHomeScreenQuickActionHandler.handle(shortcutItem))
}
}
@MainActor
final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
if let shortcutItem = connectionOptions.shortcutItem {
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
}
}
func windowScene(
_ windowScene: UIWindowScene,
performActionFor shortcutItem: UIApplicationShortcutItem,
completionHandler: @escaping (Bool) -> Void
) {
completionHandler(SybilHomeScreenQuickActionHandler.handle(shortcutItem))
}
func sceneWillResignActive(_ scene: UIScene) {
SybilHomeScreenQuickActionHandler.configureQuickActions()
}
}
@MainActor
private enum SybilHomeScreenQuickActionHandler {
static func configureQuickActions() {
// The quick question action is static in Info.plist so it is available before first launch.
UIApplication.shared.shortcutItems = []
}
static func handle(_ shortcutItem: UIApplicationShortcutItem) -> Bool {
guard shortcutItem.type == SybilHomeScreenQuickAction.quickQuestionType else {
return false
}
Task { @MainActor in
SybilQuickActionRouter.shared.requestQuickQuestionPresentation()
}
return true
}
}

View File

@@ -22,9 +22,10 @@ targets:
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
TARGETED_DEVICE_FAMILY: "1,2,6" TARGETED_DEVICE_FAMILY: "1,2,6"
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
INFOPLIST_FILE: Apps/Sybil/Info.plist
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
MARKETING_VERSION: 1.5 MARKETING_VERSION: 1.9
CURRENT_PROJECT_VERSION: 6 CURRENT_PROJECT_VERSION: 10
INFOPLIST_KEY_CFBundleDisplayName: Sybil INFOPLIST_KEY_CFBundleDisplayName: Sybil
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES

View File

@@ -2,10 +2,14 @@ import SwiftUI
public struct SplitView: View { public struct SplitView: View {
@State private var viewModel = SybilViewModel() @State private var viewModel = SybilViewModel()
@ObservedObject private var quickActionRouter = SybilQuickActionRouter.shared
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@State private var shouldRefreshOnForeground = false @State private var shouldRefreshOnForeground = false
@State private var composerFocusRequest = 0 @State private var composerFocusRequest = 0
@State private var quickQuestionFocusRequest = 0
@State private var hasPendingQuickQuestionPresentation = false
@State private var isQuickQuestionPresented = false
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic @State private var columnVisibility: NavigationSplitViewVisibility = .automatic
private var keyboardActions: SybilKeyboardActions? { private var keyboardActions: SybilKeyboardActions? {
@@ -74,8 +78,28 @@ public struct SplitView: View {
.font(.sybil(.body)) .font(.sybil(.body))
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions) .focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
SybilQuickQuestionView(
viewModel: viewModel,
focusRequest: quickQuestionFocusRequest
)
.presentationDragIndicator(.visible)
}
.task { .task {
await viewModel.bootstrap() await viewModel.bootstrap()
presentPendingQuickQuestionIfPossible()
}
.onReceive(quickActionRouter.$quickQuestionPresentationRequest) { request in
guard request > 0 else {
return
}
queueQuickQuestionPresentation()
}
.onChange(of: viewModel.isCheckingSession) { _, _ in
presentPendingQuickQuestionIfPossible()
}
.onChange(of: viewModel.isAuthenticated) { _, _ in
presentPendingQuickQuestionIfPossible()
} }
.onChange(of: scenePhase) { _, nextPhase in .onChange(of: scenePhase) { _, nextPhase in
switch nextPhase { switch nextPhase {
@@ -112,6 +136,28 @@ public struct SplitView: View {
columnVisibility = .all columnVisibility = .all
} }
} }
private func queueQuickQuestionPresentation() {
hasPendingQuickQuestionPresentation = true
presentPendingQuickQuestionIfPossible()
}
private func presentPendingQuickQuestionIfPossible() {
guard hasPendingQuickQuestionPresentation,
!viewModel.isCheckingSession,
viewModel.isAuthenticated
else {
return
}
hasPendingQuickQuestionPresentation = false
quickQuestionFocusRequest += 1
isQuickQuestionPresented = true
}
private func handleQuickQuestionDismissed() {
viewModel.cancelQuickQuestion()
}
} }
public struct SybilCommands: Commands { public struct SybilCommands: Commands {

View File

@@ -44,16 +44,26 @@ actor SybilAPIClient: SybilAPIClienting {
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self) try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self)
} }
func listWorkspaceItems() async throws -> [WorkspaceItem] {
let response = try await request("/v1/workspace-items", method: "GET", responseType: WorkspaceListResponse.self)
return response.items
}
func listChats() async throws -> [ChatSummary] { func listChats() async throws -> [ChatSummary] {
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self) let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
return response.chats return response.chats
} }
func createChat(title: String? = nil) async throws -> ChatSummary { func createChat(
title: String? = nil,
provider: Provider? = nil,
model: String? = nil,
messages: [CompletionRequestMessage]? = nil
) async throws -> ChatSummary {
let response = try await request( let response = try await request(
"/v1/chats", "/v1/chats",
method: "POST", method: "POST",
body: AnyEncodable(ChatCreateBody(title: title)), body: AnyEncodable(ChatCreateBody(title: title, provider: provider, model: model, messages: messages)),
responseType: ChatCreateResponse.self responseType: ChatCreateResponse.self
) )
return response.chat return response.chat
@@ -617,13 +627,18 @@ actor SybilAPIClient: SybilAPIClienting {
struct CompletionStreamRequest: Codable, Sendable { struct CompletionStreamRequest: Codable, Sendable {
var chatId: String? var chatId: String?
var persist: Bool? = nil
var provider: Provider var provider: Provider
var model: String var model: String
var messages: [CompletionRequestMessage] var messages: [CompletionRequestMessage]
var userLocation: String? = nil
} }
private struct ChatCreateBody: Encodable { private struct ChatCreateBody: Encodable {
var title: String? var title: String?
var provider: Provider?
var model: String?
var messages: [CompletionRequestMessage]?
} }
private struct SearchCreateBody: Encodable { private struct SearchCreateBody: Encodable {

View File

@@ -2,8 +2,14 @@ import Foundation
protocol SybilAPIClienting: Sendable { protocol SybilAPIClienting: Sendable {
func verifySession() async throws -> AuthSession func verifySession() async throws -> AuthSession
func listWorkspaceItems() async throws -> [WorkspaceItem]
func listChats() async throws -> [ChatSummary] func listChats() async throws -> [ChatSummary]
func createChat(title: String?) async throws -> ChatSummary func createChat(
title: String?,
provider: Provider?,
model: String?,
messages: [CompletionRequestMessage]?
) async throws -> ChatSummary
func getChat(chatID: String) async throws -> ChatDetail func getChat(chatID: String) async throws -> ChatDetail
func deleteChat(chatID: String) async throws func deleteChat(chatID: String) async throws
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
@@ -32,3 +38,9 @@ protocol SybilAPIClienting: Sendable {
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
) async throws ) async throws
} }
extension SybilAPIClienting {
func createChat(title: String?) async throws -> ChatSummary {
try await createChat(title: title, provider: nil, model: nil, messages: nil)
}
}

View File

@@ -17,18 +17,6 @@ struct SybilChatTranscriptView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 26) { LazyVStack(alignment: .leading, spacing: 26) {
if isSending && !hasPendingAssistant {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.textMuted)
Text("Assistant is typing…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
}
.scaleEffect(x: 1, y: -1)
}
ForEach(messages.reversed()) { message in ForEach(messages.reversed()) { message in
MessageBubble(message: message, isSending: isSending) MessageBubble(message: message, isSending: isSending)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)

View File

@@ -4,12 +4,14 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
case openai case openai
case anthropic case anthropic
case xai case xai
case hermesAgent = "hermes-agent"
public var displayName: String { public var displayName: String {
switch self { switch self {
case .openai: return "OpenAI" case .openai: return "OpenAI"
case .anthropic: return "Anthropic" case .anthropic: return "Anthropic"
case .xai: return "xAI" case .xai: return "xAI"
case .hermesAgent: return "Hermes Agent"
} }
} }
} }
@@ -166,6 +168,75 @@ public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
public var updatedAt: Date public var updatedAt: Date
} }
public enum WorkspaceItemType: String, Codable, Hashable, Sendable {
case chat
case search
}
public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
public var type: WorkspaceItemType
public var id: String
public var title: String?
public var query: String?
public var createdAt: Date
public var updatedAt: Date
public var initiatedProvider: Provider?
public var initiatedModel: String?
public var lastUsedProvider: Provider?
public var lastUsedModel: String?
public init(chat: ChatSummary) {
self.type = .chat
self.id = chat.id
self.title = chat.title
self.query = nil
self.createdAt = chat.createdAt
self.updatedAt = chat.updatedAt
self.initiatedProvider = chat.initiatedProvider
self.initiatedModel = chat.initiatedModel
self.lastUsedProvider = chat.lastUsedProvider
self.lastUsedModel = chat.lastUsedModel
}
public init(search: SearchSummary) {
self.type = .search
self.id = search.id
self.title = search.title
self.query = search.query
self.createdAt = search.createdAt
self.updatedAt = search.updatedAt
self.initiatedProvider = nil
self.initiatedModel = nil
self.lastUsedProvider = nil
self.lastUsedModel = nil
}
public var chatSummary: ChatSummary? {
guard type == .chat else { return nil }
return ChatSummary(
id: id,
title: title,
createdAt: createdAt,
updatedAt: updatedAt,
initiatedProvider: initiatedProvider,
initiatedModel: initiatedModel,
lastUsedProvider: lastUsedProvider,
lastUsedModel: lastUsedModel
)
}
public var searchSummary: SearchSummary? {
guard type == .search else { return nil }
return SearchSummary(
id: id,
title: title,
query: query,
createdAt: createdAt,
updatedAt: updatedAt
)
}
}
public struct Message: Codable, Identifiable, Hashable, Sendable { public struct Message: Codable, Identifiable, Hashable, Sendable {
public var id: String public var id: String
public var createdAt: Date public var createdAt: Date
@@ -404,8 +475,8 @@ public struct CompletionRequestMessage: Codable, Sendable {
} }
public struct CompletionStreamMeta: Codable, Sendable { public struct CompletionStreamMeta: Codable, Sendable {
public var chatId: String public var chatId: String?
public var callId: String public var callId: String?
public var provider: Provider public var provider: Provider
public var model: String public var model: String
} }
@@ -522,6 +593,10 @@ struct SearchListResponse: Codable {
var searches: [SearchSummary] var searches: [SearchSummary]
} }
struct WorkspaceListResponse: Codable {
var items: [WorkspaceItem]
}
struct ChatDetailResponse: Codable { struct ChatDetailResponse: Codable {
var chat: ChatDetail var chat: ChatDetail
} }

View File

@@ -0,0 +1,19 @@
import Combine
import Foundation
public enum SybilHomeScreenQuickAction {
public static let quickQuestionType = "net.buzzert.sybil2.quick-question"
}
@MainActor
public final class SybilQuickActionRouter: ObservableObject {
public static let shared = SybilQuickActionRouter()
@Published public private(set) var quickQuestionPresentationRequest = 0
private init() {}
public func requestQuickQuestionPresentation() {
quickQuestionPresentationRequest += 1
}
}

View File

@@ -0,0 +1,302 @@
import MarkdownUI
import Observation
import SwiftUI
struct SybilQuickQuestionView: View {
@Bindable var viewModel: SybilViewModel
var focusRequest: Int
@Environment(\.dismiss) private var dismiss
@FocusState private var promptFocused: Bool
private var hasAnswerContent: Bool {
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
}
var body: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 16) {
header
answerArea
composer
}
.padding(.horizontal, 16)
.padding(.top, 18)
.padding(.bottom, 12)
.frame(maxWidth: 640, maxHeight: .infinity, alignment: .top)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(SybilTheme.backgroundGradient)
.preferredColorScheme(.dark)
.task(id: focusRequest) {
try? await Task.sleep(for: .milliseconds(260))
guard !Task.isCancelled else {
return
}
promptFocused = true
}
}
private var header: some View {
HStack {
Image(systemName: "sparkles")
.font(.system(size: 21, weight: .semibold))
.foregroundStyle(SybilTheme.primary)
Text("Quick question")
.font(.title3.weight(.semibold))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var answerArea: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
if hasAnswerContent {
ForEach(viewModel.quickQuestionMessages) { message in
QuickQuestionMessageView(message: message, isSending: viewModel.isQuickQuestionSending)
}
if let error = viewModel.quickQuestionError {
Text(error)
.font(.caption)
.foregroundStyle(SybilTheme.danger)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(14)
}
.scrollDismissesKeyboard(.interactively)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.black.opacity(0.36))
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(SybilTheme.border.opacity(0.55), lineWidth: 1)
)
}
private var composer: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .bottom, spacing: 10) {
TextField(
"Ask anything...",
text: Binding(
get: { viewModel.quickQuestionPrompt },
set: { viewModel.updateQuickQuestionPrompt($0) }
),
axis: .vertical
)
.focused($promptFocused)
.font(.body)
.textInputAutocapitalization(.sentences)
.autocorrectionDisabled(false)
.lineLimit(1 ... 6)
.submitLabel(.send)
.onSubmit(submitQuestion)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.composerGradient)
.opacity(0.98)
)
.foregroundStyle(SybilTheme.text)
Button(action: submitQuestion) {
Image(systemName: "arrow.up")
.font(.body.weight(.semibold))
.frame(width: 40, height: 40)
.background(
Circle()
.fill(
viewModel.canSendQuickQuestion
? AnyShapeStyle(SybilTheme.primaryGradient)
: AnyShapeStyle(SybilTheme.surfaceStrong.opacity(0.92))
)
)
.foregroundStyle(viewModel.canSendQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
}
.buttonStyle(.plain)
.disabled(!viewModel.canSendQuickQuestion)
.accessibilityLabel("Ask quick question")
}
controlsRow
}
}
private var convertButton: some View {
Button {
Task {
let didConvert = await viewModel.convertQuickQuestionToChat()
if didConvert {
dismiss()
}
}
} label: {
Label("Chat", systemImage: "bubble.left")
.font(.caption.weight(.medium))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.buttonStyle(.plain)
.foregroundStyle(viewModel.canConvertQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, minHeight: 40)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.surfaceStrong.opacity(0.78))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(SybilTheme.border.opacity(0.78), lineWidth: 1)
)
)
.disabled(!viewModel.canConvertQuickQuestion)
}
private var controlsRow: some View {
HStack(alignment: .center, spacing: 10) {
providerMenu
modelMenu
convertButton
}
}
private var providerMenu: some View {
Menu {
ForEach(viewModel.providerOptions, id: \.self) { provider in
Button {
viewModel.setQuickQuestionProvider(provider)
} label: {
if viewModel.quickQuestionProvider == provider {
Label(provider.displayName, systemImage: "checkmark")
} else {
Text(provider.displayName)
}
}
}
} label: {
QuickQuestionPickerPill(title: viewModel.quickQuestionProvider.displayName)
}
.frame(maxWidth: .infinity)
.disabled(viewModel.isQuickQuestionSending || viewModel.isConvertingQuickQuestion)
.accessibilityLabel("Quick question provider")
}
private var modelMenu: some View {
Menu {
if viewModel.quickQuestionProviderModelOptions.isEmpty {
Text("No models")
} else {
ForEach(viewModel.quickQuestionProviderModelOptions, id: \.self) { model in
Button {
viewModel.setQuickQuestionModel(model)
} label: {
if viewModel.quickQuestionModel == model {
Label(model, systemImage: "checkmark")
} else {
Text(model)
}
}
}
}
} label: {
QuickQuestionPickerPill(title: viewModel.quickQuestionModel.isEmpty ? "No model" : viewModel.quickQuestionModel)
}
.frame(maxWidth: .infinity)
.disabled(viewModel.isQuickQuestionSending || viewModel.isConvertingQuickQuestion)
.accessibilityLabel("Quick question model")
}
private func submitQuestion() {
guard viewModel.canSendQuickQuestion else {
return
}
promptFocused = false
_ = viewModel.sendQuickQuestion()
}
}
private struct QuickQuestionPickerPill: View {
var title: String
var body: some View {
HStack(spacing: 8) {
Text(title)
.font(.caption.weight(.medium))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.minimumScaleFactor(0.8)
Image(systemName: "chevron.down")
.font(.caption.weight(.semibold))
.foregroundStyle(SybilTheme.textMuted)
}
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, minHeight: 40)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.surfaceStrong.opacity(0.78))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(SybilTheme.border.opacity(0.78), lineWidth: 1)
)
)
}
}
private struct QuickQuestionMessageView: View {
var message: Message
var isSending: Bool
private var isPendingAssistant: Bool {
message.id.hasPrefix("temp-assistant-quick-") &&
isSending &&
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
if let metadata = message.toolCallMetadata {
Text(toolCallSummary(for: metadata, fallbackContent: message.content))
.font(.caption)
.foregroundStyle(SybilTheme.textMuted)
.fixedSize(horizontal: false, vertical: true)
} else if isPendingAssistant {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.primary)
Text("Thinking...")
.font(.caption)
.foregroundStyle(SybilTheme.textMuted)
}
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Markdown(message.content)
.font(.body)
.tint(SybilTheme.primary)
.foregroundStyle(SybilTheme.text.opacity(0.96))
.textSelection(.enabled)
}
}
private func toolCallSummary(for metadata: ToolCallMetadata, fallbackContent: String) -> String {
if let summary = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !summary.isEmpty {
return summary
}
if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return fallbackContent
}
return "Ran \(metadata.toolName ?? "tool")."
}
}

View File

@@ -11,6 +11,12 @@ final class SybilSettingsStore {
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel" static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel" static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
static let preferredXAIModel = "sybil.ios.preferredXAIModel" static let preferredXAIModel = "sybil.ios.preferredXAIModel"
static let preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel"
static let quickQuestionPreferredProvider = "sybil.ios.quickQuestionPreferredProvider"
static let quickQuestionPreferredOpenAIModel = "sybil.ios.quickQuestionPreferredOpenAIModel"
static let quickQuestionPreferredAnthropicModel = "sybil.ios.quickQuestionPreferredAnthropicModel"
static let quickQuestionPreferredXAIModel = "sybil.ios.quickQuestionPreferredXAIModel"
static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel"
} }
private let defaults: UserDefaults private let defaults: UserDefaults
@@ -19,6 +25,8 @@ final class SybilSettingsStore {
var adminToken: String var adminToken: String
var preferredProvider: Provider var preferredProvider: Provider
var preferredModelByProvider: [Provider: String] var preferredModelByProvider: [Provider: String]
var quickQuestionPreferredProvider: Provider
var quickQuestionPreferredModelByProvider: [Provider: String]
init(defaults: UserDefaults = .standard) { init(defaults: UserDefaults = .standard) {
self.defaults = defaults self.defaults = defaults
@@ -32,10 +40,21 @@ final class SybilSettingsStore {
let provider = defaults.string(forKey: Keys.preferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai let provider = defaults.string(forKey: Keys.preferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
self.preferredProvider = provider self.preferredProvider = provider
self.preferredModelByProvider = [ let preferredModels: [Provider: String] = [
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini", .openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest", .anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini" .xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini",
.hermesAgent: defaults.string(forKey: Keys.preferredHermesAgentModel) ?? "hermes-agent"
]
self.preferredModelByProvider = preferredModels
self.quickQuestionPreferredProvider =
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? provider
self.quickQuestionPreferredModelByProvider = [
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini",
.anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
.hermesAgent: defaults.string(forKey: Keys.quickQuestionPreferredHermesAgentModel) ?? preferredModels[.hermesAgent] ?? "hermes-agent"
] ]
} }
@@ -53,6 +72,13 @@ final class SybilSettingsStore {
defaults.set(preferredModelByProvider[.openai], forKey: Keys.preferredOpenAIModel) defaults.set(preferredModelByProvider[.openai], forKey: Keys.preferredOpenAIModel)
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel) defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel) defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
defaults.set(preferredModelByProvider[.hermesAgent], forKey: Keys.preferredHermesAgentModel)
defaults.set(quickQuestionPreferredProvider.rawValue, forKey: Keys.quickQuestionPreferredProvider)
defaults.set(quickQuestionPreferredModelByProvider[.openai], forKey: Keys.quickQuestionPreferredOpenAIModel)
defaults.set(quickQuestionPreferredModelByProvider[.anthropic], forKey: Keys.quickQuestionPreferredAnthropicModel)
defaults.set(quickQuestionPreferredModelByProvider[.xai], forKey: Keys.quickQuestionPreferredXAIModel)
defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel)
} }
var trimmedTokenOrNil: String? { var trimmedTokenOrNil: String? {
@@ -68,7 +94,7 @@ final class SybilSettingsStore {
raw.removeLast() raw.removeLast()
} }
guard var components = URLComponents(string: raw) else { guard let components = URLComponents(string: raw) else {
return nil return nil
} }

View File

@@ -159,10 +159,7 @@ struct SybilSidebarItemList: View {
.padding(10) .padding(10)
} }
.refreshable { .refreshable {
await viewModel.refreshVisibleContent( await viewModel.refreshSidebarCollectionsFromPullToRefresh()
refreshCollections: true,
refreshSelection: false
)
} }
} }
} }

View File

@@ -95,6 +95,7 @@ final class SybilViewModel {
var chats: [ChatSummary] = [] var chats: [ChatSummary] = []
var searches: [SearchSummary] = [] var searches: [SearchSummary] = []
var workspaceItems: [WorkspaceItem] = []
var selectedItem: SidebarSelection? var selectedItem: SidebarSelection?
var selectedChat: ChatDetail? var selectedChat: ChatDetail?
@@ -111,6 +112,16 @@ final class SybilViewModel {
var provider: Provider var provider: Provider
var modelCatalog: [Provider: ProviderModelInfo] = [:] var modelCatalog: [Provider: ProviderModelInfo] = [:]
var model: String var model: String
var quickQuestionPrompt = ""
var quickQuestionMessages: [Message] = []
var quickQuestionError: String?
var quickQuestionProvider: Provider
var quickQuestionModel: String
var quickQuestionSubmittedPrompt: String?
var quickQuestionSubmittedProvider: Provider?
var quickQuestionSubmittedModel: String?
var isQuickQuestionSending = false
var isConvertingQuickQuestion = false
@ObservationIgnored @ObservationIgnored
private var hasBootstrapped = false private var hasBootstrapped = false
@@ -132,6 +143,10 @@ final class SybilViewModel {
@ObservationIgnored @ObservationIgnored
private var activeSearchAttachTasks: [String: Task<Void, Never>] = [:] private var activeSearchAttachTasks: [String: Task<Void, Never>] = [:]
@ObservationIgnored @ObservationIgnored
private var quickQuestionTask: Task<Void, Never>?
@ObservationIgnored
private var quickQuestionRunID: UUID?
@ObservationIgnored
private var isAppActive = true private var isAppActive = true
@ObservationIgnored @ObservationIgnored
private var appLifecycleGeneration = 0 private var appLifecycleGeneration = 0
@@ -141,7 +156,8 @@ final class SybilViewModel {
private let fallbackModels: [Provider: [String]] = [ private let fallbackModels: [Provider: [String]] = [
.openai: ["gpt-4.1-mini"], .openai: ["gpt-4.1-mini"],
.anthropic: ["claude-3-5-sonnet-latest"], .anthropic: ["claude-3-5-sonnet-latest"],
.xai: ["grok-3-mini"] .xai: ["grok-3-mini"],
.hermesAgent: ["hermes-agent"]
] ]
init( init(
@@ -152,14 +168,56 @@ final class SybilViewModel {
) { ) {
self.settings = settings self.settings = settings
self.clientFactory = clientFactory self.clientFactory = clientFactory
self.provider = settings.preferredProvider let initialProvider = settings.preferredProvider
self.model = settings.preferredModelByProvider[settings.preferredProvider] ?? "gpt-4.1-mini" let initialModel = settings.preferredModelByProvider[initialProvider] ?? "gpt-4.1-mini"
self.provider = initialProvider
self.model = initialModel
let initialQuickQuestionProvider = settings.quickQuestionPreferredProvider
let initialQuickQuestionModel = settings.quickQuestionPreferredModelByProvider[initialQuickQuestionProvider] ?? initialModel
self.quickQuestionProvider = initialQuickQuestionProvider
self.quickQuestionModel = initialQuickQuestionModel
} }
var providerModelOptions: [String] { var providerModelOptions: [String] {
modelOptions(for: provider) modelOptions(for: provider)
} }
var providerOptions: [Provider] {
Provider.allCases.filter { candidate in
candidate != .hermesAgent || modelCatalog[candidate] != nil
}
}
var quickQuestionProviderModelOptions: [String] {
modelOptions(for: quickQuestionProvider)
}
var canSendQuickQuestion: Bool {
!isQuickQuestionSending &&
!isConvertingQuickQuestion &&
!quickQuestionPrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var quickQuestionAnswerText: String {
for message in quickQuestionMessages.reversed() where message.role == .assistant {
let content = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
if !content.isEmpty {
return content
}
}
return ""
}
var canConvertQuickQuestion: Bool {
!isQuickQuestionSending &&
!isConvertingQuickQuestion &&
!(quickQuestionSubmittedPrompt?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) &&
!quickQuestionAnswerText.isEmpty &&
quickQuestionSubmittedProvider != nil &&
!(quickQuestionSubmittedModel?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
}
func modelOptions(for candidate: Provider) -> [String] { func modelOptions(for candidate: Provider) -> [String] {
let serverModels = modelCatalog[candidate]?.models ?? [] let serverModels = modelCatalog[candidate]?.models ?? []
if !serverModels.isEmpty { if !serverModels.isEmpty {
@@ -331,40 +389,40 @@ final class SybilViewModel {
} }
var sidebarItems: [SidebarItem] { var sidebarItems: [SidebarItem] {
let chatItems: [SidebarItem] = chats.map { chat in workspaceItems.map { item in
let initiatedLabel: String? switch item.type {
if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty { case .chat:
if let provider = chat.initiatedProvider { let initiatedLabel: String?
initiatedLabel = "\(provider.displayName)\(model)" if let model = item.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
if let provider = item.initiatedProvider {
initiatedLabel = "\(provider.displayName)\(model)"
} else {
initiatedLabel = model
}
} else { } else {
initiatedLabel = model initiatedLabel = nil
} }
} else {
initiatedLabel = nil return SidebarItem(
selection: .chat(item.id),
kind: .chat,
title: chatTitle(title: item.title, messages: nil),
updatedAt: item.updatedAt,
initiatedLabel: initiatedLabel,
isRunning: isChatRowRunning(item.id)
)
case .search:
return SidebarItem(
selection: .search(item.id),
kind: .search,
title: searchTitle(title: item.title, query: item.query),
updatedAt: item.updatedAt,
initiatedLabel: "exa",
isRunning: isSearchRowRunning(item.id)
)
} }
return SidebarItem(
selection: .chat(chat.id),
kind: .chat,
title: chatTitle(title: chat.title, messages: nil),
updatedAt: chat.updatedAt,
initiatedLabel: initiatedLabel,
isRunning: isChatRowRunning(chat.id)
)
} }
let searchItems: [SidebarItem] = searches.map { search in
SidebarItem(
selection: .search(search.id),
kind: .search,
title: searchTitle(title: search.title, query: search.query),
updatedAt: search.updatedAt,
initiatedLabel: "exa",
isRunning: isSearchRowRunning(search.id)
)
}
return (chatItems + searchItems).sorted { $0.updatedAt > $1.updatedAt }
} }
var selectedChatSummary: ChatSummary? { var selectedChatSummary: ChatSummary? {
@@ -415,6 +473,7 @@ final class SybilViewModel {
localActiveSearchIDs = [] localActiveSearchIDs = []
serverActiveChatIDs = [] serverActiveChatIDs = []
serverActiveSearchIDs = [] serverActiveSearchIDs = []
resetQuickQuestion()
draftIdentity = UUID() draftIdentity = UUID()
composerAttachments = [] composerAttachments = []
settings.persist() settings.persist()
@@ -444,6 +503,7 @@ final class SybilViewModel {
authMode = nil authMode = nil
chats = [] chats = []
searches = [] searches = []
workspaceItems = []
selectedItem = .settings selectedItem = .settings
selectedChat = nil selectedChat = nil
selectedSearch = nil selectedSearch = nil
@@ -487,6 +547,160 @@ final class SybilViewModel {
SybilLog.info(SybilLog.ui, "Provider changed to \(nextProvider.rawValue), model=\(nextModel)") SybilLog.info(SybilLog.ui, "Provider changed to \(nextProvider.rawValue), model=\(nextModel)")
} }
func setQuickQuestionProvider(_ nextProvider: Provider) {
quickQuestionProvider = nextProvider
let options = modelOptions(for: nextProvider)
if let preferred = settings.quickQuestionPreferredModelByProvider[nextProvider], options.contains(preferred) {
quickQuestionModel = preferred
} else if let first = options.first {
quickQuestionModel = first
} else {
quickQuestionModel = ""
}
persistQuickQuestionModelSelection()
}
func setQuickQuestionModel(_ nextModel: String) {
quickQuestionModel = nextModel
persistQuickQuestionModelSelection()
}
private func persistQuickQuestionModelSelection() {
settings.quickQuestionPreferredProvider = quickQuestionProvider
let trimmedModel = quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedModel.isEmpty {
settings.quickQuestionPreferredModelByProvider[quickQuestionProvider] = trimmedModel
}
settings.persist()
}
func updateQuickQuestionPrompt(_ nextPrompt: String) {
guard nextPrompt != quickQuestionPrompt else {
return
}
if isQuickQuestionSending || quickQuestionSubmittedPrompt != nil || !quickQuestionMessages.isEmpty {
cancelQuickQuestion()
quickQuestionSubmittedPrompt = nil
quickQuestionSubmittedProvider = nil
quickQuestionSubmittedModel = nil
quickQuestionMessages = []
quickQuestionError = nil
}
quickQuestionPrompt = nextPrompt
}
func resetQuickQuestion() {
cancelQuickQuestion()
quickQuestionPrompt = ""
quickQuestionMessages = []
quickQuestionError = nil
quickQuestionSubmittedPrompt = nil
quickQuestionSubmittedProvider = nil
quickQuestionSubmittedModel = nil
isConvertingQuickQuestion = false
}
func cancelQuickQuestion() {
quickQuestionTask?.cancel()
quickQuestionTask = nil
quickQuestionRunID = nil
isQuickQuestionSending = false
}
@discardableResult
func sendQuickQuestion() -> Task<Void, Never>? {
let content = quickQuestionPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty, !isQuickQuestionSending, !isConvertingQuickQuestion else {
return nil
}
let selectedModel = quickQuestionModel.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedModel.isEmpty else {
quickQuestionError = "No model available for selected provider."
return nil
}
cancelQuickQuestion()
let selectedProvider = quickQuestionProvider
let task = Task { [weak self] in
guard let self else {
return
}
await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
}
quickQuestionTask = task
return task
}
@discardableResult
func convertQuickQuestionToChat() async -> Bool {
let question = quickQuestionSubmittedPrompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let answer = quickQuestionAnswerText
guard !question.isEmpty,
!answer.isEmpty,
let submittedProvider = quickQuestionSubmittedProvider,
let submittedModel = quickQuestionSubmittedModel?.trimmingCharacters(in: .whitespacesAndNewlines),
!submittedModel.isEmpty,
!isQuickQuestionSending,
!isConvertingQuickQuestion
else {
return false
}
isConvertingQuickQuestion = true
quickQuestionError = nil
defer {
isConvertingQuickQuestion = false
}
do {
let titleSeed = question.split(whereSeparator: \.isNewline).first.map(String.init) ?? question
let title = String(titleSeed.trimmingCharacters(in: .whitespacesAndNewlines).prefix(48))
let chat = try await client().createChat(
title: title.isEmpty ? "Quick question" : title,
provider: submittedProvider,
model: submittedModel,
messages: [
CompletionRequestMessage(role: .user, content: question),
CompletionRequestMessage(role: .assistant, content: answer)
]
)
setProvider(submittedProvider, model: submittedModel)
chats.removeAll(where: { $0.id == chat.id })
chats.insert(chat, at: 0)
upsertWorkspaceChat(chat)
draftKind = nil
selectedItem = .chat(chat.id)
selectedChat = ChatDetail(
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: []
)
selectedSearch = nil
composer = ""
composerAttachments = []
await refreshCollections(preferredSelection: .chat(chat.id))
resetQuickQuestion()
return true
} catch {
quickQuestionError = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Convert quick question to chat failed", error: error)
return false
}
}
func startNewChat() { func startNewChat() {
SybilLog.debug(SybilLog.ui, "Starting draft chat") SybilLog.debug(SybilLog.ui, "Starting draft chat")
resetSelectionLoading() resetSelectionLoading()
@@ -700,6 +914,23 @@ final class SybilViewModel {
} }
} }
func refreshSidebarCollectionsFromPullToRefresh() async {
guard isAuthenticated, !isCheckingSession else {
return
}
SybilLog.info(
SybilLog.ui,
"Sidebar pull-to-refresh requested"
)
let preferredSelection = selectedItem
let refreshTask = Task { @MainActor in
await refreshCollections(preferredSelection: preferredSelection, refreshSelection: false)
}
await refreshTask.value
}
func sendComposer() async { func sendComposer() async {
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines) let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
let attachments = composerAttachments let attachments = composerAttachments
@@ -806,6 +1037,7 @@ final class SybilViewModel {
guard selectedItem == sourceSelection, draftKind == nil else { guard selectedItem == sourceSelection, draftKind == nil else {
chats.removeAll(where: { $0.id == chat.id }) chats.removeAll(where: { $0.id == chat.id })
chats.insert(chat, at: 0) chats.insert(chat, at: 0)
upsertWorkspaceChat(chat)
isCreatingSearchChat = false isCreatingSearchChat = false
return return
} }
@@ -817,6 +1049,7 @@ final class SybilViewModel {
chats.removeAll(where: { $0.id == chat.id }) chats.removeAll(where: { $0.id == chat.id })
chats.insert(chat, at: 0) chats.insert(chat, at: 0)
upsertWorkspaceChat(chat)
selectedItem = .chat(chat.id) selectedItem = .chat(chat.id)
selectedSearch = nil selectedSearch = nil
@@ -830,23 +1063,106 @@ final class SybilViewModel {
isCreatingSearchChat = false isCreatingSearchChat = false
} }
private func runQuickQuestion(prompt: String, provider: Provider, model: String) async {
let runID = UUID()
quickQuestionRunID = runID
quickQuestionError = nil
quickQuestionSubmittedPrompt = prompt
quickQuestionSubmittedProvider = provider
quickQuestionSubmittedModel = model
quickQuestionMessages = [
Message(
id: "temp-assistant-quick-\(UUID().uuidString)",
createdAt: Date(),
role: .assistant,
content: "",
name: nil
)
]
isQuickQuestionSending = true
defer {
if quickQuestionRunID == runID {
quickQuestionTask = nil
quickQuestionRunID = nil
isQuickQuestionSending = false
}
}
let streamStatus = CompletionStreamStatus()
do {
try await client().runCompletionStream(
body: CompletionStreamRequest(
chatId: nil,
persist: false,
provider: provider,
model: model,
messages: [CompletionRequestMessage(role: .user, content: prompt)]
)
) { [weak self] event in
guard let self else { return }
await self.applyQuickQuestionCompletionEvent(event, streamStatus: streamStatus)
}
if let streamError = await streamStatus.error() {
throw APIError.httpError(statusCode: 502, message: streamError)
}
} catch {
guard quickQuestionRunID == runID else {
return
}
if isCancellation(error) {
return
}
quickQuestionError = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Quick question failed", error: error)
}
}
private func applyQuickQuestionCompletionEvent(_ event: CompletionStreamEvent, streamStatus: CompletionStreamStatus) async {
switch event {
case .meta:
break
case let .toolCall(payload):
insertQuickQuestionToolCallMessage(payload)
case let .delta(payload):
guard !payload.text.isEmpty else { return }
mutateQuickQuestionAssistantMessage { existing in
existing + payload.text
}
case let .done(payload):
mutateQuickQuestionAssistantMessage { _ in
payload.text
}
case let .error(payload):
await streamStatus.setError(payload.message)
case .ignored:
break
}
}
private func loadInitialData(using client: any SybilAPIClienting) async { private func loadInitialData(using client: any SybilAPIClienting) async {
isLoadingCollections = true isLoadingCollections = true
errorMessage = nil errorMessage = nil
do { do {
async let chatsValue = client.listChats() async let workspaceItemsValue = client.listWorkspaceItems()
async let searchesValue = client.listSearches()
async let activeRunsValue = client.getActiveRuns() async let activeRunsValue = client.getActiveRuns()
let (nextChats, nextSearches, nextActiveRuns) = try await (chatsValue, searchesValue, activeRunsValue) let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
chats = nextChats applyWorkspaceItems(nextWorkspaceItems)
searches = nextSearches
applyActiveRuns(nextActiveRuns) applyActiveRuns(nextActiveRuns)
SybilLog.info( SybilLog.info(
SybilLog.app, SybilLog.app,
"Loaded collections: \(nextChats.count) chats, \(nextSearches.count) searches" "Loaded collections: \(chats.count) chats, \(searches.count) searches"
) )
do { do {
@@ -863,7 +1179,7 @@ final class SybilViewModel {
if case .settings = selectedItem { if case .settings = selectedItem {
nextSelection = .settings nextSelection = .settings
} else if let currentSelection = selectedItem, } else if let currentSelection = selectedItem,
hasSelection(currentSelection, chats: nextChats, searches: nextSearches) { hasSelection(currentSelection, chats: chats, searches: searches) {
nextSelection = currentSelection nextSelection = currentSelection
} else { } else {
nextSelection = sidebarItems.first?.selection nextSelection = sidebarItems.first?.selection
@@ -893,6 +1209,11 @@ final class SybilViewModel {
} }
private func syncModelSelectionWithServerCatalog() { private func syncModelSelectionWithServerCatalog() {
if !providerOptions.contains(provider), let firstProvider = providerOptions.first {
provider = firstProvider
settings.preferredProvider = firstProvider
}
if !providerModelOptions.contains(model), let first = providerModelOptions.first { if !providerModelOptions.contains(model), let first = providerModelOptions.first {
model = first model = first
settings.preferredModelByProvider[provider] = first settings.preferredModelByProvider[provider] = first
@@ -902,6 +1223,22 @@ final class SybilViewModel {
model = preferred model = preferred
} }
if !providerOptions.contains(quickQuestionProvider), let firstProvider = providerOptions.first {
quickQuestionProvider = firstProvider
settings.quickQuestionPreferredProvider = firstProvider
}
if !quickQuestionProviderModelOptions.contains(quickQuestionModel), let first = quickQuestionProviderModelOptions.first {
quickQuestionModel = first
settings.quickQuestionPreferredModelByProvider[quickQuestionProvider] = first
}
if let preferred = settings.quickQuestionPreferredModelByProvider[quickQuestionProvider],
quickQuestionProviderModelOptions.contains(preferred)
{
quickQuestionModel = preferred
}
settings.persist() settings.persist()
} }
@@ -914,18 +1251,16 @@ final class SybilViewModel {
do { do {
let client = try client() let client = try client()
async let chatsValue = client.listChats() async let workspaceItemsValue = client.listWorkspaceItems()
async let searchesValue = client.listSearches()
async let activeRunsValue = client.getActiveRuns() async let activeRunsValue = client.getActiveRuns()
let (nextChats, nextSearches, nextActiveRuns) = try await (chatsValue, searchesValue, activeRunsValue) let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
chats = nextChats applyWorkspaceItems(nextWorkspaceItems)
searches = nextSearches
applyActiveRuns(nextActiveRuns) applyActiveRuns(nextActiveRuns)
SybilLog.info( SybilLog.info(
SybilLog.app, SybilLog.app,
"Refreshed collections: \(nextChats.count) chats, \(nextSearches.count) searches" "Refreshed collections: \(chats.count) chats, \(searches.count) searches"
) )
errorMessage = nil errorMessage = nil
@@ -943,10 +1278,10 @@ final class SybilViewModel {
} }
if let preferredSelection, if let preferredSelection,
hasSelection(preferredSelection, chats: nextChats, searches: nextSearches) { hasSelection(preferredSelection, chats: chats, searches: searches) {
selectedItem = preferredSelection selectedItem = preferredSelection
} else if let existing = selectedItem, } else if let existing = selectedItem,
hasSelection(existing, chats: nextChats, searches: nextSearches) { hasSelection(existing, chats: chats, searches: searches) {
selectedItem = existing selectedItem = existing
} else { } else {
selectedItem = sidebarItems.first?.selection selectedItem = sidebarItems.first?.selection
@@ -959,7 +1294,9 @@ final class SybilViewModel {
attachToVisibleActiveRunIfNeeded() attachToVisibleActiveRunIfNeeded()
} }
} catch { } catch {
if shouldSuppressInactiveTransportError(error) { if isCancellation(error) {
SybilLog.debug(SybilLog.app, "Collection refresh cancelled")
} else if shouldSuppressInactiveTransportError(error) {
SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive") SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive")
} else { } else {
errorMessage = normalizeAPIError(error) errorMessage = normalizeAPIError(error)
@@ -1038,6 +1375,34 @@ final class SybilViewModel {
serverActiveSearchIDs = Set(activeRuns.searches) serverActiveSearchIDs = Set(activeRuns.searches)
} }
private func applyWorkspaceItems(_ items: [WorkspaceItem]) {
workspaceItems = items
chats = items.compactMap(\.chatSummary)
searches = items.compactMap(\.searchSummary)
}
private func upsertWorkspaceChat(_ chat: ChatSummary, moveToFront: Bool = true) {
upsertWorkspaceItem(WorkspaceItem(chat: chat), moveToFront: moveToFront)
}
private func upsertWorkspaceSearch(_ search: SearchSummary, moveToFront: Bool = true) {
upsertWorkspaceItem(WorkspaceItem(search: search), moveToFront: moveToFront)
}
private func upsertWorkspaceItem(_ item: WorkspaceItem, moveToFront: Bool) {
if let existingIndex = workspaceItems.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
workspaceItems.remove(at: existingIndex)
if moveToFront {
workspaceItems.insert(item, at: 0)
} else {
workspaceItems.insert(item, at: existingIndex)
}
return
}
workspaceItems.insert(item, at: 0)
}
private func attachToVisibleActiveRunIfNeeded() { private func attachToVisibleActiveRunIfNeeded() {
guard draftKind == nil else { guard draftKind == nil else {
return return
@@ -1369,6 +1734,7 @@ final class SybilViewModel {
chats.removeAll(where: { $0.id == created.id }) chats.removeAll(where: { $0.id == created.id })
chats.insert(created, at: 0) chats.insert(created, at: 0)
upsertWorkspaceChat(created)
if shouldShowCreatedChat { if shouldShowCreatedChat {
draftKind = nil draftKind = nil
@@ -1445,6 +1811,7 @@ final class SybilViewModel {
} }
return existing return existing
} }
self.upsertWorkspaceChat(updated, moveToFront: false)
if self.selectedChat?.id == updated.id { if self.selectedChat?.id == updated.id {
self.selectedChat?.title = updated.title self.selectedChat?.title = updated.title
@@ -1582,6 +1949,7 @@ final class SybilViewModel {
searches.removeAll(where: { $0.id == created.id }) searches.removeAll(where: { $0.id == created.id })
searches.insert(created, at: 0) searches.insert(created, at: 0)
upsertWorkspaceSearch(created)
if shouldShowCreatedSearch { if shouldShowCreatedSearch {
draftKind = nil draftKind = nil
@@ -1752,6 +2120,15 @@ final class SybilViewModel {
pendingChatStates[chatID] = pending pendingChatStates[chatID] = pending
} }
private func mutateQuickQuestionAssistantMessage(_ transform: (String) -> String) {
let index = quickQuestionMessages.indices.last { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }
guard let index else {
return
}
quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content)
}
private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) { private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
guard var pending = pendingChatStates[chatID] else { guard var pending = pendingChatStates[chatID] else {
return return
@@ -1761,6 +2138,31 @@ final class SybilViewModel {
return return
} }
let message = toolCallMessage(for: payload)
if let assistantIndex = pending.messages.indices.last(where: { pending.messages[$0].id.hasPrefix("temp-assistant-") }) {
pending.messages.insert(message, at: assistantIndex)
} else {
pending.messages.append(message)
}
pendingChatStates[chatID] = pending
}
private func insertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
if quickQuestionMessages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
return
}
let message = toolCallMessage(for: payload)
if let assistantIndex = quickQuestionMessages.indices.last(where: { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }) {
quickQuestionMessages.insert(message, at: assistantIndex)
} else {
quickQuestionMessages.append(message)
}
}
private func toolCallMessage(for payload: CompletionStreamToolCall) -> Message {
let metadata: JSONValue = .object([ let metadata: JSONValue = .object([
"kind": .string("tool_call"), "kind": .string("tool_call"),
"toolCallId": .string(payload.toolCallId), "toolCallId": .string(payload.toolCallId),
@@ -1779,7 +2181,7 @@ final class SybilViewModel {
? "Ran tool '\(payload.name)'." ? "Ran tool '\(payload.name)'."
: payload.summary : payload.summary
let message = Message( return Message(
id: "temp-tool-\(payload.toolCallId)", id: "temp-tool-\(payload.toolCallId)",
createdAt: Date(), createdAt: Date(),
role: .tool, role: .tool,
@@ -1787,14 +2189,6 @@ final class SybilViewModel {
name: payload.name, name: payload.name,
metadata: metadata metadata: metadata
) )
if let assistantIndex = pending.messages.indices.last(where: { pending.messages[$0].id.hasPrefix("temp-assistant-") }) {
pending.messages.insert(message, at: assistantIndex)
} else {
pending.messages.append(message)
}
pendingChatStates[chatID] = pending
} }
private var currentChatID: String? { private var currentChatID: String? {

View File

@@ -232,13 +232,7 @@ struct SybilWorkspaceView: View {
HStack(spacing: 14) { HStack(spacing: 14) {
workspaceNavigationLeadingControl workspaceNavigationLeadingControl
Text(viewModel.selectedTitle) customWorkspaceNavigationTitle
.font(.sybil(size: 16, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.minimumScaleFactor(0.78)
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
workspaceNavigationTrailingControl workspaceNavigationTrailingControl
} }
@@ -251,6 +245,32 @@ struct SybilWorkspaceView: View {
} }
} }
private var selectedProviderModelSubtitle: String {
let selectedModel = viewModel.model.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedModel.isEmpty else {
return viewModel.provider.displayName
}
return "\(viewModel.provider.displayName)\(selectedModel)"
}
private var customWorkspaceNavigationTitle: some View {
VStack(alignment: .leading, spacing: 2) {
Text(viewModel.selectedTitle)
.font(.sybil(size: 16, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.minimumScaleFactor(0.78)
Text(selectedProviderModelSubtitle)
.font(.sybil(size: 10, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
.lineLimit(1)
.minimumScaleFactor(0.82)
}
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
}
@ViewBuilder @ViewBuilder
private var workspaceNavigationLeadingControl: some View { private var workspaceNavigationLeadingControl: some View {
switch navigationLeadingControl { switch navigationLeadingControl {
@@ -495,7 +515,7 @@ struct SybilWorkspaceView: View {
Divider() Divider()
ForEach(Provider.allCases, id: \.self) { candidate in ForEach(viewModel.providerOptions, id: \.self) { candidate in
Menu(candidate.displayName) { Menu(candidate.displayName) {
let models = viewModel.modelOptions(for: candidate) let models = viewModel.modelOptions(for: candidate)
if models.isEmpty { if models.isEmpty {
@@ -703,9 +723,7 @@ struct SybilWorkspaceView: View {
} }
#if !targetEnvironment(macCatalyst) #if !targetEnvironment(macCatalyst)
if !viewModel.isSearchMode { composerFocused = false
composerFocused = false
}
#endif #endif
Task { Task {

View File

@@ -4,26 +4,42 @@ import Testing
@testable import Sybil @testable import Sybil
private struct MockClientCallSnapshot: Sendable { private struct MockClientCallSnapshot: Sendable {
var listWorkspaceItems = 0
var listChats = 0 var listChats = 0
var listSearches = 0 var listSearches = 0
var createChat = 0
var getChat = 0 var getChat = 0
var getSearch = 0 var getSearch = 0
var getActiveRuns = 0 var getActiveRuns = 0
var runCompletionStream = 0
var attachCompletionStream = 0 var attachCompletionStream = 0
var attachSearchStream = 0 var attachSearchStream = 0
} }
private struct ChatCreateCallSnapshot: Sendable {
var title: String?
var provider: Provider?
var model: String?
var messages: [CompletionRequestMessage]?
}
private struct UnexpectedClientCall: Error {} private struct UnexpectedClientCall: Error {}
private actor MockSybilClient: SybilAPIClienting { private actor MockSybilClient: SybilAPIClienting {
private let chatsResponse: [ChatSummary] private let chatsResponse: [ChatSummary]
private let searchesResponse: [SearchSummary] private let searchesResponse: [SearchSummary]
private let workspaceItemsResponse: [WorkspaceItem]
private let chatDetails: [String: ChatDetail] private let chatDetails: [String: ChatDetail]
private let searchDetails: [String: SearchDetail] private let searchDetails: [String: SearchDetail]
private let createChatResponse: ChatSummary? private let createChatResponse: ChatSummary?
private let activeRunsResponse: ActiveRunsResponse private let activeRunsResponse: ActiveRunsResponse
private var snapshot = MockClientCallSnapshot() private var snapshot = MockClientCallSnapshot()
private var lastCreateChatCall: ChatCreateCallSnapshot?
private var lastCompletionStreamBody: CompletionStreamRequest?
private var completionStreamEvents: [CompletionStreamEvent]?
private var listChatsDelayNanoseconds: UInt64 = 0
private var listSearchesDelayNanoseconds: UInt64 = 0
private var getChatDelayNanoseconds: UInt64 = 0 private var getChatDelayNanoseconds: UInt64 = 0
private var getSearchDelayNanoseconds: UInt64 = 0 private var getSearchDelayNanoseconds: UInt64 = 0
private var completionStreamNetworkErrorMessage: String? private var completionStreamNetworkErrorMessage: String?
@@ -41,25 +57,49 @@ private actor MockSybilClient: SybilAPIClienting {
chatDetails: [String: ChatDetail] = [:], chatDetails: [String: ChatDetail] = [:],
searchDetails: [String: SearchDetail] = [:], searchDetails: [String: SearchDetail] = [:],
createChatResponse: ChatSummary? = nil, createChatResponse: ChatSummary? = nil,
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse() activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
workspaceItemsResponse: [WorkspaceItem]? = nil
) { ) {
self.chatsResponse = chatsResponse self.chatsResponse = chatsResponse
self.searchesResponse = searchesResponse self.searchesResponse = searchesResponse
self.workspaceItemsResponse = workspaceItemsResponse ?? Self.makeWorkspaceItems(chats: chatsResponse, searches: searchesResponse)
self.chatDetails = chatDetails self.chatDetails = chatDetails
self.searchDetails = searchDetails self.searchDetails = searchDetails
self.createChatResponse = createChatResponse self.createChatResponse = createChatResponse
self.activeRunsResponse = activeRunsResponse self.activeRunsResponse = activeRunsResponse
} }
private static func makeWorkspaceItems(chats: [ChatSummary], searches: [SearchSummary]) -> [WorkspaceItem] {
(chats.map { WorkspaceItem(chat: $0) } + searches.map { WorkspaceItem(search: $0) }).sorted { $0.updatedAt > $1.updatedAt }
}
func currentSnapshot() -> MockClientCallSnapshot { func currentSnapshot() -> MockClientCallSnapshot {
snapshot snapshot
} }
func currentCreateChatCall() -> ChatCreateCallSnapshot? {
lastCreateChatCall
}
func currentCompletionStreamBody() -> CompletionStreamRequest? {
lastCompletionStreamBody
}
func setCompletionStreamEvents(_ events: [CompletionStreamEvent], delayNanoseconds: UInt64 = 0) {
completionStreamEvents = events
completionStreamDelayNanoseconds = delayNanoseconds
}
func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) { func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
completionStreamNetworkErrorMessage = message completionStreamNetworkErrorMessage = message
completionStreamDelayNanoseconds = delayNanoseconds completionStreamDelayNanoseconds = delayNanoseconds
} }
func setListDelays(chats: UInt64 = 0, searches: UInt64 = 0) {
listChatsDelayNanoseconds = chats
listSearchesDelayNanoseconds = searches
}
func setGetChatDelay(_ delayNanoseconds: UInt64) { func setGetChatDelay(_ delayNanoseconds: UInt64) {
getChatDelayNanoseconds = delayNanoseconds getChatDelayNanoseconds = delayNanoseconds
} }
@@ -95,12 +135,36 @@ private actor MockSybilClient: SybilAPIClienting {
AuthSession(authenticated: true, mode: "open") AuthSession(authenticated: true, mode: "open")
} }
func listWorkspaceItems() async throws -> [WorkspaceItem] {
snapshot.listWorkspaceItems += 1
let delay = max(listChatsDelayNanoseconds, listSearchesDelayNanoseconds)
if delay > 0 {
try await Task.sleep(nanoseconds: delay)
}
return workspaceItemsResponse
}
func listChats() async throws -> [ChatSummary] { func listChats() async throws -> [ChatSummary] {
snapshot.listChats += 1 snapshot.listChats += 1
if listChatsDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: listChatsDelayNanoseconds)
}
return chatsResponse return chatsResponse
} }
func createChat(title: String?) async throws -> ChatSummary { func createChat(
title: String?,
provider: Provider?,
model: String?,
messages: [CompletionRequestMessage]?
) async throws -> ChatSummary {
snapshot.createChat += 1
lastCreateChatCall = ChatCreateCallSnapshot(
title: title,
provider: provider,
model: model,
messages: messages
)
if let createChatResponse { if let createChatResponse {
return createChatResponse return createChatResponse
} }
@@ -128,6 +192,9 @@ private actor MockSybilClient: SybilAPIClienting {
func listSearches() async throws -> [SearchSummary] { func listSearches() async throws -> [SearchSummary] {
snapshot.listSearches += 1 snapshot.listSearches += 1
if listSearchesDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: listSearchesDelayNanoseconds)
}
return searchesResponse return searchesResponse
} }
@@ -167,12 +234,20 @@ private actor MockSybilClient: SybilAPIClienting {
body: CompletionStreamRequest, body: CompletionStreamRequest,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
) async throws { ) async throws {
snapshot.runCompletionStream += 1
lastCompletionStreamBody = body
if completionStreamDelayNanoseconds > 0 { if completionStreamDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds) try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
} }
if let completionStreamNetworkErrorMessage { if let completionStreamNetworkErrorMessage {
throw APIError.networkError(message: completionStreamNetworkErrorMessage) throw APIError.networkError(message: completionStreamNetworkErrorMessage)
} }
if let completionStreamEvents {
for event in completionStreamEvents {
await onEvent(event)
}
return
}
throw UnexpectedClientCall() throw UnexpectedClientCall()
} }
@@ -331,13 +406,41 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false) await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)
let snapshot = await client.currentSnapshot() let snapshot = await client.currentSnapshot()
#expect(snapshot.listChats == 1) #expect(snapshot.listWorkspaceItems == 1)
#expect(snapshot.listSearches == 1) #expect(snapshot.listChats == 0)
#expect(snapshot.listSearches == 0)
#expect(snapshot.getChat == 0) #expect(snapshot.getChat == 0)
#expect(snapshot.getSearch == 0) #expect(snapshot.getSearch == 0)
#expect(viewModel.selectedItem == .chat("chat-1")) #expect(viewModel.selectedItem == .chat("chat-1"))
} }
@MainActor
@Test func pullToRefreshCompletesWhenRefreshableTaskIsCancelled() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_050)
let chat = makeChatSummary(id: "chat-cancelled", date: date)
let search = makeSearchSummary(id: "search-cancelled", date: date)
let client = MockSybilClient(
chatsResponse: [chat],
searchesResponse: [search]
)
await client.setListDelays(chats: 50_000_000, searches: 50_000_000)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
let refreshTask = Task {
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
}
try await Task.sleep(nanoseconds: 10_000_000)
refreshTask.cancel()
await refreshTask.value
#expect(viewModel.errorMessage == nil)
#expect(!viewModel.isLoadingCollections)
#expect(viewModel.chats.map(\.id) == ["chat-cancelled"])
#expect(viewModel.searches.map(\.id) == ["search-cancelled"])
}
@MainActor @MainActor
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws { @Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_100) let date = Date(timeIntervalSince1970: 1_700_000_100)
@@ -351,6 +454,7 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true) await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
let snapshot = await client.currentSnapshot() let snapshot = await client.currentSnapshot()
#expect(snapshot.listWorkspaceItems == 0)
#expect(snapshot.listChats == 0) #expect(snapshot.listChats == 0)
#expect(snapshot.listSearches == 0) #expect(snapshot.listSearches == 0)
#expect(snapshot.getChat == 1) #expect(snapshot.getChat == 1)
@@ -370,6 +474,7 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true) await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
let snapshot = await client.currentSnapshot() let snapshot = await client.currentSnapshot()
#expect(snapshot.listWorkspaceItems == 0)
#expect(snapshot.listChats == 0) #expect(snapshot.listChats == 0)
#expect(snapshot.listSearches == 0) #expect(snapshot.listSearches == 0)
#expect(snapshot.getSearch == 1) #expect(snapshot.getSearch == 1)
@@ -470,6 +575,117 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
await sendTask.value await sendTask.value
} }
@MainActor
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
let client = MockSybilClient()
await client.setCompletionStreamEvents([
.delta(CompletionStreamDelta(text: "Reset it from ")),
.done(CompletionStreamDone(text: "Reset it from Settings."))
])
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.quickQuestionPrompt = "How do I reset my password?"
let task = viewModel.sendQuickQuestion()
await task?.value
let snapshot = await client.currentSnapshot()
let body = await client.currentCompletionStreamBody()
#expect(snapshot.runCompletionStream == 1)
#expect(body?.persist == false)
#expect(body?.chatId == nil)
#expect(body?.provider == .openai)
#expect(body?.messages.first?.role == .user)
#expect(body?.messages.first?.content == "How do I reset my password?")
#expect(viewModel.quickQuestionAnswerText == "Reset it from Settings.")
#expect(!viewModel.isQuickQuestionSending)
}
@MainActor
@Test func quickQuestionConvertCreatesSeededChat() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_250)
let chat = makeChatSummary(id: "quick-chat", date: date)
let detail = ChatDetail(
id: chat.id,
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
initiatedProvider: .openai,
initiatedModel: "gpt-4.1-mini",
lastUsedProvider: .openai,
lastUsedModel: "gpt-4.1-mini",
messages: [
Message(id: "quick-user", createdAt: date, role: .user, content: "How do I reset my password?", name: nil),
Message(id: "quick-assistant", createdAt: date, role: .assistant, content: "Reset it from Settings.", name: nil)
]
)
let client = MockSybilClient(
chatsResponse: [chat],
chatDetails: [chat.id: detail],
createChatResponse: chat
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.quickQuestionSubmittedPrompt = "How do I reset my password?"
viewModel.quickQuestionSubmittedProvider = .openai
viewModel.quickQuestionSubmittedModel = "gpt-4.1-mini"
viewModel.quickQuestionMessages = [
Message(
id: "temp-assistant-quick",
createdAt: date,
role: .assistant,
content: "Reset it from Settings.",
name: nil
)
]
let didConvert = await viewModel.convertQuickQuestionToChat()
let snapshot = await client.currentSnapshot()
let createCall = await client.currentCreateChatCall()
#expect(didConvert)
#expect(snapshot.createChat == 1)
#expect(createCall?.title == "How do I reset my password?")
#expect(createCall?.provider == .openai)
#expect(createCall?.model == "gpt-4.1-mini")
#expect(createCall?.messages?.map(\.role) == [.user, .assistant])
#expect(createCall?.messages?.map(\.content) == ["How do I reset my password?", "Reset it from Settings."])
#expect(viewModel.selectedItem == .chat("quick-chat"))
#expect(viewModel.quickQuestionPrompt.isEmpty)
}
@MainActor
@Test func quickQuestionProviderAndModelSelectionPersistSeparately() async throws {
let defaults = UserDefaults(suiteName: #function)!
defaults.removePersistentDomain(forName: #function)
let settings = SybilSettingsStore(defaults: defaults)
settings.apiBaseURL = "http://127.0.0.1:8787"
let viewModel = SybilViewModel(settings: settings) { _ in MockSybilClient() }
viewModel.modelCatalog = [
.openai: ProviderModelInfo(models: ["gpt-4.1-mini", "gpt-4o"], loadedAt: nil, error: nil),
.anthropic: ProviderModelInfo(models: ["claude-3-5-sonnet-latest", "claude-3-haiku"], loadedAt: nil, error: nil)
]
viewModel.setQuickQuestionProvider(.anthropic)
viewModel.setQuickQuestionModel("claude-3-haiku")
#expect(viewModel.quickQuestionProvider == .anthropic)
#expect(viewModel.quickQuestionModel == "claude-3-haiku")
#expect(settings.preferredProvider == .openai)
let reloadedSettings = SybilSettingsStore(defaults: defaults)
#expect(reloadedSettings.quickQuestionPreferredProvider == .anthropic)
#expect(reloadedSettings.quickQuestionPreferredModelByProvider[.anthropic] == "claude-3-haiku")
#expect(reloadedSettings.preferredProvider == .openai)
let reloadedViewModel = SybilViewModel(settings: reloadedSettings) { _ in MockSybilClient() }
#expect(reloadedViewModel.quickQuestionProvider == .anthropic)
#expect(reloadedViewModel.quickQuestionModel == "claude-3-haiku")
#expect(reloadedViewModel.provider == .openai)
}
@MainActor @MainActor
@Test func reconnectAttachesSelectedActiveChatStream() async throws { @Test func reconnectAttachesSelectedActiveChatStream() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_260) let date = Date(timeIntervalSince1970: 1_700_000_260)

View File

@@ -1,7 +1,7 @@
# Sybil Server # Sybil Server
Backend API for: Backend API for:
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok) - LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Hermes Agent)
- Personal chat database (chats/messages + LLM call log) - Personal chat database (chats/messages + LLM call log)
## Stack ## Stack
@@ -43,6 +43,9 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
- `OPENAI_API_KEY` - `OPENAI_API_KEY`
- `ANTHROPIC_API_KEY` - `ANTHROPIC_API_KEY`
- `XAI_API_KEY` - `XAI_API_KEY`
- `HERMES_AGENT_API_BASE_URL` (`http://127.0.0.1:8642/v1` by default; include the `/v1` suffix)
- `HERMES_AGENT_API_KEY` (enables the Hermes Agent provider; set to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth)
- `HERMES_AGENT_MODEL` (optional fallback/override model id; defaults client-side to `hermes-agent`)
- `EXA_API_KEY` - `EXA_API_KEY`
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default, or `searxng` for chat tool calls only) - `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`) - `SEARXNG_BASE_URL` (required when `CHAT_WEB_SEARCH_ENGINE=searxng`; instance must allow `format=json`)

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Chat" ADD COLUMN "additionalSystemPrompt" TEXT;
ALTER TABLE "Chat" ADD COLUMN "enabledTools" JSONB;

View File

@@ -13,6 +13,7 @@ enum Provider {
openai openai
anthropic anthropic
xai xai
hermes_agent @map("hermes-agent")
} }
enum MessageRole { enum MessageRole {
@@ -50,6 +51,9 @@ model Chat {
lastUsedProvider Provider? lastUsedProvider Provider?
lastUsedModel String? lastUsedModel String?
additionalSystemPrompt String?
enabledTools Json?
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])
userId String? userId String?

View File

@@ -11,6 +11,13 @@ const OptionalUrlSchema = z.preprocess(
z.string().trim().url().optional() z.string().trim().url().optional()
); );
const DEFAULT_HERMES_AGENT_API_BASE_URL = "http://127.0.0.1:8642/v1";
const HermesAgentApiBaseUrlSchema = z.preprocess(
(value) => (typeof value === "string" && value.trim() === "" ? undefined : value),
z.string().trim().url().default(DEFAULT_HERMES_AGENT_API_BASE_URL)
);
const ChatWebSearchEngineSchema = z.preprocess( const ChatWebSearchEngineSchema = z.preprocess(
(value) => { (value) => {
if (typeof value !== "string") return value; if (typeof value !== "string") return value;
@@ -59,6 +66,9 @@ const EnvSchema = z.object({
OPENAI_API_KEY: z.string().optional(), OPENAI_API_KEY: z.string().optional(),
ANTHROPIC_API_KEY: z.string().optional(), ANTHROPIC_API_KEY: z.string().optional(),
XAI_API_KEY: z.string().optional(), XAI_API_KEY: z.string().optional(),
HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema,
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
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-mode web_search tool configuration. Search mode remains Exa-only for now.

View File

@@ -5,7 +5,7 @@ import swaggerUI from "@fastify/swagger-ui";
import sensible from "@fastify/sensible"; import sensible from "@fastify/sensible";
import { env } from "./env.js"; import { env } from "./env.js";
import { ensureDatabaseReady } from "./db-init.js"; import { ensureDatabaseReady } from "./db-init.js";
import { warmModelCatalog } from "./llm/model-catalog.js"; import { startModelCatalogRefreshLoop, warmModelCatalog } from "./llm/model-catalog.js";
import { registerRoutes } from "./routes.js"; import { registerRoutes } from "./routes.js";
const app = Fastify({ const app = Fastify({
@@ -21,6 +21,7 @@ const app = Fastify({
await ensureDatabaseReady(app.log); await ensureDatabaseReady(app.log);
await warmModelCatalog(app.log); await warmModelCatalog(app.log);
const stopModelCatalogRefreshLoop = startModelCatalogRefreshLoop(app.log);
await app.register(cors, { await app.register(cors, {
origin: true, origin: true,
@@ -80,6 +81,10 @@ app.setErrorHandler((err, req, reply) => {
}); });
}); });
app.addHook("onClose", async () => {
stopModelCatalogRefreshLoop();
});
await registerRoutes(app); await registerRoutes(app);
await app.listen({ port: env.PORT, host: env.HOST }); await app.listen({ port: env.PORT, host: env.HOST });

View File

@@ -9,7 +9,11 @@ import { z } from "zod";
import { env } from "../env.js"; import { env } from "../env.js";
import { exaClient } from "../search/exa.js"; import { exaClient } from "../search/exa.js";
import { searchSearxng } from "../search/searxng.js"; import { searchSearxng } from "../search/searxng.js";
import { buildOpenAIConversationMessage, buildOpenAIResponsesInputMessage } from "./message-content.js"; import {
buildOpenAIConversationMessage,
buildOpenAIResponsesInputMessage,
buildSystemPromptAugmentationMessage,
} from "./message-content.js";
import type { ChatMessage } from "./types.js"; import type { ChatMessage } from "./types.js";
const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS; const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
@@ -188,7 +192,43 @@ const CHAT_TOOLS: any[] = [
...(env.CHAT_SHELL_TOOL_ENABLED ? [SHELL_EXEC_TOOL] : []), ...(env.CHAT_SHELL_TOOL_ENABLED ? [SHELL_EXEC_TOOL] : []),
]; ];
const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => { function getToolName(tool: any) {
return typeof tool?.function?.name === "string" ? tool.function.name : null;
}
export function getAvailableChatTools() {
return CHAT_TOOLS.map((tool) => {
const name = getToolName(tool);
if (!name) return null;
return {
name,
description: typeof tool?.function?.description === "string" ? tool.function.description : "",
};
}).filter((tool): tool is { name: string; description: string } => tool !== null);
}
export function normalizeEnabledChatTools(value: unknown) {
if (!Array.isArray(value)) return getAvailableChatTools().map((tool) => tool.name);
const available = new Set(getAvailableChatTools().map((tool) => tool.name));
return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))].filter((name) =>
available.has(name)
);
}
function getEnabledToolSet(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
return new Set(normalizeEnabledChatTools(params.enabledTools));
}
function getEnabledChatTools(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
const enabled = getEnabledToolSet(params);
return CHAT_TOOLS.filter((tool) => {
const name = getToolName(tool);
return name ? enabled.has(name) : false;
});
}
function toResponsesChatTools(tools: any[]) {
return tools.map((tool) => {
if (tool?.type !== "function") return tool; if (tool?.type !== "function") return tool;
return { return {
type: "function", type: "function",
@@ -197,7 +237,8 @@ const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => {
parameters: tool.function.parameters, parameters: tool.function.parameters,
strict: false, strict: false,
}; };
}); });
}
export const CHAT_TOOL_SYSTEM_PROMPT = export const CHAT_TOOL_SYSTEM_PROMPT =
"You can use tools to gather up-to-date web information when needed. " + "You can use tools to gather up-to-date web information when needed. " +
@@ -239,6 +280,8 @@ type ToolAwareCompletionParams = {
client: OpenAI; client: OpenAI;
model: string; model: string;
messages: ChatMessage[]; messages: ChatMessage[];
enabledTools?: string[];
userLocation?: string;
temperature?: number; temperature?: number;
maxTokens?: number; maxTokens?: number;
onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>; onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>;
@@ -379,16 +422,38 @@ function extractHtmlTitle(html: string) {
); );
} }
function normalizeIncomingMessages(messages: ChatMessage[]) { function buildChatToolSystemPrompt(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
const normalized = messages.map((message) => buildOpenAIConversationMessage(message)); const enabled = getEnabledToolSet(params);
return (
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized]; "You can use tools to gather up-to-date web information when needed. " +
(enabled.has("web_search") ? "Use web_search for discovery and recent facts. " : "") +
(enabled.has("fetch_url") ? "Use fetch_url to read the full content of a specific page. " : "") +
"Prefer tools when the user asks for current events, verification, sources, or details you do not already have. " +
"When you decide tool use is needed, call the tool immediately in the same response; do not say you are running a tool unless you actually call it. " +
(enabled.has("codex_exec")
? "Use codex_exec when a request needs substantial coding work, repository inspection, shell commands, tests, debugging, or another complex task suited to a persistent Codex workspace. Provide codex_exec a complete prompt with the goal, constraints, assumptions, and expected report-back format. Never ask codex_exec to wait for user input or run interactive commands. "
: "") +
(enabled.has("shell_exec")
? "Use shell_exec for direct non-interactive command-line work on the remote devbox, including quick Python programs, calculations, file inspection, running tests, and small scripts. "
: "") +
"Do not fabricate tool outputs; reason only from provided tool results."
);
} }
function normalizeIncomingResponsesInput(messages: ChatMessage[]) { function normalizeIncomingMessages(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
const normalized = messages.map((message) => buildOpenAIConversationMessage(message));
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
}
function normalizePlainIncomingMessages(messages: ChatMessage[], userLocation?: string) {
return [buildSystemPromptAugmentationMessage(userLocation), ...messages.map((message) => buildOpenAIConversationMessage(message))];
}
function normalizeIncomingResponsesInput(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message)); const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message));
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized]; return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
} }
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> { async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
@@ -853,6 +918,20 @@ function extractResponsesText(response: any, fallback = "") {
return parts.join("") || fallback; return parts.join("") || fallback;
} }
function extractChatCompletionContent(message: any) {
if (typeof message?.content === "string") return message.content;
if (!Array.isArray(message?.content)) return "";
return message.content
.map((part: any) => {
if (typeof part === "string") return part;
if (typeof part?.text === "string") return part.text;
if (typeof part?.content === "string") return part.content;
return "";
})
.join("");
}
function getUnstreamedText(finalText: string, streamedText: string) { function getUnstreamedText(finalText: string, streamedText: string) {
if (!finalText) return ""; if (!finalText) return "";
if (!streamedText) return finalText; if (!streamedText) return finalText;
@@ -939,7 +1018,8 @@ async function executeToolCallAndBuildEvent(
} }
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> { export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const input: any[] = normalizeIncomingResponsesInput(params.messages); const enabledTools = getEnabledChatTools(params);
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
const rawResponses: unknown[] = []; const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = []; const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -953,7 +1033,7 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
input, input,
temperature: params.temperature, temperature: params.temperature,
max_output_tokens: params.maxTokens, max_output_tokens: params.maxTokens,
tools: RESPONSES_CHAT_TOOLS, tools: toResponsesChatTools(enabledTools),
tool_choice: "auto", tool_choice: "auto",
parallel_tool_calls: true, parallel_tool_calls: true,
// Tool loops pass response output items back as input; reasoning items need persistence. // Tool loops pass response output items back as input; reasoning items need persistence.
@@ -1008,7 +1088,8 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
} }
export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> { export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const conversation: any[] = normalizeIncomingMessages(params.messages); const enabledTools = getEnabledChatTools(params);
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
const rawResponses: unknown[] = []; const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = []; const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -1022,7 +1103,7 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
messages: conversation, messages: conversation,
temperature: params.temperature, temperature: params.temperature,
max_tokens: params.maxTokens, max_tokens: params.maxTokens,
tools: CHAT_TOOLS, tools: enabledTools,
tool_choice: "auto", tool_choice: "auto",
} as any); } as any);
rawResponses.push(completion); rawResponses.push(completion);
@@ -1093,10 +1174,31 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
}; };
} }
export async function runPlainChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const completion = await params.client.chat.completions.create({
model: params.model,
messages: normalizePlainIncomingMessages(params.messages, params.userLocation),
temperature: params.temperature,
max_tokens: params.maxTokens,
} as any);
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
const sawUsage = mergeUsage(usageAcc, completion?.usage);
const message = completion?.choices?.[0]?.message;
return {
text: extractChatCompletionContent(message),
usage: sawUsage ? usageAcc : undefined,
raw: { response: completion, api: "chat.completions" },
toolEvents: [],
};
}
export async function* runToolAwareOpenAIChatStream( export async function* runToolAwareOpenAIChatStream(
params: ToolAwareCompletionParams params: ToolAwareCompletionParams
): AsyncGenerator<ToolAwareStreamingEvent> { ): AsyncGenerator<ToolAwareStreamingEvent> {
const input: any[] = normalizeIncomingResponsesInput(params.messages); const enabledTools = getEnabledChatTools(params);
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
const rawResponses: unknown[] = []; const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = []; const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -1110,7 +1212,7 @@ export async function* runToolAwareOpenAIChatStream(
input, input,
temperature: params.temperature, temperature: params.temperature,
max_output_tokens: params.maxTokens, max_output_tokens: params.maxTokens,
tools: RESPONSES_CHAT_TOOLS, tools: toResponsesChatTools(enabledTools),
tool_choice: "auto", tool_choice: "auto",
parallel_tool_calls: true, parallel_tool_calls: true,
// Tool loops pass response output items back as input; reasoning items need persistence. // Tool loops pass response output items back as input; reasoning items need persistence.
@@ -1222,7 +1324,8 @@ export async function* runToolAwareOpenAIChatStream(
export async function* runToolAwareChatCompletionsStream( export async function* runToolAwareChatCompletionsStream(
params: ToolAwareCompletionParams params: ToolAwareCompletionParams
): AsyncGenerator<ToolAwareStreamingEvent> { ): AsyncGenerator<ToolAwareStreamingEvent> {
const conversation: any[] = normalizeIncomingMessages(params.messages); const enabledTools = getEnabledChatTools(params);
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
const rawResponses: unknown[] = []; const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = []; const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -1236,7 +1339,7 @@ export async function* runToolAwareChatCompletionsStream(
messages: conversation, messages: conversation,
temperature: params.temperature, temperature: params.temperature,
max_tokens: params.maxTokens, max_tokens: params.maxTokens,
tools: CHAT_TOOLS, tools: enabledTools,
tool_choice: "auto", tool_choice: "auto",
stream: true, stream: true,
stream_options: { include_usage: true }, stream_options: { include_usage: true },
@@ -1354,3 +1457,41 @@ export async function* runToolAwareChatCompletionsStream(
}, },
}; };
} }
export async function* runPlainChatCompletionsStream(
params: ToolAwareCompletionParams
): AsyncGenerator<ToolAwareStreamingEvent> {
const rawResponses: unknown[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
let sawUsage = false;
let text = "";
const stream = await params.client.chat.completions.create({
model: params.model,
messages: normalizePlainIncomingMessages(params.messages, params.userLocation),
temperature: params.temperature,
max_tokens: params.maxTokens,
stream: true,
} as any);
for await (const chunk of stream as any as AsyncIterable<any>) {
rawResponses.push(chunk);
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
const deltaText = chunk?.choices?.[0]?.delta?.content ?? "";
if (typeof deltaText === "string" && deltaText.length) {
text += deltaText;
yield { type: "delta", text: deltaText };
}
}
yield {
type: "done",
result: {
text,
usage: sawUsage ? usageAcc : undefined,
raw: { streamed: true, responses: rawResponses, api: "chat.completions" },
toolEvents: [],
},
};
}

View File

@@ -1,5 +1,19 @@
import type { ChatAttachment, ChatImageAttachment, ChatMessage, ChatTextAttachment } from "./types.js"; import type { ChatAttachment, ChatImageAttachment, ChatMessage, ChatTextAttachment } from "./types.js";
const DEFAULT_USER_LOCATION = "San Francisco, CA";
function currentDateString(now = new Date()) {
return now.toISOString().slice(0, 10);
}
function resolveUserLocation(userLocation?: string) {
return userLocation?.trim() || process.env.SYBIL_USER_LOCATION?.trim() || DEFAULT_USER_LOCATION;
}
export function buildSystemPromptAugmentation(userLocation?: string, now = new Date()) {
return `Current date: ${currentDateString(now)}.\nUser location: ${resolveUserLocation(userLocation)}.`;
}
function escapeAttribute(value: string) { function escapeAttribute(value: string) {
return value.replace(/"/g, "&quot;"); return value.replace(/"/g, "&quot;");
} }
@@ -198,11 +212,18 @@ export function buildOpenAIResponsesInputMessage(message: ChatMessage) {
}; };
} }
export function buildSystemPromptAugmentationMessage(userLocation?: string) {
return {
role: "system",
content: buildSystemPromptAugmentation(userLocation),
};
}
const ANTHROPIC_NO_SERVER_TOOLS_PROMPT = const ANTHROPIC_NO_SERVER_TOOLS_PROMPT =
"This Anthropic backend path does not have server-managed tool calls. Do not claim to run shell commands, Codex tasks, web searches, or fetch URLs. If the user asks for tool execution, explain that they should switch to OpenAI or xAI in this app for tool-enabled chat."; "This Anthropic backend path does not have server-managed tool calls. Do not claim to run shell commands, Codex tasks, web searches, or fetch URLs. If the user asks for tool execution, explain that they should switch to OpenAI or xAI in this app for tool-enabled chat.";
export function getAnthropicSystemPrompt(messages: ChatMessage[]) { export function getAnthropicSystemPrompt(messages: ChatMessage[], userLocation?: string) {
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, messages.find((message) => message.role === "system")?.content] return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, buildSystemPromptAugmentation(userLocation), messages.find((message) => message.role === "system")?.content]
.filter(Boolean) .filter(Boolean)
.join("\n\n"); .join("\n\n");
} }

View File

@@ -1,5 +1,6 @@
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import { anthropicClient, openaiClient, xaiClient } from "./providers.js"; import { env } from "../env.js";
import { anthropicClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
import type { Provider } from "./types.js"; import type { Provider } from "./types.js";
export type ProviderModelSnapshot = { export type ProviderModelSnapshot = {
@@ -8,10 +9,11 @@ export type ProviderModelSnapshot = {
error: string | null; error: string | null;
}; };
export type ModelCatalogSnapshot = Record<Provider, ProviderModelSnapshot>; export type ModelCatalogSnapshot = Partial<Record<Provider, ProviderModelSnapshot>>;
const providers: Provider[] = ["openai", "anthropic", "xai"]; const baseProviders: Provider[] = ["openai", "anthropic", "xai"];
const MODEL_FETCH_TIMEOUT_MS = 15000; const MODEL_FETCH_TIMEOUT_MS = 15000;
const MODEL_CATALOG_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
const modelCatalog: ModelCatalogSnapshot = { const modelCatalog: ModelCatalogSnapshot = {
openai: { models: [], loadedAt: null, error: null }, openai: { models: [], loadedAt: null, error: null },
@@ -19,6 +21,12 @@ const modelCatalog: ModelCatalogSnapshot = {
xai: { models: [], loadedAt: null, error: null }, xai: { models: [], loadedAt: null, error: null },
}; };
let catalogRefreshPromise: Promise<void> | null = null;
function getCatalogProviders(): Provider[] {
return isHermesAgentConfigured() ? [...baseProviders, "hermes-agent"] : baseProviders;
}
function uniqSorted(models: string[]) { function uniqSorted(models: string[]) {
return [...new Set(models.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b)); return [...new Set(models.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
} }
@@ -59,8 +67,15 @@ async function fetchProviderModels(provider: Provider) {
return uniqSorted(page.data.map((model) => model.id)); return uniqSorted(page.data.map((model) => model.id));
} }
const page = await xaiClient().models.list(); if (provider === "xai") {
return uniqSorted(page.data.map((model) => model.id)); const page = await xaiClient().models.list();
return uniqSorted(page.data.map((model) => model.id));
}
const page = await hermesAgentClient().models.list();
const models = page.data.map((model) => model.id);
if (env.HERMES_AGENT_MODEL) models.push(env.HERMES_AGENT_MODEL);
return uniqSorted(models);
} }
async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLogger) { async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLogger) {
@@ -74,35 +89,53 @@ async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLog
logger?.info({ provider, modelCount: models.length }, "model catalog loaded"); logger?.info({ provider, modelCount: models.length }, "model catalog loaded");
} catch (err: any) { } catch (err: any) {
const message = err?.message ?? String(err); const message = err?.message ?? String(err);
const previous = modelCatalog[provider];
const fallbackModels = provider === "hermes-agent" && env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [];
modelCatalog[provider] = { modelCatalog[provider] = {
models: [], models: previous?.models.length ? previous.models : fallbackModels,
loadedAt: new Date().toISOString(), loadedAt: previous?.loadedAt ?? null,
error: message, error: message,
}; };
logger?.warn({ provider, err: message }, "failed to load provider model catalog"); logger?.warn({ provider, err: message }, "failed to load provider model catalog");
} }
} }
export async function refreshModelCatalog(logger?: FastifyBaseLogger) {
if (catalogRefreshPromise) return catalogRefreshPromise;
catalogRefreshPromise = Promise.all(getCatalogProviders().map((provider) => refreshProviderModels(provider, logger)))
.then(() => undefined)
.finally(() => {
catalogRefreshPromise = null;
});
return catalogRefreshPromise;
}
export async function warmModelCatalog(logger?: FastifyBaseLogger) { export async function warmModelCatalog(logger?: FastifyBaseLogger) {
await Promise.all(providers.map((provider) => refreshProviderModels(provider, logger))); await refreshModelCatalog(logger);
}
export function startModelCatalogRefreshLoop(logger?: FastifyBaseLogger) {
const timer = setInterval(() => {
void refreshModelCatalog(logger);
}, MODEL_CATALOG_REFRESH_INTERVAL_MS);
timer.unref?.();
return () => {
clearInterval(timer);
};
} }
export function getModelCatalogSnapshot(): ModelCatalogSnapshot { export function getModelCatalogSnapshot(): ModelCatalogSnapshot {
return { const snapshot: ModelCatalogSnapshot = {};
openai: { for (const provider of getCatalogProviders()) {
models: [...modelCatalog.openai.models], const entry = modelCatalog[provider] ?? { models: [], loadedAt: null, error: null };
loadedAt: modelCatalog.openai.loadedAt, snapshot[provider] = {
error: modelCatalog.openai.error, models: [...entry.models],
}, loadedAt: entry.loadedAt,
anthropic: { error: entry.error,
models: [...modelCatalog.anthropic.models], };
loadedAt: modelCatalog.anthropic.loadedAt, }
error: modelCatalog.anthropic.error, return snapshot;
},
xai: {
models: [...modelCatalog.xai.models],
loadedAt: modelCatalog.xai.loadedAt,
error: modelCatalog.xai.error,
},
};
} }

View File

@@ -1,13 +1,13 @@
import { performance } from "node:perf_hooks"; import { performance } from "node:perf_hooks";
import { prisma } from "../db.js"; import { prisma } from "../db.js";
import { anthropicClient, openaiClient, xaiClient } from "./providers.js"; import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
import { buildToolLogMessageData, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js"; import { buildToolLogMessageData, normalizeEnabledChatTools, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js"; import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
import { toPrismaProvider } from "./provider-ids.js";
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js"; import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
function asProviderEnum(p: Provider) { function asProviderEnum(p: Provider) {
// Prisma enum values match these strings. return toPrismaProvider(p);
return p;
} }
export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResponse> { export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResponse> {
@@ -47,13 +47,16 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
let usage: MultiplexResponse["usage"] | undefined; let usage: MultiplexResponse["usage"] | undefined;
let raw: unknown; let raw: unknown;
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = []; let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
if (req.provider === "openai") { if (req.provider === "openai" && enabledTools.length > 0) {
const client = openaiClient(); const client = openaiClient();
const r = await runToolAwareOpenAIChat({ const r = await runToolAwareOpenAIChat({
client, client,
model: req.model, model: req.model,
messages: req.messages, messages: req.messages,
enabledTools,
userLocation: req.userLocation,
temperature: req.temperature, temperature: req.temperature,
maxTokens: req.maxTokens, maxTokens: req.maxTokens,
logContext: { logContext: {
@@ -66,12 +69,14 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
outText = r.text; outText = r.text;
usage = r.usage; usage = r.usage;
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event)); toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
} else if (req.provider === "xai") { } else if (req.provider === "xai" && enabledTools.length > 0) {
const client = xaiClient(); const client = xaiClient();
const r = await runToolAwareChatCompletions({ const r = await runToolAwareChatCompletions({
client, client,
model: req.model, model: req.model,
messages: req.messages, messages: req.messages,
enabledTools,
userLocation: req.userLocation,
temperature: req.temperature, temperature: req.temperature,
maxTokens: req.maxTokens, maxTokens: req.maxTokens,
logContext: { logContext: {
@@ -84,10 +89,28 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
outText = r.text; outText = r.text;
usage = r.usage; usage = r.usage;
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event)); toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
} else if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
const r = await runPlainChatCompletions({
client,
model: req.model,
messages: req.messages,
userLocation: req.userLocation,
temperature: req.temperature,
maxTokens: req.maxTokens,
logContext: {
provider: req.provider,
model: req.model,
chatId,
},
});
raw = r.raw;
outText = r.text;
usage = r.usage;
} else if (req.provider === "anthropic") { } else if (req.provider === "anthropic") {
const client = anthropicClient(); const client = anthropicClient();
const system = getAnthropicSystemPrompt(req.messages); const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message)); const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
const r = await client.messages.create({ const r = await client.messages.create({

View File

@@ -0,0 +1,31 @@
import type { Provider } from "./types.js";
type PrismaProvider = Exclude<Provider, "hermes-agent"> | "hermes_agent";
export function toPrismaProvider(provider: Provider): PrismaProvider {
return provider === "hermes-agent" ? "hermes_agent" : provider;
}
export function fromPrismaProvider(provider: unknown): Provider | null {
if (provider === null || provider === undefined) return null;
if (provider === "hermes_agent" || provider === "hermes-agent") return "hermes-agent";
if (provider === "openai" || provider === "anthropic" || provider === "xai") return provider;
return null;
}
export function serializeProviderFields<T extends Record<string, any>>(value: T): T {
const next: Record<string, any> = { ...value };
if ("initiatedProvider" in next) {
next.initiatedProvider = fromPrismaProvider(next.initiatedProvider);
}
if ("lastUsedProvider" in next) {
next.lastUsedProvider = fromPrismaProvider(next.lastUsedProvider);
}
if ("provider" in next) {
next.provider = fromPrismaProvider(next.provider);
}
if (Array.isArray(next.calls)) {
next.calls = next.calls.map((call: Record<string, any>) => serializeProviderFields(call));
}
return next as T;
}

View File

@@ -13,6 +13,18 @@ export function xaiClient() {
return new OpenAI({ apiKey: env.XAI_API_KEY, baseURL: "https://api.x.ai/v1" }); return new OpenAI({ apiKey: env.XAI_API_KEY, baseURL: "https://api.x.ai/v1" });
} }
export function isHermesAgentConfigured() {
return Boolean(env.HERMES_AGENT_API_KEY);
}
export function hermesAgentClient() {
if (!env.HERMES_AGENT_API_KEY) throw new Error("HERMES_AGENT_API_KEY not set");
return new OpenAI({
apiKey: env.HERMES_AGENT_API_KEY,
baseURL: env.HERMES_AGENT_API_BASE_URL,
});
}
export function anthropicClient() { export function anthropicClient() {
if (!env.ANTHROPIC_API_KEY) throw new Error("ANTHROPIC_API_KEY not set"); if (!env.ANTHROPIC_API_KEY) throw new Error("ANTHROPIC_API_KEY not set");
return new Anthropic({ apiKey: env.ANTHROPIC_API_KEY }); return new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });

View File

@@ -1,13 +1,16 @@
import { performance } from "node:perf_hooks"; import { performance } from "node:perf_hooks";
import { prisma } from "../db.js"; import { prisma } from "../db.js";
import { anthropicClient, openaiClient, xaiClient } from "./providers.js"; import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
import { import {
buildToolLogMessageData, buildToolLogMessageData,
normalizeEnabledChatTools,
runPlainChatCompletionsStream,
runToolAwareChatCompletionsStream, runToolAwareChatCompletionsStream,
runToolAwareOpenAIChatStream, runToolAwareOpenAIChatStream,
type ToolExecutionEvent, type ToolExecutionEvent,
} from "./chat-tools.js"; } from "./chat-tools.js";
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js"; import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
import { toPrismaProvider } from "./provider-ids.js";
import type { MultiplexRequest, Provider } from "./types.js"; import type { MultiplexRequest, Provider } from "./types.js";
type StreamUsage = { type StreamUsage = {
@@ -38,7 +41,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
? await prisma.llmCall.create({ ? await prisma.llmCall.create({
data: { data: {
chatId, chatId,
provider: req.provider as any, provider: toPrismaProvider(req.provider) as any,
model: req.model, model: req.model,
request: req as any, request: req as any,
}, },
@@ -51,14 +54,14 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
prisma.chat.update({ prisma.chat.update({
where: { id: chatId }, where: { id: chatId },
data: { data: {
lastUsedProvider: req.provider as any, lastUsedProvider: toPrismaProvider(req.provider) as any,
lastUsedModel: req.model, lastUsedModel: req.model,
}, },
}), }),
prisma.chat.updateMany({ prisma.chat.updateMany({
where: { id: chatId, initiatedProvider: null }, where: { id: chatId, initiatedProvider: null },
data: { data: {
initiatedProvider: req.provider as any, initiatedProvider: toPrismaProvider(req.provider) as any,
initiatedModel: req.model, initiatedModel: req.model,
}, },
}), }),
@@ -72,14 +75,17 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
let raw: unknown = { streamed: true }; let raw: unknown = { streamed: true };
try { try {
if (req.provider === "openai" || req.provider === "xai") { if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
const client = req.provider === "openai" ? openaiClient() : xaiClient(); const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
const streamEvents = const streamEvents =
req.provider === "openai" req.provider === "openai" && enabledTools.length > 0
? runToolAwareOpenAIChatStream({ ? runToolAwareOpenAIChatStream({
client, client,
model: req.model, model: req.model,
messages: req.messages, messages: req.messages,
enabledTools,
userLocation: req.userLocation,
temperature: req.temperature, temperature: req.temperature,
maxTokens: req.maxTokens, maxTokens: req.maxTokens,
logContext: { logContext: {
@@ -88,10 +94,26 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
chatId: chatId ?? undefined, chatId: chatId ?? undefined,
}, },
}) })
: req.provider === "hermes-agent" || enabledTools.length === 0
? runPlainChatCompletionsStream({
client,
model: req.model,
messages: req.messages,
userLocation: req.userLocation,
temperature: req.temperature,
maxTokens: req.maxTokens,
logContext: {
provider: req.provider,
model: req.model,
chatId: chatId ?? undefined,
},
})
: runToolAwareChatCompletionsStream({ : runToolAwareChatCompletionsStream({
client, client,
model: req.model, model: req.model,
messages: req.messages, messages: req.messages,
enabledTools,
userLocation: req.userLocation,
temperature: req.temperature, temperature: req.temperature,
maxTokens: req.maxTokens, maxTokens: req.maxTokens,
logContext: { logContext: {
@@ -131,7 +153,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
} else if (req.provider === "anthropic") { } else if (req.provider === "anthropic") {
const client = anthropicClient(); const client = anthropicClient();
const system = getAnthropicSystemPrompt(req.messages); const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message)); const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
const stream = await client.messages.create({ const stream = await client.messages.create({

View File

@@ -1,4 +1,6 @@
export type Provider = "openai" | "anthropic" | "xai"; export const PROVIDERS = ["openai", "anthropic", "xai", "hermes-agent"] as const;
export type Provider = (typeof PROVIDERS)[number];
export type ChatImageAttachment = { export type ChatImageAttachment = {
kind: "image"; kind: "image";
@@ -34,6 +36,9 @@ export type MultiplexRequest = {
provider: Provider; provider: Provider;
model: string; model: string;
messages: ChatMessage[]; messages: ChatMessage[];
additionalSystemPrompt?: string;
enabledTools?: string[];
userLocation?: string;
temperature?: number; temperature?: number;
maxTokens?: number; maxTokens?: number;
}; };

View File

@@ -8,11 +8,17 @@ import { env } from "./env.js";
import { buildComparableAttachments } from "./llm/message-content.js"; import { buildComparableAttachments } from "./llm/message-content.js";
import { runMultiplex } from "./llm/multiplexer.js"; import { runMultiplex } from "./llm/multiplexer.js";
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js"; import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
import { getAvailableChatTools, normalizeEnabledChatTools } from "./llm/chat-tools.js";
import { getModelCatalogSnapshot } from "./llm/model-catalog.js"; import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
import { openaiClient } from "./llm/providers.js"; import { openaiClient } from "./llm/providers.js";
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
import { exaClient } from "./search/exa.js"; import { exaClient } from "./search/exa.js";
import type { ChatAttachment } from "./llm/types.js"; import type { ChatAttachment } from "./llm/types.js";
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000;
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
type IncomingChatMessage = { type IncomingChatMessage = {
role: "system" | "user" | "assistant" | "tool"; role: "system" | "user" | "assistant" | "tool";
content: string; content: string;
@@ -44,6 +50,43 @@ function isToolCallLogMessage(message: { role: string; metadata: unknown }) {
return message.role === "tool" && isToolCallLogMetadata(message.metadata); return message.role === "tool" && isToolCallLogMetadata(message.metadata);
} }
function getHeaderString(req: FastifyRequest, name: string) {
const value = req.headers[name.toLowerCase()];
if (Array.isArray(value)) return value.find((item) => item.trim());
return typeof value === "string" && value.trim() ? value : undefined;
}
function decodeHeaderPart(value: string | undefined) {
if (!value) return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
try {
return decodeURIComponent(trimmed);
} catch {
return trimmed;
}
}
function inferRequestUserLocation(req: FastifyRequest) {
const explicit = decodeHeaderPart(getHeaderString(req, "x-user-location"));
if (explicit) return explicit;
const vercelCity = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-city"));
const vercelRegion = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-country-region"));
const vercelCountry = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-country"));
const vercelLocation = [vercelCity, vercelRegion, vercelCountry].filter(Boolean).join(", ");
if (vercelLocation) return vercelLocation;
const cfCity = decodeHeaderPart(getHeaderString(req, "cf-ipcity"));
const cfRegion = decodeHeaderPart(getHeaderString(req, "cf-region"));
const cfCountry = decodeHeaderPart(getHeaderString(req, "cf-ipcountry"));
return [cfCity, cfRegion, cfCountry].filter(Boolean).join(", ") || undefined;
}
function withRequestUserLocation<T extends { userLocation?: string }>(body: T, req: FastifyRequest): T {
return body.userLocation ? body : { ...body, userLocation: inferRequestUserLocation(req) };
}
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) { async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
const incoming = messages.filter((m) => m.role !== "assistant"); const incoming = messages.filter((m) => m.role !== "assistant");
if (!incoming.length) return; if (!incoming.length) return;
@@ -125,9 +168,12 @@ const CompletionStreamBody = z
.object({ .object({
chatId: z.string().optional(), chatId: z.string().optional(),
persist: z.boolean().optional(), persist: z.boolean().optional(),
provider: z.enum(["openai", "anthropic", "xai"]), provider: ProviderSchema,
model: z.string().min(1), model: z.string().min(1),
messages: z.array(CompletionMessageSchema), messages: z.array(CompletionMessageSchema),
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
enabledTools: EnabledToolsSchema.optional(),
userLocation: z.string().trim().min(1).max(200).optional(),
temperature: z.number().min(0).max(2).optional(), temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().int().positive().optional(), maxTokens: z.number().int().positive().optional(),
}) })
@@ -152,6 +198,41 @@ function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttac
}; };
} }
function normalizeAdditionalSystemPrompt(value: string | null | undefined) {
const trimmed = value?.trim();
return trimmed || null;
}
function prependAdditionalSystemPrompt<T extends { messages: IncomingChatMessage[]; additionalSystemPrompt?: string | null }>(body: T): T {
const additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
if (!additionalSystemPrompt) return { ...body, additionalSystemPrompt: undefined };
return {
...body,
additionalSystemPrompt,
messages: [{ role: "system", content: additionalSystemPrompt }, ...body.messages],
};
}
async function applyStoredChatSettings<T extends { chatId?: string; messages: IncomingChatMessage[]; additionalSystemPrompt?: string; enabledTools?: string[] }>(
body: T
) {
if (!body.chatId || (body.additionalSystemPrompt !== undefined && body.enabledTools !== undefined)) {
return prependAdditionalSystemPrompt(body);
}
const chat = await prisma.chat.findUnique({
where: { id: body.chatId },
select: { additionalSystemPrompt: true, enabledTools: true },
});
if (!chat) return prependAdditionalSystemPrompt(body);
return prependAdditionalSystemPrompt({
...body,
additionalSystemPrompt: body.additionalSystemPrompt ?? chat.additionalSystemPrompt ?? undefined,
enabledTools: body.enabledTools ?? normalizeEnabledChatTools(chat.enabledTools),
});
}
const SearchRunBody = z.object({ const SearchRunBody = z.object({
query: z.string().trim().min(1).optional(), query: z.string().trim().min(1).optional(),
title: z.string().trim().min(1).optional(), title: z.string().trim().min(1).optional(),
@@ -323,6 +404,41 @@ function getErrorMessage(err: unknown) {
return err instanceof Error ? err.message : String(err); return err instanceof Error ? err.message : String(err);
} }
function compareUpdatedAtDesc(a: { updatedAt: Date | string }, b: { updatedAt: Date | string }) {
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
}
async function listWorkspaceItems() {
const [chats, searches] = await Promise.all([
prisma.chat.findMany({
orderBy: { updatedAt: "desc" },
take: 100,
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
},
}),
prisma.search.findMany({
orderBy: { updatedAt: "desc" },
take: 100,
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
}),
]);
return [
...chats.map((chat) => ({ type: "chat" as const, ...serializeProviderFields(chat) })),
...searches.map((search) => ({ type: "search" as const, ...search })),
].sort(compareUpdatedAtDesc);
}
function writeSseEvent(reply: FastifyReply, event: SseStreamEvent) { function writeSseEvent(reply: FastifyReply, event: SseStreamEvent) {
if (reply.raw.destroyed || reply.raw.writableEnded) return; if (reply.raw.destroyed || reply.raw.writableEnded) return;
reply.raw.write(`event: ${event.event}\n`); reply.raw.write(`event: ${event.event}\n`);
@@ -567,6 +683,11 @@ export async function registerRoutes(app: FastifyInstance) {
return { providers: getModelCatalogSnapshot() }; return { providers: getModelCatalogSnapshot() };
}); });
app.get("/v1/chat-tools", async (req) => {
requireAdmin(req);
return { tools: getAvailableChatTools() };
});
app.get("/v1/active-runs", async (req) => { app.get("/v1/active-runs", async (req) => {
requireAdmin(req); requireAdmin(req);
return { return {
@@ -575,6 +696,11 @@ export async function registerRoutes(app: FastifyInstance) {
}; };
}); });
app.get("/v1/workspace-items", async (req) => {
requireAdmin(req);
return { items: await listWorkspaceItems() };
});
app.get("/v1/chats", async (req) => { app.get("/v1/chats", async (req) => {
requireAdmin(req); requireAdmin(req);
const chats = await prisma.chat.findMany({ const chats = await prisma.chat.findMany({
@@ -589,9 +715,11 @@ export async function registerRoutes(app: FastifyInstance) {
initiatedModel: true, initiatedModel: true,
lastUsedProvider: true, lastUsedProvider: true,
lastUsedModel: true, lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
}, },
}); });
return { chats }; return { chats: chats.map((chat) => serializeProviderFields(chat)) };
}); });
app.post("/v1/chats", async (req) => { app.post("/v1/chats", async (req) => {
@@ -599,8 +727,10 @@ export async function registerRoutes(app: FastifyInstance) {
const Body = z const Body = z
.object({ .object({
title: z.string().optional(), title: z.string().optional(),
provider: z.enum(["openai", "anthropic", "xai"]).optional(), provider: ProviderSchema.optional(),
model: z.string().trim().min(1).optional(), model: z.string().trim().min(1).optional(),
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
enabledTools: EnabledToolsSchema.optional(),
messages: z.array(CompletionMessageSchema).optional(), messages: z.array(CompletionMessageSchema).optional(),
}) })
.superRefine((value, ctx) => { .superRefine((value, ctx) => {
@@ -625,10 +755,12 @@ export async function registerRoutes(app: FastifyInstance) {
const chat = await prisma.chat.create({ const chat = await prisma.chat.create({
data: { data: {
title: body.title, title: body.title,
initiatedProvider: body.provider as any, initiatedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
initiatedModel: body.model, initiatedModel: body.model,
lastUsedProvider: body.provider as any, lastUsedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
lastUsedModel: body.model, lastUsedModel: body.model,
additionalSystemPrompt: normalizeAdditionalSystemPrompt(body.additionalSystemPrompt),
enabledTools: body.enabledTools as any,
messages: body.messages?.length messages: body.messages?.length
? { ? {
create: body.messages.map((message) => ({ create: body.messages.map((message) => ({
@@ -649,21 +781,32 @@ export async function registerRoutes(app: FastifyInstance) {
initiatedModel: true, initiatedModel: true,
lastUsedProvider: true, lastUsedProvider: true,
lastUsedModel: true, lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
}, },
}); });
return { chat }; return { chat: serializeProviderFields(chat) };
}); });
app.patch("/v1/chats/:chatId", async (req) => { app.patch("/v1/chats/:chatId", async (req) => {
requireAdmin(req); requireAdmin(req);
const Params = z.object({ chatId: z.string() }); const Params = z.object({ chatId: z.string() });
const Body = z.object({ title: z.string().trim().min(1) }); const Body = z.object({
title: z.string().trim().min(1).optional(),
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).nullable().optional(),
enabledTools: EnabledToolsSchema.optional(),
});
const { chatId } = Params.parse(req.params); const { chatId } = Params.parse(req.params);
const body = Body.parse(req.body ?? {}); const body = Body.parse(req.body ?? {});
const data: Record<string, unknown> = {};
if (body.title !== undefined) data.title = body.title;
if (body.additionalSystemPrompt !== undefined) data.additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
if (body.enabledTools !== undefined) data.enabledTools = body.enabledTools;
const updated = await prisma.chat.updateMany({ const updated = await prisma.chat.updateMany({
where: { id: chatId }, where: { id: chatId },
data: { title: body.title }, data: data as any,
}); });
if (updated.count === 0) return app.httpErrors.notFound("chat not found"); if (updated.count === 0) return app.httpErrors.notFound("chat not found");
@@ -679,10 +822,12 @@ export async function registerRoutes(app: FastifyInstance) {
initiatedModel: true, initiatedModel: true,
lastUsedProvider: true, lastUsedProvider: true,
lastUsedModel: true, lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
}, },
}); });
if (!chat) return app.httpErrors.notFound("chat not found"); if (!chat) return app.httpErrors.notFound("chat not found");
return { chat }; return { chat: serializeProviderFields(chat) };
}); });
app.post("/v1/chats/title/suggest", async (req) => { app.post("/v1/chats/title/suggest", async (req) => {
@@ -704,10 +849,12 @@ export async function registerRoutes(app: FastifyInstance) {
initiatedModel: true, initiatedModel: true,
lastUsedProvider: true, lastUsedProvider: true,
lastUsedModel: true, lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
}, },
}); });
if (!existing) return app.httpErrors.notFound("chat not found"); if (!existing) return app.httpErrors.notFound("chat not found");
if (existing.title?.trim()) return { chat: existing }; if (existing.title?.trim()) return { chat: serializeProviderFields(existing) };
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat"; const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
const suggestedRaw = await generateChatTitle(body.content); const suggestedRaw = await generateChatTitle(body.content);
@@ -725,10 +872,12 @@ export async function registerRoutes(app: FastifyInstance) {
initiatedModel: true, initiatedModel: true,
lastUsedProvider: true, lastUsedProvider: true,
lastUsedModel: true, lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
}, },
}); });
return { chat }; return { chat: serializeProviderFields(chat) };
}); });
app.delete("/v1/chats/:chatId", async (req) => { app.delete("/v1/chats/:chatId", async (req) => {
@@ -845,10 +994,12 @@ export async function registerRoutes(app: FastifyInstance) {
initiatedModel: true, initiatedModel: true,
lastUsedProvider: true, lastUsedProvider: true,
lastUsedModel: true, lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
}, },
}); });
return { chat }; return { chat: serializeProviderFields(chat) };
}); });
app.post("/v1/searches/:searchId/run", async (req) => { app.post("/v1/searches/:searchId/run", async (req) => {
@@ -994,7 +1145,7 @@ export async function registerRoutes(app: FastifyInstance) {
include: { messages: { orderBy: { createdAt: "asc" } }, calls: { orderBy: { createdAt: "desc" } } }, include: { messages: { orderBy: { createdAt: "asc" } }, calls: { orderBy: { createdAt: "desc" } } },
}); });
if (!chat) return app.httpErrors.notFound("chat not found"); if (!chat) return app.httpErrors.notFound("chat not found");
return { chat }; return { chat: serializeProviderFields(chat) };
}); });
app.post("/v1/chats/:chatId/messages", async (req) => { app.post("/v1/chats/:chatId/messages", async (req) => {
@@ -1041,16 +1192,19 @@ export async function registerRoutes(app: FastifyInstance) {
const Body = z.object({ const Body = z.object({
chatId: z.string().optional(), chatId: z.string().optional(),
provider: z.enum(["openai", "anthropic", "xai"]), provider: ProviderSchema,
model: z.string().min(1), model: z.string().min(1),
messages: z.array(CompletionMessageSchema), messages: z.array(CompletionMessageSchema),
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
enabledTools: EnabledToolsSchema.optional(),
userLocation: z.string().trim().min(1).max(200).optional(),
temperature: z.number().min(0).max(2).optional(), temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().int().positive().optional(), maxTokens: z.number().int().positive().optional(),
}); });
const parsed = Body.safeParse(req.body); const parsed = Body.safeParse(req.body);
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message); if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = parsed.data; const body = withRequestUserLocation(parsed.data, req);
// ensure chat exists if provided // ensure chat exists if provided
if (body.chatId) { if (body.chatId) {
@@ -1063,7 +1217,7 @@ export async function registerRoutes(app: FastifyInstance) {
await storeNonAssistantMessages(body.chatId, body.messages); await storeNonAssistantMessages(body.chatId, body.messages);
} }
const result = await runMultiplex(body); const result = await runMultiplex(await applyStoredChatSettings(body));
return { return {
chatId: body.chatId ?? null, chatId: body.chatId ?? null,
@@ -1077,7 +1231,7 @@ export async function registerRoutes(app: FastifyInstance) {
const parsed = CompletionStreamBody.safeParse(req.body); const parsed = CompletionStreamBody.safeParse(req.body);
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message); if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = parsed.data; const body = withRequestUserLocation(parsed.data, req);
// ensure chat exists if provided // ensure chat exists if provided
if (body.chatId) { if (body.chatId) {
@@ -1094,14 +1248,14 @@ export async function registerRoutes(app: FastifyInstance) {
if (activeChatStreams.has(body.chatId)) { if (activeChatStreams.has(body.chatId)) {
return app.httpErrors.conflict("chat completion already running"); return app.httpErrors.conflict("chat completion already running");
} }
const stream = startActiveChatStream(body.chatId, body); const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body));
return streamActiveRun(req, reply, stream); return streamActiveRun(req, reply, stream);
} }
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined)); reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
reply.raw.flushHeaders(); reply.raw.flushHeaders();
for await (const ev of runMultiplexStream(body)) { for await (const ev of runMultiplexStream(await applyStoredChatSettings(body))) {
writeSseEvent(reply, mapChatStreamEvent(ev)); writeSseEvent(reply, mapChatStreamEvent(ev));
} }

View File

@@ -1,6 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { import {
runPlainChatCompletionsStream,
runToolAwareChatCompletionsStream, runToolAwareChatCompletionsStream,
runToolAwareOpenAIChatStream, runToolAwareOpenAIChatStream,
type ToolAwareStreamingEvent, type ToolAwareStreamingEvent,
@@ -105,3 +106,37 @@ test("OpenAI-compatible Chat Completions stream emits text deltas as they arrive
); );
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hello"); assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hello");
}); });
test("plain Chat Completions stream does not send Sybil-managed tools", async () => {
let requestBody: any = null;
const client = {
chat: {
completions: {
create: async (body: any) => {
requestBody = body;
return streamFrom([
{ choices: [{ delta: { content: "Hi" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
]);
},
},
},
};
const events = await collectEvents(
runPlainChatCompletionsStream({
client: client as any,
model: "hermes-agent",
messages: [{ role: "user", content: "Say hi" }],
})
);
assert.equal(requestBody.model, "hermes-agent");
assert.equal(requestBody.stream, true);
assert.equal("tools" in requestBody, false);
assert.deepEqual(
events.map((event) => event.type),
["delta", "done"]
);
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
});

View File

@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildSystemPromptAugmentation, getAnthropicSystemPrompt } from "../src/llm/message-content.js";
test("system prompt augmentation includes date and default location", () => {
const prompt = buildSystemPromptAugmentation(undefined, new Date("2026-05-24T15:30:00Z"));
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: San Francisco, CA.");
});
test("system prompt augmentation uses provided user location", () => {
const prompt = buildSystemPromptAugmentation("New York, NY", new Date("2026-05-24T15:30:00Z"));
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: New York, NY.");
});
test("Anthropic system prompt includes runtime context with existing system messages", () => {
const prompt = getAnthropicSystemPrompt(
[{ role: "system", content: "Use concise answers." }],
"Los Angeles, CA"
);
assert.match(prompt, /Current date: \d{4}-\d{2}-\d{2}\./);
assert.match(prompt, /User location: Los Angeles, CA\./);
assert.match(prompt, /Use concise answers\./);
});

View File

@@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
import { fromPrismaProvider, serializeProviderFields, toPrismaProvider } from "../src/llm/provider-ids.js";
test("Hermes Agent provider id maps between API and Prisma enum forms", () => {
assert.equal(toPrismaProvider("hermes-agent"), "hermes_agent");
assert.equal(fromPrismaProvider("hermes_agent"), "hermes-agent");
assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "xai" }), {
initiatedProvider: "hermes-agent",
lastUsedProvider: "xai",
});
});

View File

@@ -23,7 +23,7 @@ Configuration is environment-only (no in-app settings).
- `SYBIL_TUI_API_BASE_URL`: API base URL. Default: `http://127.0.0.1:8787` - `SYBIL_TUI_API_BASE_URL`: API base URL. Default: `http://127.0.0.1:8787`
- `SYBIL_TUI_ADMIN_TOKEN`: optional bearer token for token-mode servers - `SYBIL_TUI_ADMIN_TOKEN`: optional bearer token for token-mode servers
- `SYBIL_TUI_DEFAULT_PROVIDER`: `openai` | `anthropic` | `xai` (default: `openai`) - `SYBIL_TUI_DEFAULT_PROVIDER`: `openai` | `anthropic` | `xai` | `hermes-agent` (default: `openai`)
- `SYBIL_TUI_DEFAULT_MODEL`: optional default model name - `SYBIL_TUI_DEFAULT_MODEL`: optional default model name
- `SYBIL_TUI_SEARCH_NUM_RESULTS`: results per search run (default: `10`) - `SYBIL_TUI_SEARCH_NUM_RESULTS`: results per search run (default: `10`)

View File

@@ -10,6 +10,7 @@ import type {
SearchStreamHandlers, SearchStreamHandlers,
SearchSummary, SearchSummary,
SessionStatus, SessionStatus,
WorkspaceItem,
} from "./types.js"; } from "./types.js";
type RequestOptions = { type RequestOptions = {
@@ -41,6 +42,11 @@ export class SybilApiClient {
return data.chats; return data.chats;
} }
async listWorkspaceItems() {
const data = await this.request<{ items: WorkspaceItem[] }>("/v1/workspace-items");
return data.items;
}
async createChat(title?: string) { async createChat(title?: string) {
const data = await this.request<{ chat: ChatSummary }>("/v1/chats", { const data = await this.request<{ chat: ChatSummary }>("/v1/chats", {
method: "POST", method: "POST",
@@ -94,6 +100,7 @@ export class SybilApiClient {
provider: Provider; provider: Provider;
model: string; model: string;
messages: CompletionRequestMessage[]; messages: CompletionRequestMessage[];
userLocation?: string;
}, },
handlers: CompletionStreamHandlers, handlers: CompletionStreamHandlers,
options?: { signal?: AbortSignal } options?: { signal?: AbortSignal }

View File

@@ -1,6 +1,6 @@
import type { Provider } from "./types.js"; import type { Provider } from "./types.js";
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai"]; const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "hermes-agent"];
function normalizeBaseUrl(value: string) { function normalizeBaseUrl(value: string) {
const trimmed = value.trim(); const trimmed = value.trim();

View File

@@ -11,6 +11,7 @@ import type {
SearchDetail, SearchDetail,
SearchSummary, SearchSummary,
ToolCallEvent, ToolCallEvent,
WorkspaceItem,
} from "./types.js"; } from "./types.js";
type SidebarSelection = { kind: "chat" | "search"; id: string }; type SidebarSelection = { kind: "chat" | "search"; id: string };
@@ -39,11 +40,13 @@ type ToolLogMetadata = {
resultPreview?: string | null; resultPreview?: string | null;
}; };
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai"]; const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
const PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = { const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
openai: ["gpt-4.1-mini"], openai: ["gpt-4.1-mini"],
anthropic: ["claude-3-5-sonnet-latest"], anthropic: ["claude-3-5-sonnet-latest"],
xai: ["grok-3-mini"], xai: ["grok-3-mini"],
"hermes-agent": ["hermes-agent"],
}; };
const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = { const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
@@ -74,6 +77,7 @@ function getProviderLabel(provider: Provider | null | undefined) {
if (provider === "openai") return "OpenAI"; if (provider === "openai") return "OpenAI";
if (provider === "anthropic") return "Anthropic"; if (provider === "anthropic") return "Anthropic";
if (provider === "xai") return "xAI"; if (provider === "xai") return "xAI";
if (provider === "hermes-agent") return "Hermes Agent";
return ""; return "";
} }
@@ -90,9 +94,38 @@ function getSearchTitle(search: Pick<SearchSummary, "title" | "query">) {
return "New search"; return "New search";
} }
function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] { function chatWorkspaceItem(chat: ChatSummary): WorkspaceItem {
const items: SidebarItem[] = [ return { type: "chat", ...chat };
...chats.map((chat) => ({ }
function searchWorkspaceItem(search: SearchSummary): WorkspaceItem {
return { type: "search", ...search };
}
function splitWorkspaceItems(items: WorkspaceItem[]) {
const chats: ChatSummary[] = [];
const searches: SearchSummary[] = [];
for (const item of items) {
if (item.type === "chat") {
const { type: _type, ...chat } = item;
chats.push(chat);
} else {
const { type: _type, ...search } = item;
searches.push(search);
}
}
return { chats, searches };
}
function upsertWorkspaceItem(items: WorkspaceItem[], item: WorkspaceItem) {
return [item, ...items.filter((existing) => existing.type !== item.type || existing.id !== item.id)];
}
function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
return items.map((item) => {
if (item.type === "chat") {
const chat = item;
return {
kind: "chat" as const, kind: "chat" as const,
id: chat.id, id: chat.id,
title: getChatTitle(chat), title: getChatTitle(chat),
@@ -102,8 +135,11 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
initiatedModel: chat.initiatedModel, initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider, lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel, lastUsedModel: chat.lastUsedModel,
})), };
...searches.map((search) => ({ }
const search = item;
return {
kind: "search" as const, kind: "search" as const,
id: search.id, id: search.id,
title: getSearchTitle(search), title: getSearchTitle(search),
@@ -113,10 +149,8 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
initiatedModel: null, initiatedModel: null,
lastUsedProvider: null, lastUsedProvider: null,
lastUsedModel: null, lastUsedModel: null,
})), };
]; });
return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
} }
function asToolLogMetadata(value: unknown): ToolLogMetadata | null { function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
@@ -159,6 +193,10 @@ function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: P
return PROVIDER_FALLBACK_MODELS[provider]; return PROVIDER_FALLBACK_MODELS[provider];
} }
function getVisibleProviders(catalog: ModelCatalogResponse["providers"]) {
return PROVIDERS.filter((provider) => provider !== "hermes-agent" || catalog[provider] !== undefined);
}
function pickProviderModel(options: string[], preferred: string | null, fallback: string | null = null) { function pickProviderModel(options: string[], preferred: string | null, fallback: string | null = null) {
if (fallback && options.includes(fallback)) return fallback; if (fallback && options.includes(fallback)) return fallback;
if (preferred && options.includes(preferred)) return preferred; if (preferred && options.includes(preferred)) return preferred;
@@ -188,6 +226,7 @@ async function main() {
let authMode: "open" | "token" | null = null; let authMode: "open" | "token" | null = null;
let chats: ChatSummary[] = []; let chats: ChatSummary[] = [];
let searches: SearchSummary[] = []; let searches: SearchSummary[] = [];
let workspaceItems: WorkspaceItem[] = [];
let selectedItem: SidebarSelection | null = null; let selectedItem: SidebarSelection | null = null;
let selectedChat: ChatDetail | null = null; let selectedChat: ChatDetail | null = null;
let selectedSearch: SearchDetail | null = null; let selectedSearch: SearchDetail | null = null;
@@ -202,6 +241,7 @@ async function main() {
openai: null, openai: null,
anthropic: null, anthropic: null,
xai: null, xai: null,
"hermes-agent": null,
}; };
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null); let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
let errorMessage: string | null = null; let errorMessage: string | null = null;
@@ -369,7 +409,7 @@ async function main() {
} }
function getSidebarItems() { function getSidebarItems() {
return buildSidebarItems(chats, searches); return buildSidebarItems(workspaceItems);
} }
function getSelectedChatSummary() { function getSelectedChatSummary() {
@@ -693,6 +733,7 @@ async function main() {
function resetWorkspaceState() { function resetWorkspaceState() {
chats = []; chats = [];
searches = []; searches = [];
workspaceItems = [];
selectedItem = null; selectedItem = null;
selectedChat = null; selectedChat = null;
selectedSearch = null; selectedSearch = null;
@@ -759,11 +800,13 @@ async function main() {
updateUI(); updateUI();
try { try {
const [nextChats, nextSearches] = await Promise.all([api.listChats(), api.listSearches()]); const nextWorkspaceItems = await api.listWorkspaceItems();
const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems);
workspaceItems = nextWorkspaceItems;
chats = nextChats; chats = nextChats;
searches = nextSearches; searches = nextSearches;
const nextItems = buildSidebarItems(nextChats, nextSearches); const nextItems = buildSidebarItems(nextWorkspaceItems);
if (options?.preferredSelection && hasItem(nextItems, options.preferredSelection)) { if (options?.preferredSelection && hasItem(nextItems, options.preferredSelection)) {
selectedItem = options.preferredSelection; selectedItem = options.preferredSelection;
draftKind = null; draftKind = null;
@@ -868,6 +911,7 @@ async function main() {
try { try {
const updated = await api.suggestChatTitle({ chatId, content }); const updated = await api.suggestChatTitle({ chatId, content });
chats = chats.map((chat) => (chat.id === updated.id ? { ...chat, title: updated.title, updatedAt: updated.updatedAt } : chat)); chats = chats.map((chat) => (chat.id === updated.id ? { ...chat, title: updated.title, updatedAt: updated.updatedAt } : chat));
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
if (selectedChat?.id === updated.id) { if (selectedChat?.id === updated.id) {
selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt }; selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt };
} }
@@ -912,6 +956,7 @@ async function main() {
chatId = chat.id; chatId = chat.id;
draftKind = null; draftKind = null;
chats = [chat, ...chats.filter((existing) => existing.id !== chat.id)]; chats = [chat, ...chats.filter((existing) => existing.id !== chat.id)];
workspaceItems = upsertWorkspaceItem(workspaceItems, chatWorkspaceItem(chat));
selectedItem = { kind: "chat", id: chat.id }; selectedItem = { kind: "chat", id: chat.id };
pendingChatState = pendingChatState ? { ...pendingChatState, chatId } : pendingChatState; pendingChatState = pendingChatState ? { ...pendingChatState, chatId } : pendingChatState;
selectedChat = { selectedChat = {
@@ -1077,6 +1122,7 @@ async function main() {
draftKind = null; draftKind = null;
selectedItem = { kind: "search", id: searchId }; selectedItem = { kind: "search", id: searchId };
searches = [search, ...searches.filter((existing) => existing.id !== search.id)]; searches = [search, ...searches.filter((existing) => existing.id !== search.id)];
workspaceItems = upsertWorkspaceItem(workspaceItems, searchWorkspaceItem(search));
selectedChat = null; selectedChat = null;
forceScrollToBottom = true; forceScrollToBottom = true;
updateUI(); updateUI();
@@ -1257,8 +1303,10 @@ async function main() {
} }
function cycleProvider() { function cycleProvider() {
const currentIndex = PROVIDERS.indexOf(provider); const visibleProviders = getVisibleProviders(modelCatalog);
const nextProvider: Provider = PROVIDERS[(currentIndex + 1) % PROVIDERS.length] ?? "openai"; const cycleProviders = visibleProviders.length ? visibleProviders : BASE_PROVIDERS;
const currentIndex = Math.max(0, cycleProviders.indexOf(provider));
const nextProvider: Provider = cycleProviders[(currentIndex + 1) % cycleProviders.length] ?? "openai";
provider = nextProvider; provider = nextProvider;
syncModelForProvider(); syncModelForProvider();
updateUI(); updateUI();

View File

@@ -1,4 +1,4 @@
export type Provider = "openai" | "anthropic" | "xai"; export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
export type ProviderModelInfo = { export type ProviderModelInfo = {
models: string[]; models: string[];
@@ -7,7 +7,7 @@ export type ProviderModelInfo = {
}; };
export type ModelCatalogResponse = { export type ModelCatalogResponse = {
providers: Record<Provider, ProviderModelInfo>; providers: Partial<Record<Provider, ProviderModelInfo>>;
}; };
export type ChatSummary = { export type ChatSummary = {
@@ -29,6 +29,16 @@ export type SearchSummary = {
updatedAt: string; updatedAt: string;
}; };
export type ChatWorkspaceItem = ChatSummary & {
type: "chat";
};
export type SearchWorkspaceItem = SearchSummary & {
type: "search";
};
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem;
export type Message = { export type Message = {
id: string; id: string;
createdAt: string; createdAt: string;

View File

@@ -1,5 +1,20 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { Check, ChevronDown, Globe2, LoaderCircle, Menu, MessageSquare, Paperclip, Plus, Rabbit, Search, SendHorizontal, Trash2, X } from "lucide-preact"; import {
Check,
ChevronDown,
Globe2,
LoaderCircle,
Menu,
MessageSquare,
Paperclip,
Plus,
Rabbit,
Search,
SendHorizontal,
Settings2,
Trash2,
X,
} from "lucide-preact";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@@ -18,13 +33,14 @@ import {
attachSearchStream, attachSearchStream,
getActiveRuns, getActiveRuns,
getChat, getChat,
listChatTools,
listModels, listModels,
getSearch, getSearch,
listChats, listWorkspaceItems,
listSearches,
runCompletionStream, runCompletionStream,
runSearchStream, runSearchStream,
suggestChatTitle, suggestChatTitle,
updateChatSettings,
getMessageAttachments, getMessageAttachments,
type ChatAttachment, type ChatAttachment,
type ActiveRunsResponse, type ActiveRunsResponse,
@@ -32,11 +48,13 @@ import {
type Provider, type Provider,
type ChatDetail, type ChatDetail,
type ChatSummary, type ChatSummary,
type ChatToolInfo,
type CompletionRequestMessage, type CompletionRequestMessage,
type Message, type Message,
type SearchDetail, type SearchDetail,
type SearchSummary, type SearchSummary,
type ToolCallEvent, type ToolCallEvent,
type WorkspaceItem,
} from "@/lib/api"; } from "@/lib/api";
import { useSessionAuth } from "@/hooks/use-session-auth"; import { useSessionAuth } from "@/hooks/use-session-auth";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -95,6 +113,7 @@ const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
openai: ["gpt-4.1-mini"], openai: ["gpt-4.1-mini"],
anthropic: ["claude-3-5-sonnet-latest"], anthropic: ["claude-3-5-sonnet-latest"],
xai: ["grok-3-mini"], xai: ["grok-3-mini"],
"hermes-agent": ["hermes-agent"],
}; };
const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = { const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
@@ -103,6 +122,9 @@ const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
xai: { models: [], loadedAt: null, error: null }, xai: { models: [], loadedAt: null, error: null },
}; };
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
const ALL_PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider"; const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider";
const QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection"; const QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection";
@@ -117,6 +139,7 @@ const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = {
openai: null, openai: null,
anthropic: null, anthropic: null,
xai: null, xai: null,
"hermes-agent": null,
}; };
const EMPTY_ACTIVE_RUNS: ActiveRunsState = { const EMPTY_ACTIVE_RUNS: ActiveRunsState = {
chats: {}, chats: {},
@@ -193,6 +216,10 @@ function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: P
return PROVIDER_FALLBACK_MODELS[provider]; return PROVIDER_FALLBACK_MODELS[provider];
} }
function getVisibleProviders(catalog: ModelCatalogResponse["providers"]) {
return ALL_PROVIDERS.filter((provider) => provider !== "hermes-agent" || catalog[provider] !== undefined);
}
function getReplyScrollBufferHeight() { function getReplyScrollBufferHeight() {
if (typeof window === "undefined") return REPLY_SCROLL_BUFFER_MIN; if (typeof window === "undefined") return REPLY_SCROLL_BUFFER_MIN;
return Math.min( return Math.min(
@@ -308,6 +335,8 @@ function loadStoredModelPreferences() {
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null, openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null,
anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null, anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null,
xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null, xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null,
"hermes-agent":
typeof parsed["hermes-agent"] === "string" && parsed["hermes-agent"].trim() ? parsed["hermes-agent"].trim() : null,
}; };
} catch { } catch {
return EMPTY_MODEL_PREFERENCES; return EMPTY_MODEL_PREFERENCES;
@@ -315,17 +344,19 @@ function loadStoredModelPreferences() {
} }
function normalizeStoredProvider(value: unknown): Provider { function normalizeStoredProvider(value: unknown): Provider {
return value === "anthropic" || value === "xai" || value === "openai" ? value : "openai"; return value === "anthropic" || value === "xai" || value === "openai" || value === "hermes-agent" ? value : "openai";
} }
function normalizeStoredModelPreferences(value: unknown): ProviderModelPreferences { function normalizeStoredModelPreferences(value: unknown): ProviderModelPreferences {
if (!value || typeof value !== "object" || Array.isArray(value)) return EMPTY_MODEL_PREFERENCES; if (!value || typeof value !== "object" || Array.isArray(value)) return EMPTY_MODEL_PREFERENCES;
const parsed = value as Partial<Record<Provider, unknown>>; const parsed = value as Partial<Record<Provider, unknown>>;
return { return {
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null, openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null,
anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null, anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null,
xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null, xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null,
}; "hermes-agent":
typeof parsed["hermes-agent"] === "string" && parsed["hermes-agent"].trim() ? parsed["hermes-agent"].trim() : null,
};
} }
function loadStoredQuickQuestionModelSelection(): QuickQuestionModelSelection { function loadStoredQuickQuestionModelSelection(): QuickQuestionModelSelection {
@@ -354,9 +385,34 @@ function getProviderLabel(provider: Provider | null | undefined) {
if (provider === "openai") return "OpenAI"; if (provider === "openai") return "OpenAI";
if (provider === "anthropic") return "Anthropic"; if (provider === "anthropic") return "Anthropic";
if (provider === "xai") return "xAI"; if (provider === "xai") return "xAI";
if (provider === "hermes-agent") return "Hermes Agent";
return ""; return "";
} }
function getToolLabel(name: string) {
if (name === "web_search") return "Web search";
if (name === "fetch_url") return "Fetch URL";
if (name === "codex_exec") return "Codex";
if (name === "shell_exec") return "Shell";
return name
.split("_")
.filter(Boolean)
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
.join(" ");
}
function getDefaultEnabledTools(availableTools: ChatToolInfo[]) {
return availableTools.map((tool) => tool.name);
}
function normalizeEnabledTools(value: unknown, availableTools: ChatToolInfo[]) {
const available = new Set(availableTools.map((tool) => tool.name));
if (!Array.isArray(value)) return getDefaultEnabledTools(availableTools);
return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))].filter((name) =>
available.has(name)
);
}
function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "lastUsedModel"> | Pick<ChatDetail, "lastUsedProvider" | "lastUsedModel"> | null) { function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "lastUsedModel"> | Pick<ChatDetail, "lastUsedProvider" | "lastUsedModel"> | null) {
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null; if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
return { return {
@@ -574,20 +630,48 @@ function getSearchTitle(search: Pick<SearchSummary, "title" | "query">) {
return "New search"; return "New search";
} }
function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] { function chatWorkspaceItem(chat: ChatSummary): WorkspaceItem {
const items: SidebarItem[] = [ return { type: "chat", ...chat };
...chats.map((chat) => ({ }
kind: "chat" as const,
id: chat.id, function searchWorkspaceItem(search: SearchSummary): WorkspaceItem {
title: getChatTitle(chat), return { type: "search", ...search };
updatedAt: chat.updatedAt, }
createdAt: chat.createdAt,
initiatedProvider: chat.initiatedProvider, function splitWorkspaceItems(items: WorkspaceItem[]) {
initiatedModel: chat.initiatedModel, const chats: ChatSummary[] = [];
lastUsedProvider: chat.lastUsedProvider, const searches: SearchSummary[] = [];
lastUsedModel: chat.lastUsedModel, for (const item of items) {
})), if (item.type === "chat") {
...searches.map((search) => ({ const { type: _type, ...chat } = item;
chats.push(chat);
} else {
const { type: _type, ...search } = item;
searches.push(search);
}
}
return { chats, searches };
}
function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
return items.map((item) => {
if (item.type === "chat") {
const chat = item;
return {
kind: "chat" as const,
id: chat.id,
title: getChatTitle(chat),
updatedAt: chat.updatedAt,
createdAt: chat.createdAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel,
};
}
const search = item;
return {
kind: "search" as const, kind: "search" as const,
id: search.id, id: search.id,
title: getSearchTitle(search), title: getSearchTitle(search),
@@ -597,10 +681,21 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
initiatedModel: null, initiatedModel: null,
lastUsedProvider: null, lastUsedProvider: null,
lastUsedModel: null, lastUsedModel: null,
})), };
]; });
}
return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); function upsertWorkspaceItem(items: WorkspaceItem[], item: WorkspaceItem, moveToFront = true) {
const withoutExisting = items.filter((existing) => existing.type !== item.type || existing.id !== item.id);
if (moveToFront) {
return [item, ...withoutExisting];
}
const existingIndex = items.findIndex((existing) => existing.type === item.type && existing.id === item.id);
if (existingIndex < 0) return [item, ...items];
const next = [...items];
next[existingIndex] = item;
return next;
} }
function buildActiveRunsState(activeRuns: ActiveRunsResponse): ActiveRunsState { function buildActiveRunsState(activeRuns: ActiveRunsResponse): ActiveRunsState {
@@ -661,6 +756,7 @@ export default function App() {
const [chats, setChats] = useState<ChatSummary[]>([]); const [chats, setChats] = useState<ChatSummary[]>([]);
const [searches, setSearches] = useState<SearchSummary[]>([]); const [searches, setSearches] = useState<SearchSummary[]>([]);
const [workspaceItems, setWorkspaceItems] = useState<WorkspaceItem[]>([]);
const [selectedItem, setSelectedItem] = useState<SidebarSelection | null>(null); const [selectedItem, setSelectedItem] = useState<SidebarSelection | null>(null);
const [selectedChat, setSelectedChat] = useState<ChatDetail | null>(null); const [selectedChat, setSelectedChat] = useState<ChatDetail | null>(null);
const [selectedSearch, setSelectedSearch] = useState<SearchDetail | null>(null); const [selectedSearch, setSelectedSearch] = useState<SearchDetail | null>(null);
@@ -676,6 +772,7 @@ export default function App() {
const [isComposerDropActive, setIsComposerDropActive] = useState(false); const [isComposerDropActive, setIsComposerDropActive] = useState(false);
const [provider, setProvider] = useState<Provider>("openai"); const [provider, setProvider] = useState<Provider>("openai");
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG); const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG);
const [availableChatTools, setAvailableChatTools] = useState<ChatToolInfo[]>([]);
const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences()); const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences());
const [model, setModel] = useState(() => { const [model, setModel] = useState(() => {
const stored = loadStoredModelPreferences(); const stored = loadStoredModelPreferences();
@@ -698,6 +795,9 @@ export default function App() {
const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false); const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false);
const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null); const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isChatSettingsOpen, setIsChatSettingsOpen] = useState(false);
const [additionalSystemPrompt, setAdditionalSystemPrompt] = useState("");
const [enabledTools, setEnabledTools] = useState<string[]>([]);
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP); const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
const transcriptContainerRef = useRef<HTMLDivElement>(null); const transcriptContainerRef = useRef<HTMLDivElement>(null);
const transcriptEndRef = useRef<HTMLDivElement>(null); const transcriptEndRef = useRef<HTMLDivElement>(null);
@@ -715,6 +815,8 @@ export default function App() {
const wasSendingRef = useRef(false); const wasSendingRef = useRef(false);
const pendingReplyScrollRef = useRef(false); const pendingReplyScrollRef = useRef(false);
const transcriptTailSpacerHeightRef = useRef(TRANSCRIPT_BOTTOM_GAP); const transcriptTailSpacerHeightRef = useRef(TRANSCRIPT_BOTTOM_GAP);
const transcriptTailSpacerSettleFrameRef = useRef<number | null>(null);
const transcriptViewKeyRef = useRef<string | null>(null);
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("");
@@ -739,6 +841,7 @@ export default function App() {
const settleTranscriptTailSpacer = () => { const settleTranscriptTailSpacer = () => {
const container = transcriptContainerRef.current; const container = transcriptContainerRef.current;
const currentSpacerHeight = transcriptTailSpacerHeightRef.current; const currentSpacerHeight = transcriptTailSpacerHeightRef.current;
if (currentSpacerHeight <= TRANSCRIPT_BOTTOM_GAP) return;
if (!container) { if (!container) {
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP); setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
return; return;
@@ -746,7 +849,22 @@ export default function App() {
const scrollHeightWithoutSpacer = container.scrollHeight - currentSpacerHeight; const scrollHeightWithoutSpacer = container.scrollHeight - currentSpacerHeight;
const requiredSpacerHeight = container.scrollTop + container.clientHeight - scrollHeightWithoutSpacer; const requiredSpacerHeight = container.scrollTop + container.clientHeight - scrollHeightWithoutSpacer;
setTranscriptTailSpacer(requiredSpacerHeight); setTranscriptTailSpacer(Math.min(currentSpacerHeight, requiredSpacerHeight));
};
const requestSettleTranscriptTailSpacer = () => {
if (transcriptTailSpacerHeightRef.current <= TRANSCRIPT_BOTTOM_GAP) return;
if (typeof window === "undefined") {
settleTranscriptTailSpacer();
return;
}
if (transcriptTailSpacerSettleFrameRef.current !== null) {
window.cancelAnimationFrame(transcriptTailSpacerSettleFrameRef.current);
}
transcriptTailSpacerSettleFrameRef.current = window.requestAnimationFrame(() => {
transcriptTailSpacerSettleFrameRef.current = null;
settleTranscriptTailSpacer();
});
}; };
const focusComposer = () => { const focusComposer = () => {
@@ -769,7 +887,7 @@ export default function App() {
pendingAttachmentsRef.current = pendingAttachments; pendingAttachmentsRef.current = pendingAttachments;
}, [pendingAttachments]); }, [pendingAttachments]);
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]); const sidebarItems = useMemo(() => buildSidebarItems(workspaceItems), [workspaceItems]);
const filteredSidebarItems = useMemo(() => { const filteredSidebarItems = useMemo(() => {
const query = sidebarQuery.trim().toLowerCase(); const query = sidebarQuery.trim().toLowerCase();
if (!query) return sidebarItems; if (!query) return sidebarItems;
@@ -785,6 +903,7 @@ export default function App() {
const resetWorkspaceState = () => { const resetWorkspaceState = () => {
setChats([]); setChats([]);
setSearches([]); setSearches([]);
setWorkspaceItems([]);
setSelectedItem(null); setSelectedItem(null);
setSelectedChat(null); setSelectedChat(null);
setSelectedSearch(null); setSelectedSearch(null);
@@ -803,6 +922,9 @@ export default function App() {
searchRunCountersRef.current.clear(); searchRunCountersRef.current.clear();
setComposer(""); setComposer("");
setPendingAttachments([]); setPendingAttachments([]);
setIsChatSettingsOpen(false);
setAdditionalSystemPrompt("");
setEnabledTools([]);
setIsQuickQuestionOpen(false); setIsQuickQuestionOpen(false);
setQuickPrompt(""); setQuickPrompt("");
setQuickSubmittedPrompt(null); setQuickSubmittedPrompt(null);
@@ -820,15 +942,16 @@ export default function App() {
const refreshCollections = async (preferredSelection?: SidebarSelection) => { const refreshCollections = async (preferredSelection?: SidebarSelection) => {
setIsLoadingCollections(true); setIsLoadingCollections(true);
try { try {
const [nextChats, nextSearches] = await Promise.all([listChats(), listSearches()]); const nextWorkspaceItems = await listWorkspaceItems();
const nextItems = buildSidebarItems(nextChats, nextSearches); const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems);
setWorkspaceItems(nextWorkspaceItems);
setChats(nextChats); setChats(nextChats);
setSearches(nextSearches); setSearches(nextSearches);
setSelectedItem((current) => { setSelectedItem((current) => {
const hasItem = (candidate: SidebarSelection | null) => { const hasItem = (candidate: SidebarSelection | null) => {
if (!candidate) return false; if (!candidate) return false;
return nextItems.some((item) => item.kind === candidate.kind && item.id === candidate.id); return nextWorkspaceItems.some((item) => item.type === candidate.kind && item.id === candidate.id);
}; };
if (preferredSelection && hasItem(preferredSelection)) { if (preferredSelection && hasItem(preferredSelection)) {
@@ -837,8 +960,8 @@ export default function App() {
if (hasItem(current)) { if (hasItem(current)) {
return current; return current;
} }
const first = nextItems[0]; const first = nextWorkspaceItems[0];
return first ? { kind: first.kind, id: first.id } : null; return first ? { kind: first.type, id: first.id } : null;
}); });
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
@@ -866,6 +989,21 @@ export default function App() {
} }
}; };
const refreshChatTools = async () => {
try {
const tools = await listChatTools();
setAvailableChatTools(tools);
setEnabledTools((current) => normalizeEnabledTools(current.length ? current : null, tools));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setError(message);
}
}
};
const refreshActiveRuns = async () => { const refreshActiveRuns = async () => {
try { try {
const data = await getActiveRuns(); const data = await getActiveRuns();
@@ -918,7 +1056,7 @@ export default function App() {
if (!isAuthenticated) return; if (!isAuthenticated) return;
const preferredSelection = initialRouteSelectionRef.current; const preferredSelection = initialRouteSelectionRef.current;
initialRouteSelectionRef.current = null; initialRouteSelectionRef.current = null;
void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshActiveRuns()]); void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshChatTools(), refreshActiveRuns()]);
}, [isAuthenticated]); }, [isAuthenticated]);
useEffect(() => { useEffect(() => {
@@ -963,6 +1101,7 @@ export default function App() {
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]); const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]); const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]);
const providerOptions = useMemo(() => getVisibleProviders(modelCatalog), [modelCatalog]);
useEffect(() => { useEffect(() => {
if (model.trim()) return; if (model.trim()) return;
@@ -1017,6 +1156,7 @@ export default function App() {
}, [quickPrompt, isQuickQuestionOpen]); }, [quickPrompt, isQuickQuestionOpen]);
const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null; const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null;
const transcriptViewKey = draftKind ? `draft:${draftKind}` : selectedKey ?? "empty";
const selectedChatPendingState = selectedItem?.kind === "chat" ? pendingChatStates[selectedItem.id] ?? null : null; const selectedChatPendingState = selectedItem?.kind === "chat" ? pendingChatStates[selectedItem.id] ?? null : null;
const selectedSearchRunState = selectedItem?.kind === "search" ? runningSearchStates[selectedItem.id] ?? null : null; const selectedSearchRunState = selectedItem?.kind === "search" ? runningSearchStates[selectedItem.id] ?? null : null;
const selectedChatIsActive = selectedItem?.kind === "chat" && (!!selectedChatPendingState || !!activeRuns.chats[selectedItem.id]); const selectedChatIsActive = selectedItem?.kind === "chat" && (!!selectedChatPendingState || !!activeRuns.chats[selectedItem.id]);
@@ -1038,11 +1178,13 @@ export default function App() {
}; };
useEffect(() => { useEffect(() => {
const didViewChange = transcriptViewKeyRef.current !== transcriptViewKey;
transcriptViewKeyRef.current = transcriptViewKey;
shouldAutoScrollRef.current = true; shouldAutoScrollRef.current = true;
if (!isSendingActiveChat) { if (didViewChange && !pendingReplyScrollRef.current) {
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP); setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
} }
}, [isSendingActiveChat, selectedKey]); }, [transcriptViewKey]);
useEffect(() => { useEffect(() => {
selectedItemRef.current = selectedItem; selectedItemRef.current = selectedItem;
@@ -1101,6 +1243,10 @@ export default function App() {
useEffect(() => { useEffect(() => {
return () => { return () => {
if (transcriptTailSpacerSettleFrameRef.current !== null) {
window.cancelAnimationFrame(transcriptTailSpacerSettleFrameRef.current);
transcriptTailSpacerSettleFrameRef.current = null;
}
for (const controller of chatStreamAbortRefs.current.values()) { for (const controller of chatStreamAbortRefs.current.values()) {
controller.abort(); controller.abort();
} }
@@ -1129,6 +1275,16 @@ export default function App() {
if (selectedChatPendingState) return selectedChatPendingState.messages.filter(isDisplayableMessage); if (selectedChatPendingState) return selectedChatPendingState.messages.filter(isDisplayableMessage);
return messages.filter(isDisplayableMessage); return messages.filter(isDisplayableMessage);
}, [messages, selectedChatPendingState]); }, [messages, selectedChatPendingState]);
const displayMessagesLayoutKey = useMemo(
() => displayMessages.map((message) => `${message.id}:${message.content.length}`).join("|"),
[displayMessages]
);
useEffect(() => {
if (isSearchMode || isSendingActiveChat) return;
requestSettleTranscriptTailSpacer();
}, [displayMessagesLayoutKey, isSearchMode, isSendingActiveChat, selectedKey]);
const quickAnswerText = useMemo(() => { const quickAnswerText = useMemo(() => {
for (let index = quickQuestionMessages.length - 1; index >= 0; index -= 1) { for (let index = quickQuestionMessages.length - 1; index >= 0; index -= 1) {
const message = quickQuestionMessages[index]; const message = quickQuestionMessages[index];
@@ -1162,6 +1318,19 @@ export default function App() {
setModel(nextSelection.model); setModel(nextSelection.model);
}, [draftKind, selectedChat, selectedChatSummary, selectedItem]); }, [draftKind, selectedChat, selectedChatSummary, selectedItem]);
useEffect(() => {
if (draftKind === "chat") {
setAdditionalSystemPrompt("");
setEnabledTools(getDefaultEnabledTools(availableChatTools));
return;
}
if (selectedItem?.kind !== "chat") return;
const chat = selectedChat?.id === selectedItem.id ? selectedChat : selectedChatSummary;
if (!chat) return;
setAdditionalSystemPrompt(chat.additionalSystemPrompt ?? "");
setEnabledTools(normalizeEnabledTools(chat.enabledTools, availableChatTools));
}, [availableChatTools, draftKind, selectedChat, selectedChatSummary, selectedItem]);
const selectedTitle = useMemo(() => { const selectedTitle = useMemo(() => {
if (draftKind === "chat") return "New chat"; if (draftKind === "chat") return "New chat";
if (draftKind === "search") return "New search"; if (draftKind === "search") return "New search";
@@ -1465,6 +1634,11 @@ export default function App() {
}; };
const handleSendChat = async (content: string, attachments: ChatAttachment[]): Promise<SidebarSelection> => { const handleSendChat = async (content: string, attachments: ChatAttachment[]): Promise<SidebarSelection> => {
const selectedModel = model.trim();
if (!selectedModel) {
throw new Error("No model available for selected provider");
}
pendingReplyScrollRef.current = true; pendingReplyScrollRef.current = true;
expandTranscriptTailSpacer(getReplyScrollBufferHeight()); expandTranscriptTailSpacer(getReplyScrollBufferHeight());
@@ -1496,6 +1670,7 @@ export default function App() {
const withoutExisting = current.filter((existing) => existing.id !== chat.id); const withoutExisting = current.filter((existing) => existing.id !== chat.id);
return [chat, ...withoutExisting]; return [chat, ...withoutExisting];
}); });
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
setSelectedItem({ kind: "chat", id: chatId }); setSelectedItem({ kind: "chat", id: chatId });
setSelectedChat({ setSelectedChat({
id: chat.id, id: chat.id,
@@ -1548,11 +1723,6 @@ export default function App() {
}, },
]; ];
const selectedModel = model.trim();
if (!selectedModel) {
throw new Error("No model available for selected provider");
}
const chatSummary = chats.find((chat) => chat.id === chatId); const chatSummary = chats.find((chat) => chat.id === chatId);
const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim()); const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim());
if (!hasExistingTitle && !pendingTitleGenerationRef.current.has(chatId)) { if (!hasExistingTitle && !pendingTitleGenerationRef.current.has(chatId)) {
@@ -1566,6 +1736,7 @@ export default function App() {
return { ...chat, title: updatedChat.title, updatedAt: updatedChat.updatedAt }; return { ...chat, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
}) })
); );
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(updatedChat), false));
setSelectedChat((current) => { setSelectedChat((current) => {
if (!current || current.id !== updatedChat.id) return current; if (!current || current.id !== updatedChat.id) return current;
return { ...current, title: updatedChat.title, updatedAt: updatedChat.updatedAt }; return { ...current, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
@@ -1672,13 +1843,17 @@ export default function App() {
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) { if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
await refreshChat(chatId); await refreshChat(chatId);
} }
settleTranscriptTailSpacer();
removePendingChatState(chatId); removePendingChatState(chatId);
removeActiveRun("chat", chatId); removeActiveRun("chat", chatId);
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
requestSettleTranscriptTailSpacer();
}
return { kind: "chat", id: chatId }; return { kind: "chat", id: chatId };
} catch (err) { } catch (err) {
removePendingChatState(chatId); removePendingChatState(chatId);
removeActiveRun("chat", chatId); removeActiveRun("chat", chatId);
pendingReplyScrollRef.current = false;
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
throw err; throw err;
} }
}; };
@@ -1694,6 +1869,11 @@ export default function App() {
searchId = search.id; searchId = search.id;
setDraftKind(null); setDraftKind(null);
setSelectedItem({ kind: "search", id: searchId }); setSelectedItem({ kind: "search", id: searchId });
setSearches((current) => {
const withoutExisting = current.filter((existing) => existing.id !== search.id);
return [search, ...withoutExisting];
});
setWorkspaceItems((current) => upsertWorkspaceItem(current, searchWorkspaceItem(search)));
} }
if (!searchId) { if (!searchId) {
@@ -1939,6 +2119,9 @@ export default function App() {
chatStreamAbortRefs.current.delete(chatId); chatStreamAbortRefs.current.delete(chatId);
removePendingChatState(chatId); removePendingChatState(chatId);
removeActiveRun("chat", chatId); removeActiveRun("chat", chatId);
if (isCurrentSelection(target)) {
requestSettleTranscriptTailSpacer();
}
} }
}; };
@@ -2064,6 +2247,7 @@ export default function App() {
const withoutExisting = current.filter((existing) => existing.id !== chat.id); const withoutExisting = current.filter((existing) => existing.id !== chat.id);
return [chat, ...withoutExisting]; return [chat, ...withoutExisting];
}); });
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
setSelectedItem({ kind: "chat", id: chat.id }); setSelectedItem({ kind: "chat", id: chat.id });
setSelectedChat({ setSelectedChat({
id: chat.id, id: chat.id,
@@ -2239,6 +2423,7 @@ export default function App() {
const withoutExisting = current.filter((existing) => existing.id !== chat.id); const withoutExisting = current.filter((existing) => existing.id !== chat.id);
return [chat, ...withoutExisting]; return [chat, ...withoutExisting];
}); });
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
setSelectedItem({ kind: "chat", id: chat.id }); setSelectedItem({ kind: "chat", id: chat.id });
setSelectedChat({ setSelectedChat({
id: chat.id, id: chat.id,
@@ -2288,6 +2473,10 @@ export default function App() {
sentTarget = await handleSendChat(content, attachments); sentTarget = await handleSendChat(content, attachments);
} }
} catch (err) { } catch (err) {
if (!sentAsSearch) {
pendingReplyScrollRef.current = false;
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
}
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) { if (message.includes("bearer token")) {
handleAuthFailure(message); handleAuthFailure(message);
@@ -2449,7 +2638,7 @@ export default function App() {
onContextMenu={(event) => openContextMenu(event, { kind: item.kind, id: item.id })} onContextMenu={(event) => openContextMenu(event, { kind: item.kind, id: item.id })}
type="button" type="button"
> >
<div className="flex items-center gap-2"> <div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1">
<span <span
className={cn( className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border", "flex h-5 w-5 shrink-0 items-center justify-center rounded-md border",
@@ -2467,11 +2656,15 @@ export default function App() {
/> />
) : null} ) : null}
</span> </span>
<span className={cn("ml-auto shrink-0 text-xs", active ? "text-violet-100/86" : "text-violet-200/50")}>{formatDate(item.updatedAt)}</span> <span className="col-start-2 flex min-w-0 items-center gap-2">
<span className="shrink-0 text-xs text-secondary-foreground/70">{formatDate(item.updatedAt)}</span>
{initiatedLabel ? (
<span className="ml-auto min-w-0 truncate text-right text-xs text-secondary-foreground/70">
{initiatedLabel}
</span>
) : null}
</span>
</div> </div>
{initiatedLabel ? (
<p className={cn("mt-1 truncate text-right text-xs", active ? "text-violet-100/62" : "text-violet-200/42")}>{initiatedLabel}</p>
) : null}
</button> </button>
); );
})} })}
@@ -2512,9 +2705,11 @@ export default function App() {
}} }}
disabled={isActiveSelectionSending} disabled={isActiveSelectionSending}
> >
<option value="openai">OpenAI</option> {providerOptions.map((candidate) => (
<option value="anthropic">Anthropic</option> <option key={candidate} value={candidate}>
<option value="xai">xAI</option> {getProviderLabel(candidate)}
</option>
))}
</select> </select>
<ModelCombobox <ModelCombobox
options={providerModelOptions} options={providerModelOptions}
@@ -2547,6 +2742,9 @@ export default function App() {
if (!container) return; if (!container) return;
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
shouldAutoScrollRef.current = distanceFromBottom < 96; shouldAutoScrollRef.current = distanceFromBottom < 96;
if (!isSearchMode && !isSendingActiveChat && distanceFromBottom > 0) {
settleTranscriptTailSpacer();
}
}} }}
> >
{!isSearchMode ? ( {!isSearchMode ? (
@@ -2758,9 +2956,11 @@ export default function App() {
disabled={isQuickQuestionSending || isConvertingQuickQuestion} disabled={isQuickQuestionSending || isConvertingQuickQuestion}
aria-label="Quick question provider" aria-label="Quick question provider"
> >
<option value="openai">OpenAI</option> {providerOptions.map((candidate) => (
<option value="anthropic">Anthropic</option> <option key={candidate} value={candidate}>
<option value="xai">xAI</option> {getProviderLabel(candidate)}
</option>
))}
</select> </select>
<ModelCombobox <ModelCombobox
options={quickProviderModelOptions} options={quickProviderModelOptions}

View File

@@ -206,17 +206,31 @@ textarea {
} }
.md-content code { .md-content code {
background: hsl(288 22% 23%); background: hsl(249 40% 10% / 0.78);
border-radius: 0.25rem; border-radius: 0.3rem;
padding: 0.05rem 0.3rem; padding: 0.05rem 0.3rem;
font-size: 0.86em; font-size: 0.86em;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
} }
.md-content pre { .md-content pre {
overflow-x: auto; overflow-x: auto;
border-radius: 0.5rem; border: 1px solid hsl(253 31% 29% / 0.72);
background: hsl(287 28% 13%); border-radius: 0.625rem;
padding: 0.6rem 0.75rem; background: hsl(249 40% 10% / 0.82);
padding: 0.75rem;
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.05);
}
.md-content pre code {
display: block;
background: transparent;
border-radius: 0;
padding: 0;
font-size: 0.88em;
line-height: 1.55;
white-space: pre;
} }
.md-content a { .md-content a {

View File

@@ -7,6 +7,8 @@ export type ChatSummary = {
initiatedModel: string | null; initiatedModel: string | null;
lastUsedProvider: Provider | null; lastUsedProvider: Provider | null;
lastUsedModel: string | null; lastUsedModel: string | null;
additionalSystemPrompt: string | null;
enabledTools: string[] | null;
}; };
export type SearchSummary = { export type SearchSummary = {
@@ -17,6 +19,16 @@ export type SearchSummary = {
updatedAt: string; updatedAt: string;
}; };
export type ChatWorkspaceItem = ChatSummary & {
type: "chat";
};
export type SearchWorkspaceItem = SearchSummary & {
type: "search";
};
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem;
export type Message = { export type Message = {
id: string; id: string;
createdAt: string; createdAt: string;
@@ -48,6 +60,8 @@ export type ChatDetail = {
initiatedModel: string | null; initiatedModel: string | null;
lastUsedProvider: Provider | null; lastUsedProvider: Provider | null;
lastUsedModel: string | null; lastUsedModel: string | null;
additionalSystemPrompt: string | null;
enabledTools: string[] | null;
messages: Message[]; messages: Message[];
}; };
@@ -127,7 +141,7 @@ export type CompletionRequestMessage = {
attachments?: ChatAttachment[]; attachments?: ChatAttachment[];
}; };
export type Provider = "openai" | "anthropic" | "xai"; export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
export type ProviderModelInfo = { export type ProviderModelInfo = {
models: string[]; models: string[];
@@ -136,7 +150,12 @@ export type ProviderModelInfo = {
}; };
export type ModelCatalogResponse = { export type ModelCatalogResponse = {
providers: Record<Provider, ProviderModelInfo>; providers: Partial<Record<Provider, ProviderModelInfo>>;
};
export type ChatToolInfo = {
name: string;
description: string;
}; };
export type ActiveRunsResponse = { export type ActiveRunsResponse = {
@@ -164,6 +183,8 @@ type CreateChatRequest = {
title?: string; title?: string;
provider?: Provider; provider?: Provider;
model?: string; model?: string;
additionalSystemPrompt?: string;
enabledTools?: string[];
messages?: CompletionRequestMessage[]; messages?: CompletionRequestMessage[];
}; };
@@ -214,6 +235,11 @@ export async function listChats() {
return data.chats; return data.chats;
} }
export async function listWorkspaceItems() {
const data = await api<{ items: WorkspaceItem[] }>("/v1/workspace-items");
return data.items;
}
export async function verifySession() { export async function verifySession() {
return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session"); return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session");
} }
@@ -222,6 +248,11 @@ export async function listModels() {
return api<ModelCatalogResponse>("/v1/models"); return api<ModelCatalogResponse>("/v1/models");
} }
export async function listChatTools() {
const data = await api<{ tools: ChatToolInfo[] }>("/v1/chat-tools");
return data.tools;
}
export async function getActiveRuns() { export async function getActiveRuns() {
return api<ActiveRunsResponse>("/v1/active-runs"); return api<ActiveRunsResponse>("/v1/active-runs");
} }
@@ -248,6 +279,14 @@ export async function updateChatTitle(chatId: string, title: string) {
return data.chat; return data.chat;
} }
export async function updateChatSettings(chatId: string, body: { additionalSystemPrompt?: string | null; enabledTools?: string[] }) {
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
method: "PATCH",
body: JSON.stringify(body),
});
return data.chat;
}
export async function suggestChatTitle(body: { chatId: string; content: string }) { export async function suggestChatTitle(body: { chatId: string; content: string }) {
const data = await api<{ chat: ChatSummary }>("/v1/chats/title/suggest", { const data = await api<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
method: "POST", method: "POST",
@@ -554,6 +593,9 @@ export async function runCompletion(body: {
provider: Provider; provider: Provider;
model: string; model: string;
messages: CompletionRequestMessage[]; messages: CompletionRequestMessage[];
additionalSystemPrompt?: string;
enabledTools?: string[];
userLocation?: string;
}) { }) {
return api<CompletionResponse>("/v1/chat-completions", { return api<CompletionResponse>("/v1/chat-completions", {
method: "POST", method: "POST",
@@ -568,6 +610,9 @@ export async function runCompletionStream(
provider: Provider; provider: Provider;
model: string; model: string;
messages: CompletionRequestMessage[]; messages: CompletionRequestMessage[];
additionalSystemPrompt?: string;
enabledTools?: string[];
userLocation?: string;
}, },
handlers: CompletionStreamHandlers, handlers: CompletionStreamHandlers,
options?: { signal?: AbortSignal } options?: { signal?: AbortSignal }