Compare commits
14 Commits
195e157e1a
...
codex/syst
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3bb8503aa | ||
|
|
93e34d086f | ||
| f79e5e02c5 | |||
| 411790ee04 | |||
| a8e765e026 | |||
| 29c6dce0e5 | |||
| 5855b7edb8 | |||
| ac6d55f617 | |||
| 1e045db7f4 | |||
| 12b3d8c5ad | |||
| bd0200ac98 | |||
| 0c9b4d1ed3 | |||
| 30656842a7 | |||
| 8b580fd3e1 |
@@ -12,6 +12,9 @@ services:
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_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:-}
|
||||
CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa}
|
||||
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-}
|
||||
|
||||
@@ -33,11 +33,14 @@ Chat upload limits:
|
||||
"providers": {
|
||||
"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 },
|
||||
"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.
|
||||
- `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
|
||||
|
||||
@@ -55,6 +58,42 @@ Behavior notes:
|
||||
- Clients should use this after app start or page refresh to restore per-row generating indicators.
|
||||
- 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
|
||||
|
||||
### `GET /v1/chats`
|
||||
@@ -65,7 +104,7 @@ Behavior notes:
|
||||
```json
|
||||
{
|
||||
"title": "optional title",
|
||||
"provider": "optional openai|anthropic|xai",
|
||||
"provider": "optional openai|anthropic|xai|hermes-agent",
|
||||
"model": "optional model id",
|
||||
"messages": [
|
||||
{
|
||||
@@ -152,7 +191,7 @@ Notes:
|
||||
```json
|
||||
{
|
||||
"chatId": "optional-chat-id",
|
||||
"provider": "openai|anthropic|xai",
|
||||
"provider": "openai|anthropic|xai|hermes-agent",
|
||||
"model": "string",
|
||||
"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.
|
||||
- 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 `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 `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 `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`.
|
||||
- `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.
|
||||
@@ -311,9 +351,9 @@ Behavior notes:
|
||||
"title": null,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"initiatedProvider": "openai|anthropic|xai|null",
|
||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"lastUsedModel": "string|null"
|
||||
}
|
||||
```
|
||||
@@ -359,9 +399,9 @@ Behavior notes:
|
||||
"title": null,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "...",
|
||||
"initiatedProvider": "openai|anthropic|xai|null",
|
||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"initiatedModel": "string|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|null",
|
||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||
"lastUsedModel": "string|null",
|
||||
"messages": [Message]
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ Authentication:
|
||||
{
|
||||
"chatId": "optional-chat-id",
|
||||
"persist": true,
|
||||
"provider": "openai|anthropic|xai",
|
||||
"provider": "openai|anthropic|xai|hermes-agent",
|
||||
"model": "string",
|
||||
"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.
|
||||
- `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.
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
@@ -51,3 +51,4 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
||||
- OpenAI: `gpt-4.1-mini`
|
||||
- Anthropic: `claude-3-5-sonnet-latest`
|
||||
- xAI: `grok-3-mini`
|
||||
- Hermes Agent: `hermes-agent`
|
||||
|
||||
17
ios/Apps/Sybil/Info.plist
Normal file
17
ios/Apps/Sybil/Info.plist
Normal 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>
|
||||
@@ -5,6 +5,8 @@ import UIKit
|
||||
@main
|
||||
struct SybilApp: App
|
||||
{
|
||||
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,10 @@ targets:
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||
TARGETED_DEVICE_FAMILY: "1,2,6"
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
MARKETING_VERSION: 1.5
|
||||
CURRENT_PROJECT_VERSION: 6
|
||||
MARKETING_VERSION: 1.9
|
||||
CURRENT_PROJECT_VERSION: 10
|
||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||
|
||||
@@ -2,10 +2,14 @@ import SwiftUI
|
||||
|
||||
public struct SplitView: View {
|
||||
@State private var viewModel = SybilViewModel()
|
||||
@ObservedObject private var quickActionRouter = SybilQuickActionRouter.shared
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var shouldRefreshOnForeground = false
|
||||
@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
|
||||
|
||||
private var keyboardActions: SybilKeyboardActions? {
|
||||
@@ -74,8 +78,28 @@ public struct SplitView: View {
|
||||
.font(.sybil(.body))
|
||||
.preferredColorScheme(.dark)
|
||||
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
||||
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
|
||||
SybilQuickQuestionView(
|
||||
viewModel: viewModel,
|
||||
focusRequest: quickQuestionFocusRequest
|
||||
)
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.task {
|
||||
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
|
||||
switch nextPhase {
|
||||
@@ -112,6 +136,28 @@ public struct SplitView: View {
|
||||
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 {
|
||||
|
||||
@@ -44,16 +44,26 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
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] {
|
||||
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
|
||||
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(
|
||||
"/v1/chats",
|
||||
method: "POST",
|
||||
body: AnyEncodable(ChatCreateBody(title: title)),
|
||||
body: AnyEncodable(ChatCreateBody(title: title, provider: provider, model: model, messages: messages)),
|
||||
responseType: ChatCreateResponse.self
|
||||
)
|
||||
return response.chat
|
||||
@@ -617,13 +627,18 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
|
||||
struct CompletionStreamRequest: Codable, Sendable {
|
||||
var chatId: String?
|
||||
var persist: Bool? = nil
|
||||
var provider: Provider
|
||||
var model: String
|
||||
var messages: [CompletionRequestMessage]
|
||||
var userLocation: String? = nil
|
||||
}
|
||||
|
||||
private struct ChatCreateBody: Encodable {
|
||||
var title: String?
|
||||
var provider: Provider?
|
||||
var model: String?
|
||||
var messages: [CompletionRequestMessage]?
|
||||
}
|
||||
|
||||
private struct SearchCreateBody: Encodable {
|
||||
|
||||
@@ -2,8 +2,14 @@ import Foundation
|
||||
|
||||
protocol SybilAPIClienting: Sendable {
|
||||
func verifySession() async throws -> AuthSession
|
||||
func listWorkspaceItems() async throws -> [WorkspaceItem]
|
||||
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 deleteChat(chatID: String) async throws
|
||||
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
|
||||
@@ -32,3 +38,9 @@ protocol SybilAPIClienting: Sendable {
|
||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||
) async throws
|
||||
}
|
||||
|
||||
extension SybilAPIClienting {
|
||||
func createChat(title: String?) async throws -> ChatSummary {
|
||||
try await createChat(title: title, provider: nil, model: nil, messages: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,18 +17,6 @@ struct SybilChatTranscriptView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
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
|
||||
MessageBubble(message: message, isSending: isSending)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
@@ -4,12 +4,14 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
|
||||
case openai
|
||||
case anthropic
|
||||
case xai
|
||||
case hermesAgent = "hermes-agent"
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .openai: return "OpenAI"
|
||||
case .anthropic: return "Anthropic"
|
||||
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 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 var id: String
|
||||
public var createdAt: Date
|
||||
@@ -404,8 +475,8 @@ public struct CompletionRequestMessage: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct CompletionStreamMeta: Codable, Sendable {
|
||||
public var chatId: String
|
||||
public var callId: String
|
||||
public var chatId: String?
|
||||
public var callId: String?
|
||||
public var provider: Provider
|
||||
public var model: String
|
||||
}
|
||||
@@ -522,6 +593,10 @@ struct SearchListResponse: Codable {
|
||||
var searches: [SearchSummary]
|
||||
}
|
||||
|
||||
struct WorkspaceListResponse: Codable {
|
||||
var items: [WorkspaceItem]
|
||||
}
|
||||
|
||||
struct ChatDetailResponse: Codable {
|
||||
var chat: ChatDetail
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
302
ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift
Normal file
302
ios/Packages/Sybil/Sources/Sybil/SybilQuickQuestionView.swift
Normal 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")."
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,12 @@ final class SybilSettingsStore {
|
||||
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
|
||||
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
|
||||
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
|
||||
@@ -19,6 +25,8 @@ final class SybilSettingsStore {
|
||||
var adminToken: String
|
||||
var preferredProvider: Provider
|
||||
var preferredModelByProvider: [Provider: String]
|
||||
var quickQuestionPreferredProvider: Provider
|
||||
var quickQuestionPreferredModelByProvider: [Provider: String]
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
@@ -32,10 +40,21 @@ final class SybilSettingsStore {
|
||||
let provider = defaults.string(forKey: Keys.preferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
|
||||
self.preferredProvider = provider
|
||||
|
||||
self.preferredModelByProvider = [
|
||||
let preferredModels: [Provider: String] = [
|
||||
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
|
||||
.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[.anthropic], forKey: Keys.preferredAnthropicModel)
|
||||
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? {
|
||||
@@ -68,7 +94,7 @@ final class SybilSettingsStore {
|
||||
raw.removeLast()
|
||||
}
|
||||
|
||||
guard var components = URLComponents(string: raw) else {
|
||||
guard let components = URLComponents(string: raw) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -159,10 +159,7 @@ struct SybilSidebarItemList: View {
|
||||
.padding(10)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.refreshVisibleContent(
|
||||
refreshCollections: true,
|
||||
refreshSelection: false
|
||||
)
|
||||
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ final class SybilViewModel {
|
||||
|
||||
var chats: [ChatSummary] = []
|
||||
var searches: [SearchSummary] = []
|
||||
var workspaceItems: [WorkspaceItem] = []
|
||||
|
||||
var selectedItem: SidebarSelection?
|
||||
var selectedChat: ChatDetail?
|
||||
@@ -111,6 +112,16 @@ final class SybilViewModel {
|
||||
var provider: Provider
|
||||
var modelCatalog: [Provider: ProviderModelInfo] = [:]
|
||||
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
|
||||
private var hasBootstrapped = false
|
||||
@@ -132,6 +143,10 @@ final class SybilViewModel {
|
||||
@ObservationIgnored
|
||||
private var activeSearchAttachTasks: [String: Task<Void, Never>] = [:]
|
||||
@ObservationIgnored
|
||||
private var quickQuestionTask: Task<Void, Never>?
|
||||
@ObservationIgnored
|
||||
private var quickQuestionRunID: UUID?
|
||||
@ObservationIgnored
|
||||
private var isAppActive = true
|
||||
@ObservationIgnored
|
||||
private var appLifecycleGeneration = 0
|
||||
@@ -141,7 +156,8 @@ final class SybilViewModel {
|
||||
private let fallbackModels: [Provider: [String]] = [
|
||||
.openai: ["gpt-4.1-mini"],
|
||||
.anthropic: ["claude-3-5-sonnet-latest"],
|
||||
.xai: ["grok-3-mini"]
|
||||
.xai: ["grok-3-mini"],
|
||||
.hermesAgent: ["hermes-agent"]
|
||||
]
|
||||
|
||||
init(
|
||||
@@ -152,14 +168,56 @@ final class SybilViewModel {
|
||||
) {
|
||||
self.settings = settings
|
||||
self.clientFactory = clientFactory
|
||||
self.provider = settings.preferredProvider
|
||||
self.model = settings.preferredModelByProvider[settings.preferredProvider] ?? "gpt-4.1-mini"
|
||||
let initialProvider = settings.preferredProvider
|
||||
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] {
|
||||
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] {
|
||||
let serverModels = modelCatalog[candidate]?.models ?? []
|
||||
if !serverModels.isEmpty {
|
||||
@@ -331,10 +389,12 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
var sidebarItems: [SidebarItem] {
|
||||
let chatItems: [SidebarItem] = chats.map { chat in
|
||||
workspaceItems.map { item in
|
||||
switch item.type {
|
||||
case .chat:
|
||||
let initiatedLabel: String?
|
||||
if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
|
||||
if let provider = chat.initiatedProvider {
|
||||
if let model = item.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
|
||||
if let provider = item.initiatedProvider {
|
||||
initiatedLabel = "\(provider.displayName) • \(model)"
|
||||
} else {
|
||||
initiatedLabel = model
|
||||
@@ -344,27 +404,25 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
return SidebarItem(
|
||||
selection: .chat(chat.id),
|
||||
selection: .chat(item.id),
|
||||
kind: .chat,
|
||||
title: chatTitle(title: chat.title, messages: nil),
|
||||
updatedAt: chat.updatedAt,
|
||||
title: chatTitle(title: item.title, messages: nil),
|
||||
updatedAt: item.updatedAt,
|
||||
initiatedLabel: initiatedLabel,
|
||||
isRunning: isChatRowRunning(chat.id)
|
||||
isRunning: isChatRowRunning(item.id)
|
||||
)
|
||||
}
|
||||
|
||||
let searchItems: [SidebarItem] = searches.map { search in
|
||||
SidebarItem(
|
||||
selection: .search(search.id),
|
||||
case .search:
|
||||
return SidebarItem(
|
||||
selection: .search(item.id),
|
||||
kind: .search,
|
||||
title: searchTitle(title: search.title, query: search.query),
|
||||
updatedAt: search.updatedAt,
|
||||
title: searchTitle(title: item.title, query: item.query),
|
||||
updatedAt: item.updatedAt,
|
||||
initiatedLabel: "exa",
|
||||
isRunning: isSearchRowRunning(search.id)
|
||||
isRunning: isSearchRowRunning(item.id)
|
||||
)
|
||||
}
|
||||
|
||||
return (chatItems + searchItems).sorted { $0.updatedAt > $1.updatedAt }
|
||||
}
|
||||
}
|
||||
|
||||
var selectedChatSummary: ChatSummary? {
|
||||
@@ -415,6 +473,7 @@ final class SybilViewModel {
|
||||
localActiveSearchIDs = []
|
||||
serverActiveChatIDs = []
|
||||
serverActiveSearchIDs = []
|
||||
resetQuickQuestion()
|
||||
draftIdentity = UUID()
|
||||
composerAttachments = []
|
||||
settings.persist()
|
||||
@@ -444,6 +503,7 @@ final class SybilViewModel {
|
||||
authMode = nil
|
||||
chats = []
|
||||
searches = []
|
||||
workspaceItems = []
|
||||
selectedItem = .settings
|
||||
selectedChat = nil
|
||||
selectedSearch = nil
|
||||
@@ -487,6 +547,160 @@ final class SybilViewModel {
|
||||
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() {
|
||||
SybilLog.debug(SybilLog.ui, "Starting draft chat")
|
||||
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 {
|
||||
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let attachments = composerAttachments
|
||||
@@ -806,6 +1037,7 @@ final class SybilViewModel {
|
||||
guard selectedItem == sourceSelection, draftKind == nil else {
|
||||
chats.removeAll(where: { $0.id == chat.id })
|
||||
chats.insert(chat, at: 0)
|
||||
upsertWorkspaceChat(chat)
|
||||
isCreatingSearchChat = false
|
||||
return
|
||||
}
|
||||
@@ -817,6 +1049,7 @@ final class SybilViewModel {
|
||||
|
||||
chats.removeAll(where: { $0.id == chat.id })
|
||||
chats.insert(chat, at: 0)
|
||||
upsertWorkspaceChat(chat)
|
||||
|
||||
selectedItem = .chat(chat.id)
|
||||
selectedSearch = nil
|
||||
@@ -830,23 +1063,106 @@ final class SybilViewModel {
|
||||
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 {
|
||||
isLoadingCollections = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
async let chatsValue = client.listChats()
|
||||
async let searchesValue = client.listSearches()
|
||||
async let workspaceItemsValue = client.listWorkspaceItems()
|
||||
async let activeRunsValue = client.getActiveRuns()
|
||||
let (nextChats, nextSearches, nextActiveRuns) = try await (chatsValue, searchesValue, activeRunsValue)
|
||||
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
|
||||
|
||||
chats = nextChats
|
||||
searches = nextSearches
|
||||
applyWorkspaceItems(nextWorkspaceItems)
|
||||
applyActiveRuns(nextActiveRuns)
|
||||
|
||||
SybilLog.info(
|
||||
SybilLog.app,
|
||||
"Loaded collections: \(nextChats.count) chats, \(nextSearches.count) searches"
|
||||
"Loaded collections: \(chats.count) chats, \(searches.count) searches"
|
||||
)
|
||||
|
||||
do {
|
||||
@@ -863,7 +1179,7 @@ final class SybilViewModel {
|
||||
if case .settings = selectedItem {
|
||||
nextSelection = .settings
|
||||
} else if let currentSelection = selectedItem,
|
||||
hasSelection(currentSelection, chats: nextChats, searches: nextSearches) {
|
||||
hasSelection(currentSelection, chats: chats, searches: searches) {
|
||||
nextSelection = currentSelection
|
||||
} else {
|
||||
nextSelection = sidebarItems.first?.selection
|
||||
@@ -893,6 +1209,11 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
private func syncModelSelectionWithServerCatalog() {
|
||||
if !providerOptions.contains(provider), let firstProvider = providerOptions.first {
|
||||
provider = firstProvider
|
||||
settings.preferredProvider = firstProvider
|
||||
}
|
||||
|
||||
if !providerModelOptions.contains(model), let first = providerModelOptions.first {
|
||||
model = first
|
||||
settings.preferredModelByProvider[provider] = first
|
||||
@@ -902,6 +1223,22 @@ final class SybilViewModel {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -914,18 +1251,16 @@ final class SybilViewModel {
|
||||
|
||||
do {
|
||||
let client = try client()
|
||||
async let chatsValue = client.listChats()
|
||||
async let searchesValue = client.listSearches()
|
||||
async let workspaceItemsValue = client.listWorkspaceItems()
|
||||
async let activeRunsValue = client.getActiveRuns()
|
||||
let (nextChats, nextSearches, nextActiveRuns) = try await (chatsValue, searchesValue, activeRunsValue)
|
||||
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
|
||||
|
||||
chats = nextChats
|
||||
searches = nextSearches
|
||||
applyWorkspaceItems(nextWorkspaceItems)
|
||||
applyActiveRuns(nextActiveRuns)
|
||||
|
||||
SybilLog.info(
|
||||
SybilLog.app,
|
||||
"Refreshed collections: \(nextChats.count) chats, \(nextSearches.count) searches"
|
||||
"Refreshed collections: \(chats.count) chats, \(searches.count) searches"
|
||||
)
|
||||
errorMessage = nil
|
||||
|
||||
@@ -943,10 +1278,10 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
if let preferredSelection,
|
||||
hasSelection(preferredSelection, chats: nextChats, searches: nextSearches) {
|
||||
hasSelection(preferredSelection, chats: chats, searches: searches) {
|
||||
selectedItem = preferredSelection
|
||||
} else if let existing = selectedItem,
|
||||
hasSelection(existing, chats: nextChats, searches: nextSearches) {
|
||||
hasSelection(existing, chats: chats, searches: searches) {
|
||||
selectedItem = existing
|
||||
} else {
|
||||
selectedItem = sidebarItems.first?.selection
|
||||
@@ -959,7 +1294,9 @@ final class SybilViewModel {
|
||||
attachToVisibleActiveRunIfNeeded()
|
||||
}
|
||||
} 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")
|
||||
} else {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
@@ -1038,6 +1375,34 @@ final class SybilViewModel {
|
||||
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() {
|
||||
guard draftKind == nil else {
|
||||
return
|
||||
@@ -1369,6 +1734,7 @@ final class SybilViewModel {
|
||||
|
||||
chats.removeAll(where: { $0.id == created.id })
|
||||
chats.insert(created, at: 0)
|
||||
upsertWorkspaceChat(created)
|
||||
|
||||
if shouldShowCreatedChat {
|
||||
draftKind = nil
|
||||
@@ -1445,6 +1811,7 @@ final class SybilViewModel {
|
||||
}
|
||||
return existing
|
||||
}
|
||||
self.upsertWorkspaceChat(updated, moveToFront: false)
|
||||
|
||||
if self.selectedChat?.id == updated.id {
|
||||
self.selectedChat?.title = updated.title
|
||||
@@ -1582,6 +1949,7 @@ final class SybilViewModel {
|
||||
|
||||
searches.removeAll(where: { $0.id == created.id })
|
||||
searches.insert(created, at: 0)
|
||||
upsertWorkspaceSearch(created)
|
||||
|
||||
if shouldShowCreatedSearch {
|
||||
draftKind = nil
|
||||
@@ -1752,6 +2120,15 @@ final class SybilViewModel {
|
||||
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) {
|
||||
guard var pending = pendingChatStates[chatID] else {
|
||||
return
|
||||
@@ -1761,6 +2138,31 @@ final class SybilViewModel {
|
||||
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([
|
||||
"kind": .string("tool_call"),
|
||||
"toolCallId": .string(payload.toolCallId),
|
||||
@@ -1779,7 +2181,7 @@ final class SybilViewModel {
|
||||
? "Ran tool '\(payload.name)'."
|
||||
: payload.summary
|
||||
|
||||
let message = Message(
|
||||
return Message(
|
||||
id: "temp-tool-\(payload.toolCallId)",
|
||||
createdAt: Date(),
|
||||
role: .tool,
|
||||
@@ -1787,14 +2189,6 @@ final class SybilViewModel {
|
||||
name: payload.name,
|
||||
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? {
|
||||
|
||||
@@ -232,13 +232,7 @@ struct SybilWorkspaceView: View {
|
||||
HStack(spacing: 14) {
|
||||
workspaceNavigationLeadingControl
|
||||
|
||||
Text(viewModel.selectedTitle)
|
||||
.font(.sybil(size: 16, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
customWorkspaceNavigationTitle
|
||||
|
||||
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
|
||||
private var workspaceNavigationLeadingControl: some View {
|
||||
switch navigationLeadingControl {
|
||||
@@ -495,7 +515,7 @@ struct SybilWorkspaceView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(Provider.allCases, id: \.self) { candidate in
|
||||
ForEach(viewModel.providerOptions, id: \.self) { candidate in
|
||||
Menu(candidate.displayName) {
|
||||
let models = viewModel.modelOptions(for: candidate)
|
||||
if models.isEmpty {
|
||||
@@ -703,9 +723,7 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
if !viewModel.isSearchMode {
|
||||
composerFocused = false
|
||||
}
|
||||
#endif
|
||||
|
||||
Task {
|
||||
|
||||
@@ -4,26 +4,42 @@ import Testing
|
||||
@testable import Sybil
|
||||
|
||||
private struct MockClientCallSnapshot: Sendable {
|
||||
var listWorkspaceItems = 0
|
||||
var listChats = 0
|
||||
var listSearches = 0
|
||||
var createChat = 0
|
||||
var getChat = 0
|
||||
var getSearch = 0
|
||||
var getActiveRuns = 0
|
||||
var runCompletionStream = 0
|
||||
var attachCompletionStream = 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 actor MockSybilClient: SybilAPIClienting {
|
||||
private let chatsResponse: [ChatSummary]
|
||||
private let searchesResponse: [SearchSummary]
|
||||
private let workspaceItemsResponse: [WorkspaceItem]
|
||||
private let chatDetails: [String: ChatDetail]
|
||||
private let searchDetails: [String: SearchDetail]
|
||||
private let createChatResponse: ChatSummary?
|
||||
private let activeRunsResponse: ActiveRunsResponse
|
||||
|
||||
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 getSearchDelayNanoseconds: UInt64 = 0
|
||||
private var completionStreamNetworkErrorMessage: String?
|
||||
@@ -41,25 +57,49 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
chatDetails: [String: ChatDetail] = [:],
|
||||
searchDetails: [String: SearchDetail] = [:],
|
||||
createChatResponse: ChatSummary? = nil,
|
||||
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse()
|
||||
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
||||
workspaceItemsResponse: [WorkspaceItem]? = nil
|
||||
) {
|
||||
self.chatsResponse = chatsResponse
|
||||
self.searchesResponse = searchesResponse
|
||||
self.workspaceItemsResponse = workspaceItemsResponse ?? Self.makeWorkspaceItems(chats: chatsResponse, searches: searchesResponse)
|
||||
self.chatDetails = chatDetails
|
||||
self.searchDetails = searchDetails
|
||||
self.createChatResponse = createChatResponse
|
||||
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 {
|
||||
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) {
|
||||
completionStreamNetworkErrorMessage = message
|
||||
completionStreamDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
|
||||
func setListDelays(chats: UInt64 = 0, searches: UInt64 = 0) {
|
||||
listChatsDelayNanoseconds = chats
|
||||
listSearchesDelayNanoseconds = searches
|
||||
}
|
||||
|
||||
func setGetChatDelay(_ delayNanoseconds: UInt64) {
|
||||
getChatDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
@@ -95,12 +135,36 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
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] {
|
||||
snapshot.listChats += 1
|
||||
if listChatsDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: listChatsDelayNanoseconds)
|
||||
}
|
||||
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 {
|
||||
return createChatResponse
|
||||
}
|
||||
@@ -128,6 +192,9 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
|
||||
func listSearches() async throws -> [SearchSummary] {
|
||||
snapshot.listSearches += 1
|
||||
if listSearchesDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: listSearchesDelayNanoseconds)
|
||||
}
|
||||
return searchesResponse
|
||||
}
|
||||
|
||||
@@ -167,12 +234,20 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
body: CompletionStreamRequest,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
) async throws {
|
||||
snapshot.runCompletionStream += 1
|
||||
lastCompletionStreamBody = body
|
||||
if completionStreamDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
|
||||
}
|
||||
if let completionStreamNetworkErrorMessage {
|
||||
throw APIError.networkError(message: completionStreamNetworkErrorMessage)
|
||||
}
|
||||
if let completionStreamEvents {
|
||||
for event in completionStreamEvents {
|
||||
await onEvent(event)
|
||||
}
|
||||
return
|
||||
}
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
|
||||
@@ -331,13 +406,41 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.listChats == 1)
|
||||
#expect(snapshot.listSearches == 1)
|
||||
#expect(snapshot.listWorkspaceItems == 1)
|
||||
#expect(snapshot.listChats == 0)
|
||||
#expect(snapshot.listSearches == 0)
|
||||
#expect(snapshot.getChat == 0)
|
||||
#expect(snapshot.getSearch == 0)
|
||||
#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
|
||||
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
|
||||
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)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.listWorkspaceItems == 0)
|
||||
#expect(snapshot.listChats == 0)
|
||||
#expect(snapshot.listSearches == 0)
|
||||
#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)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.listWorkspaceItems == 0)
|
||||
#expect(snapshot.listChats == 0)
|
||||
#expect(snapshot.listSearches == 0)
|
||||
#expect(snapshot.getSearch == 1)
|
||||
@@ -470,6 +575,117 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
||||
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
|
||||
@Test func reconnectAttachesSelectedActiveChatStream() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_260)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Sybil Server
|
||||
|
||||
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)
|
||||
|
||||
## Stack
|
||||
@@ -43,6 +43,9 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
|
||||
- `OPENAI_API_KEY`
|
||||
- `ANTHROPIC_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`
|
||||
- `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`)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Chat" ADD COLUMN "additionalSystemPrompt" TEXT;
|
||||
ALTER TABLE "Chat" ADD COLUMN "enabledTools" JSONB;
|
||||
@@ -13,6 +13,7 @@ enum Provider {
|
||||
openai
|
||||
anthropic
|
||||
xai
|
||||
hermes_agent @map("hermes-agent")
|
||||
}
|
||||
|
||||
enum MessageRole {
|
||||
@@ -50,6 +51,9 @@ model Chat {
|
||||
lastUsedProvider Provider?
|
||||
lastUsedModel String?
|
||||
|
||||
additionalSystemPrompt String?
|
||||
enabledTools Json?
|
||||
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
|
||||
|
||||
@@ -11,6 +11,13 @@ const OptionalUrlSchema = z.preprocess(
|
||||
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(
|
||||
(value) => {
|
||||
if (typeof value !== "string") return value;
|
||||
@@ -59,6 +66,9 @@ const EnvSchema = z.object({
|
||||
OPENAI_API_KEY: z.string().optional(),
|
||||
ANTHROPIC_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(),
|
||||
|
||||
// Chat-mode web_search tool configuration. Search mode remains Exa-only for now.
|
||||
|
||||
@@ -5,7 +5,7 @@ import swaggerUI from "@fastify/swagger-ui";
|
||||
import sensible from "@fastify/sensible";
|
||||
import { env } from "./env.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";
|
||||
|
||||
const app = Fastify({
|
||||
@@ -21,6 +21,7 @@ const app = Fastify({
|
||||
|
||||
await ensureDatabaseReady(app.log);
|
||||
await warmModelCatalog(app.log);
|
||||
const stopModelCatalogRefreshLoop = startModelCatalogRefreshLoop(app.log);
|
||||
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
@@ -80,6 +81,10 @@ app.setErrorHandler((err, req, reply) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.addHook("onClose", async () => {
|
||||
stopModelCatalogRefreshLoop();
|
||||
});
|
||||
|
||||
await registerRoutes(app);
|
||||
|
||||
await app.listen({ port: env.PORT, host: env.HOST });
|
||||
|
||||
@@ -9,7 +9,11 @@ import { z } from "zod";
|
||||
import { env } from "../env.js";
|
||||
import { exaClient } from "../search/exa.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";
|
||||
|
||||
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] : []),
|
||||
];
|
||||
|
||||
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;
|
||||
return {
|
||||
type: "function",
|
||||
@@ -198,6 +238,7 @@ const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => {
|
||||
strict: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const CHAT_TOOL_SYSTEM_PROMPT =
|
||||
"You can use tools to gather up-to-date web information when needed. " +
|
||||
@@ -239,6 +280,8 @@ type ToolAwareCompletionParams = {
|
||||
client: OpenAI;
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>;
|
||||
@@ -379,16 +422,38 @@ function extractHtmlTitle(html: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeIncomingMessages(messages: ChatMessage[]) {
|
||||
const normalized = messages.map((message) => buildOpenAIConversationMessage(message));
|
||||
|
||||
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
|
||||
function buildChatToolSystemPrompt(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||
const enabled = getEnabledToolSet(params);
|
||||
return (
|
||||
"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));
|
||||
|
||||
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> {
|
||||
@@ -853,6 +918,20 @@ function extractResponsesText(response: any, 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) {
|
||||
if (!finalText) return "";
|
||||
if (!streamedText) return finalText;
|
||||
@@ -939,7 +1018,8 @@ async function executeToolCallAndBuildEvent(
|
||||
}
|
||||
|
||||
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 toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
@@ -953,7 +1033,7 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
|
||||
input,
|
||||
temperature: params.temperature,
|
||||
max_output_tokens: params.maxTokens,
|
||||
tools: RESPONSES_CHAT_TOOLS,
|
||||
tools: toResponsesChatTools(enabledTools),
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: true,
|
||||
// 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> {
|
||||
const conversation: any[] = normalizeIncomingMessages(params.messages);
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
@@ -1022,7 +1103,7 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
|
||||
messages: conversation,
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
tools: CHAT_TOOLS,
|
||||
tools: enabledTools,
|
||||
tool_choice: "auto",
|
||||
} as any);
|
||||
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(
|
||||
params: ToolAwareCompletionParams
|
||||
): 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 toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
@@ -1110,7 +1212,7 @@ export async function* runToolAwareOpenAIChatStream(
|
||||
input,
|
||||
temperature: params.temperature,
|
||||
max_output_tokens: params.maxTokens,
|
||||
tools: RESPONSES_CHAT_TOOLS,
|
||||
tools: toResponsesChatTools(enabledTools),
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: true,
|
||||
// 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(
|
||||
params: ToolAwareCompletionParams
|
||||
): 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 toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
@@ -1236,7 +1339,7 @@ export async function* runToolAwareChatCompletionsStream(
|
||||
messages: conversation,
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
tools: CHAT_TOOLS,
|
||||
tools: enabledTools,
|
||||
tool_choice: "auto",
|
||||
stream: 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: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
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) {
|
||||
return value.replace(/"/g, """);
|
||||
}
|
||||
@@ -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 =
|
||||
"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[]) {
|
||||
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, messages.find((message) => message.role === "system")?.content]
|
||||
export function getAnthropicSystemPrompt(messages: ChatMessage[], userLocation?: string) {
|
||||
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, buildSystemPromptAugmentation(userLocation), messages.find((message) => message.role === "system")?.content]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
|
||||
export type ProviderModelSnapshot = {
|
||||
@@ -8,10 +9,11 @@ export type ProviderModelSnapshot = {
|
||||
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_CATALOG_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const modelCatalog: ModelCatalogSnapshot = {
|
||||
openai: { models: [], loadedAt: null, error: null },
|
||||
@@ -19,6 +21,12 @@ const modelCatalog: ModelCatalogSnapshot = {
|
||||
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[]) {
|
||||
return [...new Set(models.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
@@ -59,10 +67,17 @@ async function fetchProviderModels(provider: Provider) {
|
||||
return uniqSorted(page.data.map((model) => model.id));
|
||||
}
|
||||
|
||||
if (provider === "xai") {
|
||||
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) {
|
||||
try {
|
||||
const models = await withTimeout(fetchProviderModels(provider), MODEL_FETCH_TIMEOUT_MS, `${provider} model fetch`);
|
||||
@@ -74,35 +89,53 @@ async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLog
|
||||
logger?.info({ provider, modelCount: models.length }, "model catalog loaded");
|
||||
} catch (err: any) {
|
||||
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] = {
|
||||
models: [],
|
||||
loadedAt: new Date().toISOString(),
|
||||
models: previous?.models.length ? previous.models : fallbackModels,
|
||||
loadedAt: previous?.loadedAt ?? null,
|
||||
error: message,
|
||||
};
|
||||
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) {
|
||||
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 {
|
||||
return {
|
||||
openai: {
|
||||
models: [...modelCatalog.openai.models],
|
||||
loadedAt: modelCatalog.openai.loadedAt,
|
||||
error: modelCatalog.openai.error,
|
||||
},
|
||||
anthropic: {
|
||||
models: [...modelCatalog.anthropic.models],
|
||||
loadedAt: modelCatalog.anthropic.loadedAt,
|
||||
error: modelCatalog.anthropic.error,
|
||||
},
|
||||
xai: {
|
||||
models: [...modelCatalog.xai.models],
|
||||
loadedAt: modelCatalog.xai.loadedAt,
|
||||
error: modelCatalog.xai.error,
|
||||
},
|
||||
const snapshot: ModelCatalogSnapshot = {};
|
||||
for (const provider of getCatalogProviders()) {
|
||||
const entry = modelCatalog[provider] ?? { models: [], loadedAt: null, error: null };
|
||||
snapshot[provider] = {
|
||||
models: [...entry.models],
|
||||
loadedAt: entry.loadedAt,
|
||||
error: entry.error,
|
||||
};
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { prisma } from "../db.js";
|
||||
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
|
||||
import { buildToolLogMessageData, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
|
||||
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
||||
import { buildToolLogMessageData, normalizeEnabledChatTools, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
|
||||
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
||||
import { toPrismaProvider } from "./provider-ids.js";
|
||||
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
|
||||
|
||||
function asProviderEnum(p: Provider) {
|
||||
// Prisma enum values match these strings.
|
||||
return p;
|
||||
return toPrismaProvider(p);
|
||||
}
|
||||
|
||||
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 raw: unknown;
|
||||
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 r = await runToolAwareOpenAIChat({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
enabledTools,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
@@ -66,12 +69,14 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
||||
outText = r.text;
|
||||
usage = r.usage;
|
||||
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 r = await runToolAwareChatCompletions({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
enabledTools,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
@@ -84,10 +89,28 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
||||
outText = r.text;
|
||||
usage = r.usage;
|
||||
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") {
|
||||
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 r = await client.messages.create({
|
||||
|
||||
31
server/src/llm/provider-ids.ts
Normal file
31
server/src/llm/provider-ids.ts
Normal 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;
|
||||
}
|
||||
@@ -13,6 +13,18 @@ export function xaiClient() {
|
||||
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() {
|
||||
if (!env.ANTHROPIC_API_KEY) throw new Error("ANTHROPIC_API_KEY not set");
|
||||
return new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { prisma } from "../db.js";
|
||||
import { anthropicClient, openaiClient, xaiClient } from "./providers.js";
|
||||
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
||||
import {
|
||||
buildToolLogMessageData,
|
||||
normalizeEnabledChatTools,
|
||||
runPlainChatCompletionsStream,
|
||||
runToolAwareChatCompletionsStream,
|
||||
runToolAwareOpenAIChatStream,
|
||||
type ToolExecutionEvent,
|
||||
} from "./chat-tools.js";
|
||||
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
||||
import { toPrismaProvider } from "./provider-ids.js";
|
||||
import type { MultiplexRequest, Provider } from "./types.js";
|
||||
|
||||
type StreamUsage = {
|
||||
@@ -38,7 +41,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
? await prisma.llmCall.create({
|
||||
data: {
|
||||
chatId,
|
||||
provider: req.provider as any,
|
||||
provider: toPrismaProvider(req.provider) as any,
|
||||
model: req.model,
|
||||
request: req as any,
|
||||
},
|
||||
@@ -51,14 +54,14 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
prisma.chat.update({
|
||||
where: { id: chatId },
|
||||
data: {
|
||||
lastUsedProvider: req.provider as any,
|
||||
lastUsedProvider: toPrismaProvider(req.provider) as any,
|
||||
lastUsedModel: req.model,
|
||||
},
|
||||
}),
|
||||
prisma.chat.updateMany({
|
||||
where: { id: chatId, initiatedProvider: null },
|
||||
data: {
|
||||
initiatedProvider: req.provider as any,
|
||||
initiatedProvider: toPrismaProvider(req.provider) as any,
|
||||
initiatedModel: req.model,
|
||||
},
|
||||
}),
|
||||
@@ -72,14 +75,31 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
let raw: unknown = { streamed: true };
|
||||
|
||||
try {
|
||||
if (req.provider === "openai" || req.provider === "xai") {
|
||||
const client = req.provider === "openai" ? openaiClient() : xaiClient();
|
||||
if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
|
||||
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
|
||||
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
|
||||
const streamEvents =
|
||||
req.provider === "openai"
|
||||
req.provider === "openai" && enabledTools.length > 0
|
||||
? runToolAwareOpenAIChatStream({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
enabledTools,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
provider: req.provider,
|
||||
model: req.model,
|
||||
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: {
|
||||
@@ -92,6 +112,8 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
enabledTools,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
@@ -131,7 +153,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
} else if (req.provider === "anthropic") {
|
||||
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 stream = await client.messages.create({
|
||||
|
||||
@@ -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 = {
|
||||
kind: "image";
|
||||
@@ -34,6 +36,9 @@ export type MultiplexRequest = {
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
additionalSystemPrompt?: string;
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
|
||||
@@ -8,11 +8,17 @@ import { env } from "./env.js";
|
||||
import { buildComparableAttachments } from "./llm/message-content.js";
|
||||
import { runMultiplex } from "./llm/multiplexer.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 { openaiClient } from "./llm/providers.js";
|
||||
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
||||
import { exaClient } from "./search/exa.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 = {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
@@ -44,6 +50,43 @@ function isToolCallLogMessage(message: { role: string; metadata: unknown }) {
|
||||
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[]) {
|
||||
const incoming = messages.filter((m) => m.role !== "assistant");
|
||||
if (!incoming.length) return;
|
||||
@@ -125,9 +168,12 @@ const CompletionStreamBody = z
|
||||
.object({
|
||||
chatId: z.string().optional(),
|
||||
persist: z.boolean().optional(),
|
||||
provider: z.enum(["openai", "anthropic", "xai"]),
|
||||
provider: ProviderSchema,
|
||||
model: z.string().min(1),
|
||||
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(),
|
||||
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({
|
||||
query: 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);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (reply.raw.destroyed || reply.raw.writableEnded) return;
|
||||
reply.raw.write(`event: ${event.event}\n`);
|
||||
@@ -567,6 +683,11 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
return { providers: getModelCatalogSnapshot() };
|
||||
});
|
||||
|
||||
app.get("/v1/chat-tools", async (req) => {
|
||||
requireAdmin(req);
|
||||
return { tools: getAvailableChatTools() };
|
||||
});
|
||||
|
||||
app.get("/v1/active-runs", async (req) => {
|
||||
requireAdmin(req);
|
||||
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) => {
|
||||
requireAdmin(req);
|
||||
const chats = await prisma.chat.findMany({
|
||||
@@ -589,9 +715,11 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
additionalSystemPrompt: true,
|
||||
enabledTools: true,
|
||||
},
|
||||
});
|
||||
return { chats };
|
||||
return { chats: chats.map((chat) => serializeProviderFields(chat)) };
|
||||
});
|
||||
|
||||
app.post("/v1/chats", async (req) => {
|
||||
@@ -599,8 +727,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
const Body = z
|
||||
.object({
|
||||
title: z.string().optional(),
|
||||
provider: z.enum(["openai", "anthropic", "xai"]).optional(),
|
||||
provider: ProviderSchema.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(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
@@ -625,10 +755,12 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
const chat = await prisma.chat.create({
|
||||
data: {
|
||||
title: body.title,
|
||||
initiatedProvider: body.provider as any,
|
||||
initiatedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
|
||||
initiatedModel: body.model,
|
||||
lastUsedProvider: body.provider as any,
|
||||
lastUsedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
|
||||
lastUsedModel: body.model,
|
||||
additionalSystemPrompt: normalizeAdditionalSystemPrompt(body.additionalSystemPrompt),
|
||||
enabledTools: body.enabledTools as any,
|
||||
messages: body.messages?.length
|
||||
? {
|
||||
create: body.messages.map((message) => ({
|
||||
@@ -649,21 +781,32 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
additionalSystemPrompt: true,
|
||||
enabledTools: true,
|
||||
},
|
||||
});
|
||||
return { chat };
|
||||
return { chat: serializeProviderFields(chat) };
|
||||
});
|
||||
|
||||
app.patch("/v1/chats/:chatId", async (req) => {
|
||||
requireAdmin(req);
|
||||
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 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({
|
||||
where: { id: chatId },
|
||||
data: { title: body.title },
|
||||
data: data as any,
|
||||
});
|
||||
|
||||
if (updated.count === 0) return app.httpErrors.notFound("chat not found");
|
||||
@@ -679,10 +822,12 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
additionalSystemPrompt: true,
|
||||
enabledTools: true,
|
||||
},
|
||||
});
|
||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||
return { chat };
|
||||
return { chat: serializeProviderFields(chat) };
|
||||
});
|
||||
|
||||
app.post("/v1/chats/title/suggest", async (req) => {
|
||||
@@ -704,10 +849,12 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
additionalSystemPrompt: true,
|
||||
enabledTools: true,
|
||||
},
|
||||
});
|
||||
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 suggestedRaw = await generateChatTitle(body.content);
|
||||
@@ -725,10 +872,12 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
additionalSystemPrompt: true,
|
||||
enabledTools: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { chat };
|
||||
return { chat: serializeProviderFields(chat) };
|
||||
});
|
||||
|
||||
app.delete("/v1/chats/:chatId", async (req) => {
|
||||
@@ -845,10 +994,12 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
additionalSystemPrompt: true,
|
||||
enabledTools: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { chat };
|
||||
return { chat: serializeProviderFields(chat) };
|
||||
});
|
||||
|
||||
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" } } },
|
||||
});
|
||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||
return { chat };
|
||||
return { chat: serializeProviderFields(chat) };
|
||||
});
|
||||
|
||||
app.post("/v1/chats/:chatId/messages", async (req) => {
|
||||
@@ -1041,16 +1192,19 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
const Body = z.object({
|
||||
chatId: z.string().optional(),
|
||||
provider: z.enum(["openai", "anthropic", "xai"]),
|
||||
provider: ProviderSchema,
|
||||
model: z.string().min(1),
|
||||
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(),
|
||||
maxTokens: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
const parsed = Body.safeParse(req.body);
|
||||
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
|
||||
if (body.chatId) {
|
||||
@@ -1063,7 +1217,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||
}
|
||||
|
||||
const result = await runMultiplex(body);
|
||||
const result = await runMultiplex(await applyStoredChatSettings(body));
|
||||
|
||||
return {
|
||||
chatId: body.chatId ?? null,
|
||||
@@ -1077,7 +1231,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
const parsed = CompletionStreamBody.safeParse(req.body);
|
||||
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
|
||||
if (body.chatId) {
|
||||
@@ -1094,14 +1248,14 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
if (activeChatStreams.has(body.chatId)) {
|
||||
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);
|
||||
}
|
||||
|
||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||
reply.raw.flushHeaders();
|
||||
|
||||
for await (const ev of runMultiplexStream(body)) {
|
||||
for await (const ev of runMultiplexStream(await applyStoredChatSettings(body))) {
|
||||
writeSseEvent(reply, mapChatStreamEvent(ev));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
runPlainChatCompletionsStream,
|
||||
runToolAwareChatCompletionsStream,
|
||||
runToolAwareOpenAIChatStream,
|
||||
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");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
26
server/tests/message-content.test.ts
Normal file
26
server/tests/message-content.test.ts
Normal 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\./);
|
||||
});
|
||||
12
server/tests/provider-ids.test.ts
Normal file
12
server/tests/provider-ids.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
@@ -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_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_SEARCH_NUM_RESULTS`: results per search run (default: `10`)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
SearchStreamHandlers,
|
||||
SearchSummary,
|
||||
SessionStatus,
|
||||
WorkspaceItem,
|
||||
} from "./types.js";
|
||||
|
||||
type RequestOptions = {
|
||||
@@ -41,6 +42,11 @@ export class SybilApiClient {
|
||||
return data.chats;
|
||||
}
|
||||
|
||||
async listWorkspaceItems() {
|
||||
const data = await this.request<{ items: WorkspaceItem[] }>("/v1/workspace-items");
|
||||
return data.items;
|
||||
}
|
||||
|
||||
async createChat(title?: string) {
|
||||
const data = await this.request<{ chat: ChatSummary }>("/v1/chats", {
|
||||
method: "POST",
|
||||
@@ -94,6 +100,7 @@ export class SybilApiClient {
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: CompletionRequestMessage[];
|
||||
userLocation?: string;
|
||||
},
|
||||
handlers: CompletionStreamHandlers,
|
||||
options?: { signal?: AbortSignal }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Provider } from "./types.js";
|
||||
|
||||
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
|
||||
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "hermes-agent"];
|
||||
|
||||
function normalizeBaseUrl(value: string) {
|
||||
const trimmed = value.trim();
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
SearchDetail,
|
||||
SearchSummary,
|
||||
ToolCallEvent,
|
||||
WorkspaceItem,
|
||||
} from "./types.js";
|
||||
|
||||
type SidebarSelection = { kind: "chat" | "search"; id: string };
|
||||
@@ -39,11 +40,13 @@ type ToolLogMetadata = {
|
||||
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[]> = {
|
||||
openai: ["gpt-4.1-mini"],
|
||||
anthropic: ["claude-3-5-sonnet-latest"],
|
||||
xai: ["grok-3-mini"],
|
||||
"hermes-agent": ["hermes-agent"],
|
||||
};
|
||||
|
||||
const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
||||
@@ -74,6 +77,7 @@ function getProviderLabel(provider: Provider | null | undefined) {
|
||||
if (provider === "openai") return "OpenAI";
|
||||
if (provider === "anthropic") return "Anthropic";
|
||||
if (provider === "xai") return "xAI";
|
||||
if (provider === "hermes-agent") return "Hermes Agent";
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -90,9 +94,38 @@ function getSearchTitle(search: Pick<SearchSummary, "title" | "query">) {
|
||||
return "New search";
|
||||
}
|
||||
|
||||
function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] {
|
||||
const items: SidebarItem[] = [
|
||||
...chats.map((chat) => ({
|
||||
function chatWorkspaceItem(chat: ChatSummary): WorkspaceItem {
|
||||
return { type: "chat", ...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,
|
||||
id: chat.id,
|
||||
title: getChatTitle(chat),
|
||||
@@ -102,8 +135,11 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
})),
|
||||
...searches.map((search) => ({
|
||||
};
|
||||
}
|
||||
|
||||
const search = item;
|
||||
return {
|
||||
kind: "search" as const,
|
||||
id: search.id,
|
||||
title: getSearchTitle(search),
|
||||
@@ -113,10 +149,8 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
|
||||
initiatedModel: null,
|
||||
lastUsedProvider: null,
|
||||
lastUsedModel: null,
|
||||
})),
|
||||
];
|
||||
|
||||
return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
|
||||
@@ -159,6 +193,10 @@ function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: P
|
||||
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) {
|
||||
if (fallback && options.includes(fallback)) return fallback;
|
||||
if (preferred && options.includes(preferred)) return preferred;
|
||||
@@ -188,6 +226,7 @@ async function main() {
|
||||
let authMode: "open" | "token" | null = null;
|
||||
let chats: ChatSummary[] = [];
|
||||
let searches: SearchSummary[] = [];
|
||||
let workspaceItems: WorkspaceItem[] = [];
|
||||
let selectedItem: SidebarSelection | null = null;
|
||||
let selectedChat: ChatDetail | null = null;
|
||||
let selectedSearch: SearchDetail | null = null;
|
||||
@@ -202,6 +241,7 @@ async function main() {
|
||||
openai: null,
|
||||
anthropic: null,
|
||||
xai: null,
|
||||
"hermes-agent": null,
|
||||
};
|
||||
let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null);
|
||||
let errorMessage: string | null = null;
|
||||
@@ -369,7 +409,7 @@ async function main() {
|
||||
}
|
||||
|
||||
function getSidebarItems() {
|
||||
return buildSidebarItems(chats, searches);
|
||||
return buildSidebarItems(workspaceItems);
|
||||
}
|
||||
|
||||
function getSelectedChatSummary() {
|
||||
@@ -693,6 +733,7 @@ async function main() {
|
||||
function resetWorkspaceState() {
|
||||
chats = [];
|
||||
searches = [];
|
||||
workspaceItems = [];
|
||||
selectedItem = null;
|
||||
selectedChat = null;
|
||||
selectedSearch = null;
|
||||
@@ -759,11 +800,13 @@ async function main() {
|
||||
updateUI();
|
||||
|
||||
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;
|
||||
searches = nextSearches;
|
||||
|
||||
const nextItems = buildSidebarItems(nextChats, nextSearches);
|
||||
const nextItems = buildSidebarItems(nextWorkspaceItems);
|
||||
if (options?.preferredSelection && hasItem(nextItems, options.preferredSelection)) {
|
||||
selectedItem = options.preferredSelection;
|
||||
draftKind = null;
|
||||
@@ -868,6 +911,7 @@ async function main() {
|
||||
try {
|
||||
const updated = await api.suggestChatTitle({ chatId, content });
|
||||
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) {
|
||||
selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt };
|
||||
}
|
||||
@@ -912,6 +956,7 @@ async function main() {
|
||||
chatId = chat.id;
|
||||
draftKind = null;
|
||||
chats = [chat, ...chats.filter((existing) => existing.id !== chat.id)];
|
||||
workspaceItems = upsertWorkspaceItem(workspaceItems, chatWorkspaceItem(chat));
|
||||
selectedItem = { kind: "chat", id: chat.id };
|
||||
pendingChatState = pendingChatState ? { ...pendingChatState, chatId } : pendingChatState;
|
||||
selectedChat = {
|
||||
@@ -1077,6 +1122,7 @@ async function main() {
|
||||
draftKind = null;
|
||||
selectedItem = { kind: "search", id: searchId };
|
||||
searches = [search, ...searches.filter((existing) => existing.id !== search.id)];
|
||||
workspaceItems = upsertWorkspaceItem(workspaceItems, searchWorkspaceItem(search));
|
||||
selectedChat = null;
|
||||
forceScrollToBottom = true;
|
||||
updateUI();
|
||||
@@ -1257,8 +1303,10 @@ async function main() {
|
||||
}
|
||||
|
||||
function cycleProvider() {
|
||||
const currentIndex = PROVIDERS.indexOf(provider);
|
||||
const nextProvider: Provider = PROVIDERS[(currentIndex + 1) % PROVIDERS.length] ?? "openai";
|
||||
const visibleProviders = getVisibleProviders(modelCatalog);
|
||||
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;
|
||||
syncModelForProvider();
|
||||
updateUI();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type Provider = "openai" | "anthropic" | "xai";
|
||||
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
|
||||
|
||||
export type ProviderModelInfo = {
|
||||
models: string[];
|
||||
@@ -7,7 +7,7 @@ export type ProviderModelInfo = {
|
||||
};
|
||||
|
||||
export type ModelCatalogResponse = {
|
||||
providers: Record<Provider, ProviderModelInfo>;
|
||||
providers: Partial<Record<Provider, ProviderModelInfo>>;
|
||||
};
|
||||
|
||||
export type ChatSummary = {
|
||||
@@ -29,6 +29,16 @@ export type SearchSummary = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ChatWorkspaceItem = ChatSummary & {
|
||||
type: "chat";
|
||||
};
|
||||
|
||||
export type SearchWorkspaceItem = SearchSummary & {
|
||||
type: "search";
|
||||
};
|
||||
|
||||
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem;
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
|
||||
276
web/src/App.tsx
276
web/src/App.tsx
@@ -1,5 +1,20 @@
|
||||
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 { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -18,13 +33,14 @@ import {
|
||||
attachSearchStream,
|
||||
getActiveRuns,
|
||||
getChat,
|
||||
listChatTools,
|
||||
listModels,
|
||||
getSearch,
|
||||
listChats,
|
||||
listSearches,
|
||||
listWorkspaceItems,
|
||||
runCompletionStream,
|
||||
runSearchStream,
|
||||
suggestChatTitle,
|
||||
updateChatSettings,
|
||||
getMessageAttachments,
|
||||
type ChatAttachment,
|
||||
type ActiveRunsResponse,
|
||||
@@ -32,11 +48,13 @@ import {
|
||||
type Provider,
|
||||
type ChatDetail,
|
||||
type ChatSummary,
|
||||
type ChatToolInfo,
|
||||
type CompletionRequestMessage,
|
||||
type Message,
|
||||
type SearchDetail,
|
||||
type SearchSummary,
|
||||
type ToolCallEvent,
|
||||
type WorkspaceItem,
|
||||
} from "@/lib/api";
|
||||
import { useSessionAuth } from "@/hooks/use-session-auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -95,6 +113,7 @@ const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
||||
openai: ["gpt-4.1-mini"],
|
||||
anthropic: ["claude-3-5-sonnet-latest"],
|
||||
xai: ["grok-3-mini"],
|
||||
"hermes-agent": ["hermes-agent"],
|
||||
};
|
||||
|
||||
const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
||||
@@ -103,6 +122,9 @@ const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
||||
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 QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection";
|
||||
|
||||
@@ -117,6 +139,7 @@ const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = {
|
||||
openai: null,
|
||||
anthropic: null,
|
||||
xai: null,
|
||||
"hermes-agent": null,
|
||||
};
|
||||
const EMPTY_ACTIVE_RUNS: ActiveRunsState = {
|
||||
chats: {},
|
||||
@@ -193,6 +216,10 @@ function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: P
|
||||
return PROVIDER_FALLBACK_MODELS[provider];
|
||||
}
|
||||
|
||||
function getVisibleProviders(catalog: ModelCatalogResponse["providers"]) {
|
||||
return ALL_PROVIDERS.filter((provider) => provider !== "hermes-agent" || catalog[provider] !== undefined);
|
||||
}
|
||||
|
||||
function getReplyScrollBufferHeight() {
|
||||
if (typeof window === "undefined") return REPLY_SCROLL_BUFFER_MIN;
|
||||
return Math.min(
|
||||
@@ -308,6 +335,8 @@ function loadStoredModelPreferences() {
|
||||
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.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,
|
||||
"hermes-agent":
|
||||
typeof parsed["hermes-agent"] === "string" && parsed["hermes-agent"].trim() ? parsed["hermes-agent"].trim() : null,
|
||||
};
|
||||
} catch {
|
||||
return EMPTY_MODEL_PREFERENCES;
|
||||
@@ -315,7 +344,7 @@ function loadStoredModelPreferences() {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -325,6 +354,8 @@ function normalizeStoredModelPreferences(value: unknown): ProviderModelPreferenc
|
||||
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.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,
|
||||
"hermes-agent":
|
||||
typeof parsed["hermes-agent"] === "string" && parsed["hermes-agent"].trim() ? parsed["hermes-agent"].trim() : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -354,9 +385,34 @@ function getProviderLabel(provider: Provider | null | undefined) {
|
||||
if (provider === "openai") return "OpenAI";
|
||||
if (provider === "anthropic") return "Anthropic";
|
||||
if (provider === "xai") return "xAI";
|
||||
if (provider === "hermes-agent") return "Hermes Agent";
|
||||
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) {
|
||||
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
|
||||
return {
|
||||
@@ -574,9 +630,34 @@ function getSearchTitle(search: Pick<SearchSummary, "title" | "query">) {
|
||||
return "New search";
|
||||
}
|
||||
|
||||
function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] {
|
||||
const items: SidebarItem[] = [
|
||||
...chats.map((chat) => ({
|
||||
function chatWorkspaceItem(chat: ChatSummary): WorkspaceItem {
|
||||
return { type: "chat", ...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 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),
|
||||
@@ -586,8 +667,11 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
})),
|
||||
...searches.map((search) => ({
|
||||
};
|
||||
}
|
||||
|
||||
const search = item;
|
||||
return {
|
||||
kind: "search" as const,
|
||||
id: search.id,
|
||||
title: getSearchTitle(search),
|
||||
@@ -597,10 +681,21 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
|
||||
initiatedModel: null,
|
||||
lastUsedProvider: 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 {
|
||||
@@ -661,6 +756,7 @@ export default function App() {
|
||||
|
||||
const [chats, setChats] = useState<ChatSummary[]>([]);
|
||||
const [searches, setSearches] = useState<SearchSummary[]>([]);
|
||||
const [workspaceItems, setWorkspaceItems] = useState<WorkspaceItem[]>([]);
|
||||
const [selectedItem, setSelectedItem] = useState<SidebarSelection | null>(null);
|
||||
const [selectedChat, setSelectedChat] = useState<ChatDetail | null>(null);
|
||||
const [selectedSearch, setSelectedSearch] = useState<SearchDetail | null>(null);
|
||||
@@ -676,6 +772,7 @@ export default function App() {
|
||||
const [isComposerDropActive, setIsComposerDropActive] = useState(false);
|
||||
const [provider, setProvider] = useState<Provider>("openai");
|
||||
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG);
|
||||
const [availableChatTools, setAvailableChatTools] = useState<ChatToolInfo[]>([]);
|
||||
const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences());
|
||||
const [model, setModel] = useState(() => {
|
||||
const stored = loadStoredModelPreferences();
|
||||
@@ -698,6 +795,9 @@ export default function App() {
|
||||
const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false);
|
||||
const [quickQuestionError, setQuickQuestionError] = 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 transcriptContainerRef = useRef<HTMLDivElement>(null);
|
||||
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||
@@ -715,6 +815,8 @@ export default function App() {
|
||||
const wasSendingRef = useRef(false);
|
||||
const pendingReplyScrollRef = useRef(false);
|
||||
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 [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||
const [sidebarQuery, setSidebarQuery] = useState("");
|
||||
@@ -739,6 +841,7 @@ export default function App() {
|
||||
const settleTranscriptTailSpacer = () => {
|
||||
const container = transcriptContainerRef.current;
|
||||
const currentSpacerHeight = transcriptTailSpacerHeightRef.current;
|
||||
if (currentSpacerHeight <= TRANSCRIPT_BOTTOM_GAP) return;
|
||||
if (!container) {
|
||||
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
|
||||
return;
|
||||
@@ -746,7 +849,22 @@ export default function App() {
|
||||
|
||||
const scrollHeightWithoutSpacer = container.scrollHeight - currentSpacerHeight;
|
||||
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 = () => {
|
||||
@@ -769,7 +887,7 @@ export default function App() {
|
||||
pendingAttachmentsRef.current = pendingAttachments;
|
||||
}, [pendingAttachments]);
|
||||
|
||||
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]);
|
||||
const sidebarItems = useMemo(() => buildSidebarItems(workspaceItems), [workspaceItems]);
|
||||
const filteredSidebarItems = useMemo(() => {
|
||||
const query = sidebarQuery.trim().toLowerCase();
|
||||
if (!query) return sidebarItems;
|
||||
@@ -785,6 +903,7 @@ export default function App() {
|
||||
const resetWorkspaceState = () => {
|
||||
setChats([]);
|
||||
setSearches([]);
|
||||
setWorkspaceItems([]);
|
||||
setSelectedItem(null);
|
||||
setSelectedChat(null);
|
||||
setSelectedSearch(null);
|
||||
@@ -803,6 +922,9 @@ export default function App() {
|
||||
searchRunCountersRef.current.clear();
|
||||
setComposer("");
|
||||
setPendingAttachments([]);
|
||||
setIsChatSettingsOpen(false);
|
||||
setAdditionalSystemPrompt("");
|
||||
setEnabledTools([]);
|
||||
setIsQuickQuestionOpen(false);
|
||||
setQuickPrompt("");
|
||||
setQuickSubmittedPrompt(null);
|
||||
@@ -820,15 +942,16 @@ export default function App() {
|
||||
const refreshCollections = async (preferredSelection?: SidebarSelection) => {
|
||||
setIsLoadingCollections(true);
|
||||
try {
|
||||
const [nextChats, nextSearches] = await Promise.all([listChats(), listSearches()]);
|
||||
const nextItems = buildSidebarItems(nextChats, nextSearches);
|
||||
const nextWorkspaceItems = await listWorkspaceItems();
|
||||
const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems);
|
||||
setWorkspaceItems(nextWorkspaceItems);
|
||||
setChats(nextChats);
|
||||
setSearches(nextSearches);
|
||||
|
||||
setSelectedItem((current) => {
|
||||
const hasItem = (candidate: SidebarSelection | null) => {
|
||||
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)) {
|
||||
@@ -837,8 +960,8 @@ export default function App() {
|
||||
if (hasItem(current)) {
|
||||
return current;
|
||||
}
|
||||
const first = nextItems[0];
|
||||
return first ? { kind: first.kind, id: first.id } : null;
|
||||
const first = nextWorkspaceItems[0];
|
||||
return first ? { kind: first.type, id: first.id } : null;
|
||||
});
|
||||
} catch (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 () => {
|
||||
try {
|
||||
const data = await getActiveRuns();
|
||||
@@ -918,7 +1056,7 @@ export default function App() {
|
||||
if (!isAuthenticated) return;
|
||||
const preferredSelection = initialRouteSelectionRef.current;
|
||||
initialRouteSelectionRef.current = null;
|
||||
void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshActiveRuns()]);
|
||||
void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshChatTools(), refreshActiveRuns()]);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -963,6 +1101,7 @@ export default function App() {
|
||||
|
||||
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
|
||||
const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]);
|
||||
const providerOptions = useMemo(() => getVisibleProviders(modelCatalog), [modelCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (model.trim()) return;
|
||||
@@ -1017,6 +1156,7 @@ export default function App() {
|
||||
}, [quickPrompt, isQuickQuestionOpen]);
|
||||
|
||||
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 selectedSearchRunState = selectedItem?.kind === "search" ? runningSearchStates[selectedItem.id] ?? null : null;
|
||||
const selectedChatIsActive = selectedItem?.kind === "chat" && (!!selectedChatPendingState || !!activeRuns.chats[selectedItem.id]);
|
||||
@@ -1038,11 +1178,13 @@ export default function App() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const didViewChange = transcriptViewKeyRef.current !== transcriptViewKey;
|
||||
transcriptViewKeyRef.current = transcriptViewKey;
|
||||
shouldAutoScrollRef.current = true;
|
||||
if (!isSendingActiveChat) {
|
||||
if (didViewChange && !pendingReplyScrollRef.current) {
|
||||
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
|
||||
}
|
||||
}, [isSendingActiveChat, selectedKey]);
|
||||
}, [transcriptViewKey]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedItemRef.current = selectedItem;
|
||||
@@ -1101,6 +1243,10 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (transcriptTailSpacerSettleFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(transcriptTailSpacerSettleFrameRef.current);
|
||||
transcriptTailSpacerSettleFrameRef.current = null;
|
||||
}
|
||||
for (const controller of chatStreamAbortRefs.current.values()) {
|
||||
controller.abort();
|
||||
}
|
||||
@@ -1129,6 +1275,16 @@ export default function App() {
|
||||
if (selectedChatPendingState) return selectedChatPendingState.messages.filter(isDisplayableMessage);
|
||||
return messages.filter(isDisplayableMessage);
|
||||
}, [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(() => {
|
||||
for (let index = quickQuestionMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = quickQuestionMessages[index];
|
||||
@@ -1162,6 +1318,19 @@ export default function App() {
|
||||
setModel(nextSelection.model);
|
||||
}, [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(() => {
|
||||
if (draftKind === "chat") return "New chat";
|
||||
if (draftKind === "search") return "New search";
|
||||
@@ -1465,6 +1634,11 @@ export default function App() {
|
||||
};
|
||||
|
||||
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;
|
||||
expandTranscriptTailSpacer(getReplyScrollBufferHeight());
|
||||
|
||||
@@ -1496,6 +1670,7 @@ export default function App() {
|
||||
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||
return [chat, ...withoutExisting];
|
||||
});
|
||||
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
|
||||
setSelectedItem({ kind: "chat", id: chatId });
|
||||
setSelectedChat({
|
||||
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 hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim());
|
||||
if (!hasExistingTitle && !pendingTitleGenerationRef.current.has(chatId)) {
|
||||
@@ -1566,6 +1736,7 @@ export default function App() {
|
||||
return { ...chat, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
|
||||
})
|
||||
);
|
||||
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(updatedChat), false));
|
||||
setSelectedChat((current) => {
|
||||
if (!current || current.id !== updatedChat.id) return current;
|
||||
return { ...current, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
|
||||
@@ -1672,13 +1843,17 @@ export default function App() {
|
||||
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
|
||||
await refreshChat(chatId);
|
||||
}
|
||||
settleTranscriptTailSpacer();
|
||||
removePendingChatState(chatId);
|
||||
removeActiveRun("chat", chatId);
|
||||
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
|
||||
requestSettleTranscriptTailSpacer();
|
||||
}
|
||||
return { kind: "chat", id: chatId };
|
||||
} catch (err) {
|
||||
removePendingChatState(chatId);
|
||||
removeActiveRun("chat", chatId);
|
||||
pendingReplyScrollRef.current = false;
|
||||
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -1694,6 +1869,11 @@ export default function App() {
|
||||
searchId = search.id;
|
||||
setDraftKind(null);
|
||||
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) {
|
||||
@@ -1939,6 +2119,9 @@ export default function App() {
|
||||
chatStreamAbortRefs.current.delete(chatId);
|
||||
removePendingChatState(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);
|
||||
return [chat, ...withoutExisting];
|
||||
});
|
||||
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
|
||||
setSelectedItem({ kind: "chat", id: chat.id });
|
||||
setSelectedChat({
|
||||
id: chat.id,
|
||||
@@ -2239,6 +2423,7 @@ export default function App() {
|
||||
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||
return [chat, ...withoutExisting];
|
||||
});
|
||||
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
|
||||
setSelectedItem({ kind: "chat", id: chat.id });
|
||||
setSelectedChat({
|
||||
id: chat.id,
|
||||
@@ -2288,6 +2473,10 @@ export default function App() {
|
||||
sentTarget = await handleSendChat(content, attachments);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!sentAsSearch) {
|
||||
pendingReplyScrollRef.current = false;
|
||||
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
@@ -2449,7 +2638,7 @@ export default function App() {
|
||||
onContextMenu={(event) => openContextMenu(event, { kind: item.kind, id: item.id })}
|
||||
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
|
||||
className={cn(
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border",
|
||||
@@ -2467,11 +2656,15 @@ export default function App() {
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span className={cn("ml-auto shrink-0 text-xs", active ? "text-violet-100/86" : "text-violet-200/50")}>{formatDate(item.updatedAt)}</span>
|
||||
</div>
|
||||
<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 ? (
|
||||
<p className={cn("mt-1 truncate text-right text-xs", active ? "text-violet-100/62" : "text-violet-200/42")}>{initiatedLabel}</p>
|
||||
<span className="ml-auto min-w-0 truncate text-right text-xs text-secondary-foreground/70">
|
||||
{initiatedLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -2512,9 +2705,11 @@ export default function App() {
|
||||
}}
|
||||
disabled={isActiveSelectionSending}
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="xai">xAI</option>
|
||||
{providerOptions.map((candidate) => (
|
||||
<option key={candidate} value={candidate}>
|
||||
{getProviderLabel(candidate)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ModelCombobox
|
||||
options={providerModelOptions}
|
||||
@@ -2547,6 +2742,9 @@ export default function App() {
|
||||
if (!container) return;
|
||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
shouldAutoScrollRef.current = distanceFromBottom < 96;
|
||||
if (!isSearchMode && !isSendingActiveChat && distanceFromBottom > 0) {
|
||||
settleTranscriptTailSpacer();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!isSearchMode ? (
|
||||
@@ -2758,9 +2956,11 @@ export default function App() {
|
||||
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
|
||||
aria-label="Quick question provider"
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="xai">xAI</option>
|
||||
{providerOptions.map((candidate) => (
|
||||
<option key={candidate} value={candidate}>
|
||||
{getProviderLabel(candidate)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ModelCombobox
|
||||
options={quickProviderModelOptions}
|
||||
|
||||
@@ -206,17 +206,31 @@ textarea {
|
||||
}
|
||||
|
||||
.md-content code {
|
||||
background: hsl(288 22% 23%);
|
||||
border-radius: 0.25rem;
|
||||
background: hsl(249 40% 10% / 0.78);
|
||||
border-radius: 0.3rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
font-size: 0.86em;
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
}
|
||||
|
||||
.md-content pre {
|
||||
overflow-x: auto;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(287 28% 13%);
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid hsl(253 31% 29% / 0.72);
|
||||
border-radius: 0.625rem;
|
||||
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 {
|
||||
|
||||
@@ -7,6 +7,8 @@ export type ChatSummary = {
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
lastUsedModel: string | null;
|
||||
additionalSystemPrompt: string | null;
|
||||
enabledTools: string[] | null;
|
||||
};
|
||||
|
||||
export type SearchSummary = {
|
||||
@@ -17,6 +19,16 @@ export type SearchSummary = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ChatWorkspaceItem = ChatSummary & {
|
||||
type: "chat";
|
||||
};
|
||||
|
||||
export type SearchWorkspaceItem = SearchSummary & {
|
||||
type: "search";
|
||||
};
|
||||
|
||||
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem;
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
@@ -48,6 +60,8 @@ export type ChatDetail = {
|
||||
initiatedModel: string | null;
|
||||
lastUsedProvider: Provider | null;
|
||||
lastUsedModel: string | null;
|
||||
additionalSystemPrompt: string | null;
|
||||
enabledTools: string[] | null;
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
@@ -127,7 +141,7 @@ export type CompletionRequestMessage = {
|
||||
attachments?: ChatAttachment[];
|
||||
};
|
||||
|
||||
export type Provider = "openai" | "anthropic" | "xai";
|
||||
export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent";
|
||||
|
||||
export type ProviderModelInfo = {
|
||||
models: string[];
|
||||
@@ -136,7 +150,12 @@ export type ProviderModelInfo = {
|
||||
};
|
||||
|
||||
export type ModelCatalogResponse = {
|
||||
providers: Record<Provider, ProviderModelInfo>;
|
||||
providers: Partial<Record<Provider, ProviderModelInfo>>;
|
||||
};
|
||||
|
||||
export type ChatToolInfo = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ActiveRunsResponse = {
|
||||
@@ -164,6 +183,8 @@ type CreateChatRequest = {
|
||||
title?: string;
|
||||
provider?: Provider;
|
||||
model?: string;
|
||||
additionalSystemPrompt?: string;
|
||||
enabledTools?: string[];
|
||||
messages?: CompletionRequestMessage[];
|
||||
};
|
||||
|
||||
@@ -214,6 +235,11 @@ export async function listChats() {
|
||||
return data.chats;
|
||||
}
|
||||
|
||||
export async function listWorkspaceItems() {
|
||||
const data = await api<{ items: WorkspaceItem[] }>("/v1/workspace-items");
|
||||
return data.items;
|
||||
}
|
||||
|
||||
export async function verifySession() {
|
||||
return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session");
|
||||
}
|
||||
@@ -222,6 +248,11 @@ export async function listModels() {
|
||||
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() {
|
||||
return api<ActiveRunsResponse>("/v1/active-runs");
|
||||
}
|
||||
@@ -248,6 +279,14 @@ export async function updateChatTitle(chatId: string, title: string) {
|
||||
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 }) {
|
||||
const data = await api<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
|
||||
method: "POST",
|
||||
@@ -554,6 +593,9 @@ export async function runCompletion(body: {
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: CompletionRequestMessage[];
|
||||
additionalSystemPrompt?: string;
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
}) {
|
||||
return api<CompletionResponse>("/v1/chat-completions", {
|
||||
method: "POST",
|
||||
@@ -568,6 +610,9 @@ export async function runCompletionStream(
|
||||
provider: Provider;
|
||||
model: string;
|
||||
messages: CompletionRequestMessage[];
|
||||
additionalSystemPrompt?: string;
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
},
|
||||
handlers: CompletionStreamHandlers,
|
||||
options?: { signal?: AbortSignal }
|
||||
|
||||
Reference in New Issue
Block a user