9 Commits

27 changed files with 950 additions and 352 deletions

View File

@@ -40,6 +40,7 @@ Chat upload limits:
``` ```
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation. - OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id. - `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
## Active Runs ## Active Runs
@@ -57,6 +58,42 @@ Behavior notes:
- Clients should use this after app start or page refresh to restore per-row generating indicators. - Clients should use this after app start or page refresh to restore per-row generating indicators.
- The lists are not durable across server restarts. - The lists are not durable across server restarts.
## Workspace Items
### `GET /v1/workspace-items`
- Response: `{ "items": WorkspaceItem[] }`
- `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending:
```json
{
"items": [
{
"type": "chat",
"id": "chat-id",
"title": "optional title",
"createdAt": "2026-02-14T00:00:00.000Z",
"updatedAt": "2026-02-14T00:00:00.000Z",
"initiatedProvider": "openai",
"initiatedModel": "gpt-4.1-mini",
"lastUsedProvider": "openai",
"lastUsedModel": "gpt-4.1-mini"
},
{
"type": "search",
"id": "search-id",
"title": "optional title",
"query": "search query",
"createdAt": "2026-02-14T00:00:00.000Z",
"updatedAt": "2026-02-14T00:00:00.000Z"
}
]
}
```
Behavior notes:
- This endpoint is intended for combined conversation/search lists such as sidebars.
- The legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections.
- The response currently combines up to 100 chats and up to 100 searches.
## Chats ## Chats
### `GET /v1/chats` ### `GET /v1/chats`

View File

@@ -24,8 +24,8 @@ targets:
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
INFOPLIST_FILE: Apps/Sybil/Info.plist INFOPLIST_FILE: Apps/Sybil/Info.plist
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
MARKETING_VERSION: 1.7 MARKETING_VERSION: 1.9
CURRENT_PROJECT_VERSION: 8 CURRENT_PROJECT_VERSION: 10
INFOPLIST_KEY_CFBundleDisplayName: Sybil INFOPLIST_KEY_CFBundleDisplayName: Sybil
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES

View File

@@ -44,6 +44,11 @@ actor SybilAPIClient: SybilAPIClienting {
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self) try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self)
} }
func listWorkspaceItems() async throws -> [WorkspaceItem] {
let response = try await request("/v1/workspace-items", method: "GET", responseType: WorkspaceListResponse.self)
return response.items
}
func listChats() async throws -> [ChatSummary] { func listChats() async throws -> [ChatSummary] {
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self) let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
return response.chats return response.chats
@@ -626,6 +631,7 @@ struct CompletionStreamRequest: Codable, Sendable {
var provider: Provider var provider: Provider
var model: String var model: String
var messages: [CompletionRequestMessage] var messages: [CompletionRequestMessage]
var userLocation: String? = nil
} }
private struct ChatCreateBody: Encodable { private struct ChatCreateBody: Encodable {

View File

@@ -2,6 +2,7 @@ import Foundation
protocol SybilAPIClienting: Sendable { protocol SybilAPIClienting: Sendable {
func verifySession() async throws -> AuthSession func verifySession() async throws -> AuthSession
func listWorkspaceItems() async throws -> [WorkspaceItem]
func listChats() async throws -> [ChatSummary] func listChats() async throws -> [ChatSummary]
func createChat( func createChat(
title: String?, title: String?,

View File

@@ -7,9 +7,6 @@ struct SybilChatTranscriptView: View {
var isSending: Bool var isSending: Bool
var topContentInset: CGFloat = 0 var topContentInset: CGFloat = 0
var bottomContentInset: CGFloat = 0 var bottomContentInset: CGFloat = 0
var tailSpacerHeight: CGFloat = 0
var onViewportHeightChange: ((CGFloat) -> Void)? = nil
var onPendingAssistantHeightChange: ((CGFloat) -> Void)? = nil
private var hasPendingAssistant: Bool { private var hasPendingAssistant: Bool {
messages.contains { message in messages.contains { message in
@@ -23,16 +20,6 @@ struct SybilChatTranscriptView: View {
ForEach(messages.reversed()) { message in ForEach(messages.reversed()) { message in
MessageBubble(message: message, isSending: isSending) MessageBubble(message: message, isSending: isSending)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background {
if isStreamingPendingAssistant(message) {
GeometryReader { proxy in
Color.clear.preference(
key: SybilPendingAssistantHeightPreferenceKey.self,
value: proxy.size.height
)
}
}
}
.scaleEffect(x: 1, y: -1) .scaleEffect(x: 1, y: -1)
} }
@@ -46,39 +33,13 @@ struct SybilChatTranscriptView: View {
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.top, 18 + bottomContentInset + tailSpacerHeight) .padding(.top, 18 + bottomContentInset)
.padding(.bottom, 18 + topContentInset) .padding(.bottom, 18 + topContentInset)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.background {
GeometryReader { proxy in
Color.clear
.onAppear {
onViewportHeightChange?(proxy.size.height)
}
.onChange(of: proxy.size.height) { _, height in
onViewportHeightChange?(height)
}
}
}
.onPreferenceChange(SybilPendingAssistantHeightPreferenceKey.self) { height in
onPendingAssistantHeightChange?(height)
}
.scaleEffect(x: 1, y: -1) .scaleEffect(x: 1, y: -1)
} }
private func isStreamingPendingAssistant(_ message: Message) -> Bool {
isSending && message.id.hasPrefix("temp-assistant-")
}
}
private struct SybilPendingAssistantHeightPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
} }
private struct MessageBubble: View { private struct MessageBubble: View {

View File

@@ -168,6 +168,75 @@ public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
public var updatedAt: Date public var updatedAt: Date
} }
public enum WorkspaceItemType: String, Codable, Hashable, Sendable {
case chat
case search
}
public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
public var type: WorkspaceItemType
public var id: String
public var title: String?
public var query: String?
public var createdAt: Date
public var updatedAt: Date
public var initiatedProvider: Provider?
public var initiatedModel: String?
public var lastUsedProvider: Provider?
public var lastUsedModel: String?
public init(chat: ChatSummary) {
self.type = .chat
self.id = chat.id
self.title = chat.title
self.query = nil
self.createdAt = chat.createdAt
self.updatedAt = chat.updatedAt
self.initiatedProvider = chat.initiatedProvider
self.initiatedModel = chat.initiatedModel
self.lastUsedProvider = chat.lastUsedProvider
self.lastUsedModel = chat.lastUsedModel
}
public init(search: SearchSummary) {
self.type = .search
self.id = search.id
self.title = search.title
self.query = search.query
self.createdAt = search.createdAt
self.updatedAt = search.updatedAt
self.initiatedProvider = nil
self.initiatedModel = nil
self.lastUsedProvider = nil
self.lastUsedModel = nil
}
public var chatSummary: ChatSummary? {
guard type == .chat else { return nil }
return ChatSummary(
id: id,
title: title,
createdAt: createdAt,
updatedAt: updatedAt,
initiatedProvider: initiatedProvider,
initiatedModel: initiatedModel,
lastUsedProvider: lastUsedProvider,
lastUsedModel: lastUsedModel
)
}
public var searchSummary: SearchSummary? {
guard type == .search else { return nil }
return SearchSummary(
id: id,
title: title,
query: query,
createdAt: createdAt,
updatedAt: updatedAt
)
}
}
public struct Message: Codable, Identifiable, Hashable, Sendable { public struct Message: Codable, Identifiable, Hashable, Sendable {
public var id: String public var id: String
public var createdAt: Date public var createdAt: Date
@@ -524,6 +593,10 @@ struct SearchListResponse: Codable {
var searches: [SearchSummary] var searches: [SearchSummary]
} }
struct WorkspaceListResponse: Codable {
var items: [WorkspaceItem]
}
struct ChatDetailResponse: Codable { struct ChatDetailResponse: Codable {
var chat: ChatDetail var chat: ChatDetail
} }

View File

@@ -219,6 +219,11 @@ struct SybilQuickQuestionView: View {
} }
private func submitQuestion() { private func submitQuestion() {
guard viewModel.canSendQuickQuestion else {
return
}
promptFocused = false
_ = viewModel.sendQuickQuestion() _ = viewModel.sendQuickQuestion()
} }
} }

View File

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

View File

@@ -95,6 +95,7 @@ final class SybilViewModel {
var chats: [ChatSummary] = [] var chats: [ChatSummary] = []
var searches: [SearchSummary] = [] var searches: [SearchSummary] = []
var workspaceItems: [WorkspaceItem] = []
var selectedItem: SidebarSelection? var selectedItem: SidebarSelection?
var selectedChat: ChatDetail? var selectedChat: ChatDetail?
@@ -388,10 +389,12 @@ final class SybilViewModel {
} }
var sidebarItems: [SidebarItem] { var sidebarItems: [SidebarItem] {
let chatItems: [SidebarItem] = chats.map { chat in workspaceItems.map { item in
switch item.type {
case .chat:
let initiatedLabel: String? let initiatedLabel: String?
if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty { if let model = item.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
if let provider = chat.initiatedProvider { if let provider = item.initiatedProvider {
initiatedLabel = "\(provider.displayName)\(model)" initiatedLabel = "\(provider.displayName)\(model)"
} else { } else {
initiatedLabel = model initiatedLabel = model
@@ -401,27 +404,25 @@ final class SybilViewModel {
} }
return SidebarItem( return SidebarItem(
selection: .chat(chat.id), selection: .chat(item.id),
kind: .chat, kind: .chat,
title: chatTitle(title: chat.title, messages: nil), title: chatTitle(title: item.title, messages: nil),
updatedAt: chat.updatedAt, updatedAt: item.updatedAt,
initiatedLabel: initiatedLabel, initiatedLabel: initiatedLabel,
isRunning: isChatRowRunning(chat.id) isRunning: isChatRowRunning(item.id)
) )
}
let searchItems: [SidebarItem] = searches.map { search in case .search:
SidebarItem( return SidebarItem(
selection: .search(search.id), selection: .search(item.id),
kind: .search, kind: .search,
title: searchTitle(title: search.title, query: search.query), title: searchTitle(title: item.title, query: item.query),
updatedAt: search.updatedAt, updatedAt: item.updatedAt,
initiatedLabel: "exa", initiatedLabel: "exa",
isRunning: isSearchRowRunning(search.id) isRunning: isSearchRowRunning(item.id)
) )
} }
}
return (chatItems + searchItems).sorted { $0.updatedAt > $1.updatedAt }
} }
var selectedChatSummary: ChatSummary? { var selectedChatSummary: ChatSummary? {
@@ -502,6 +503,7 @@ final class SybilViewModel {
authMode = nil authMode = nil
chats = [] chats = []
searches = [] searches = []
workspaceItems = []
selectedItem = .settings selectedItem = .settings
selectedChat = nil selectedChat = nil
selectedSearch = nil selectedSearch = nil
@@ -671,6 +673,7 @@ final class SybilViewModel {
setProvider(submittedProvider, model: submittedModel) setProvider(submittedProvider, model: submittedModel)
chats.removeAll(where: { $0.id == chat.id }) chats.removeAll(where: { $0.id == chat.id })
chats.insert(chat, at: 0) chats.insert(chat, at: 0)
upsertWorkspaceChat(chat)
draftKind = nil draftKind = nil
selectedItem = .chat(chat.id) selectedItem = .chat(chat.id)
selectedChat = ChatDetail( selectedChat = ChatDetail(
@@ -911,6 +914,23 @@ final class SybilViewModel {
} }
} }
func refreshSidebarCollectionsFromPullToRefresh() async {
guard isAuthenticated, !isCheckingSession else {
return
}
SybilLog.info(
SybilLog.ui,
"Sidebar pull-to-refresh requested"
)
let preferredSelection = selectedItem
let refreshTask = Task { @MainActor in
await refreshCollections(preferredSelection: preferredSelection, refreshSelection: false)
}
await refreshTask.value
}
func sendComposer() async { func sendComposer() async {
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines) let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
let attachments = composerAttachments let attachments = composerAttachments
@@ -1017,6 +1037,7 @@ final class SybilViewModel {
guard selectedItem == sourceSelection, draftKind == nil else { guard selectedItem == sourceSelection, draftKind == nil else {
chats.removeAll(where: { $0.id == chat.id }) chats.removeAll(where: { $0.id == chat.id })
chats.insert(chat, at: 0) chats.insert(chat, at: 0)
upsertWorkspaceChat(chat)
isCreatingSearchChat = false isCreatingSearchChat = false
return return
} }
@@ -1028,6 +1049,7 @@ final class SybilViewModel {
chats.removeAll(where: { $0.id == chat.id }) chats.removeAll(where: { $0.id == chat.id })
chats.insert(chat, at: 0) chats.insert(chat, at: 0)
upsertWorkspaceChat(chat)
selectedItem = .chat(chat.id) selectedItem = .chat(chat.id)
selectedSearch = nil selectedSearch = nil
@@ -1131,18 +1153,16 @@ final class SybilViewModel {
errorMessage = nil errorMessage = nil
do { do {
async let chatsValue = client.listChats() async let workspaceItemsValue = client.listWorkspaceItems()
async let searchesValue = client.listSearches()
async let activeRunsValue = client.getActiveRuns() async let activeRunsValue = client.getActiveRuns()
let (nextChats, nextSearches, nextActiveRuns) = try await (chatsValue, searchesValue, activeRunsValue) let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
chats = nextChats applyWorkspaceItems(nextWorkspaceItems)
searches = nextSearches
applyActiveRuns(nextActiveRuns) applyActiveRuns(nextActiveRuns)
SybilLog.info( SybilLog.info(
SybilLog.app, SybilLog.app,
"Loaded collections: \(nextChats.count) chats, \(nextSearches.count) searches" "Loaded collections: \(chats.count) chats, \(searches.count) searches"
) )
do { do {
@@ -1159,7 +1179,7 @@ final class SybilViewModel {
if case .settings = selectedItem { if case .settings = selectedItem {
nextSelection = .settings nextSelection = .settings
} else if let currentSelection = selectedItem, } else if let currentSelection = selectedItem,
hasSelection(currentSelection, chats: nextChats, searches: nextSearches) { hasSelection(currentSelection, chats: chats, searches: searches) {
nextSelection = currentSelection nextSelection = currentSelection
} else { } else {
nextSelection = sidebarItems.first?.selection nextSelection = sidebarItems.first?.selection
@@ -1231,18 +1251,16 @@ final class SybilViewModel {
do { do {
let client = try client() let client = try client()
async let chatsValue = client.listChats() async let workspaceItemsValue = client.listWorkspaceItems()
async let searchesValue = client.listSearches()
async let activeRunsValue = client.getActiveRuns() async let activeRunsValue = client.getActiveRuns()
let (nextChats, nextSearches, nextActiveRuns) = try await (chatsValue, searchesValue, activeRunsValue) let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
chats = nextChats applyWorkspaceItems(nextWorkspaceItems)
searches = nextSearches
applyActiveRuns(nextActiveRuns) applyActiveRuns(nextActiveRuns)
SybilLog.info( SybilLog.info(
SybilLog.app, SybilLog.app,
"Refreshed collections: \(nextChats.count) chats, \(nextSearches.count) searches" "Refreshed collections: \(chats.count) chats, \(searches.count) searches"
) )
errorMessage = nil errorMessage = nil
@@ -1260,10 +1278,10 @@ final class SybilViewModel {
} }
if let preferredSelection, if let preferredSelection,
hasSelection(preferredSelection, chats: nextChats, searches: nextSearches) { hasSelection(preferredSelection, chats: chats, searches: searches) {
selectedItem = preferredSelection selectedItem = preferredSelection
} else if let existing = selectedItem, } else if let existing = selectedItem,
hasSelection(existing, chats: nextChats, searches: nextSearches) { hasSelection(existing, chats: chats, searches: searches) {
selectedItem = existing selectedItem = existing
} else { } else {
selectedItem = sidebarItems.first?.selection selectedItem = sidebarItems.first?.selection
@@ -1276,7 +1294,9 @@ final class SybilViewModel {
attachToVisibleActiveRunIfNeeded() attachToVisibleActiveRunIfNeeded()
} }
} catch { } catch {
if shouldSuppressInactiveTransportError(error) { if isCancellation(error) {
SybilLog.debug(SybilLog.app, "Collection refresh cancelled")
} else if shouldSuppressInactiveTransportError(error) {
SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive") SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive")
} else { } else {
errorMessage = normalizeAPIError(error) errorMessage = normalizeAPIError(error)
@@ -1355,6 +1375,34 @@ final class SybilViewModel {
serverActiveSearchIDs = Set(activeRuns.searches) serverActiveSearchIDs = Set(activeRuns.searches)
} }
private func applyWorkspaceItems(_ items: [WorkspaceItem]) {
workspaceItems = items
chats = items.compactMap(\.chatSummary)
searches = items.compactMap(\.searchSummary)
}
private func upsertWorkspaceChat(_ chat: ChatSummary, moveToFront: Bool = true) {
upsertWorkspaceItem(WorkspaceItem(chat: chat), moveToFront: moveToFront)
}
private func upsertWorkspaceSearch(_ search: SearchSummary, moveToFront: Bool = true) {
upsertWorkspaceItem(WorkspaceItem(search: search), moveToFront: moveToFront)
}
private func upsertWorkspaceItem(_ item: WorkspaceItem, moveToFront: Bool) {
if let existingIndex = workspaceItems.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
workspaceItems.remove(at: existingIndex)
if moveToFront {
workspaceItems.insert(item, at: 0)
} else {
workspaceItems.insert(item, at: existingIndex)
}
return
}
workspaceItems.insert(item, at: 0)
}
private func attachToVisibleActiveRunIfNeeded() { private func attachToVisibleActiveRunIfNeeded() {
guard draftKind == nil else { guard draftKind == nil else {
return return
@@ -1686,6 +1734,7 @@ final class SybilViewModel {
chats.removeAll(where: { $0.id == created.id }) chats.removeAll(where: { $0.id == created.id })
chats.insert(created, at: 0) chats.insert(created, at: 0)
upsertWorkspaceChat(created)
if shouldShowCreatedChat { if shouldShowCreatedChat {
draftKind = nil draftKind = nil
@@ -1762,6 +1811,7 @@ final class SybilViewModel {
} }
return existing return existing
} }
self.upsertWorkspaceChat(updated, moveToFront: false)
if self.selectedChat?.id == updated.id { if self.selectedChat?.id == updated.id {
self.selectedChat?.title = updated.title self.selectedChat?.title = updated.title
@@ -1899,6 +1949,7 @@ final class SybilViewModel {
searches.removeAll(where: { $0.id == created.id }) searches.removeAll(where: { $0.id == created.id })
searches.insert(created, at: 0) searches.insert(created, at: 0)
upsertWorkspaceSearch(created)
if shouldShowCreatedSearch { if shouldShowCreatedSearch {
draftKind = nil draftKind = nil

View File

@@ -26,10 +26,6 @@ struct SybilWorkspaceView: View {
@State private var isShowingPhotoPicker = false @State private var isShowingPhotoPicker = false
@State private var photoPickerItems: [PhotosPickerItem] = [] @State private var photoPickerItems: [PhotosPickerItem] = []
@State private var isComposerDropTargeted = false @State private var isComposerDropTargeted = false
@State private var transcriptTailSpacerHeight = SybilTranscriptTailSpacer.minimumHeight
@State private var transcriptTailSpacerTargetHeight = SybilTranscriptTailSpacer.minimumHeight
@State private var transcriptViewportHeight: CGFloat = 0
@State private var pendingAssistantBaselineHeight: CGFloat?
@State private var newChatSwipeOffset: CGFloat = 0 @State private var newChatSwipeOffset: CGFloat = 0
@State private var newChatSwipeCompletionOffset: CGFloat = 0 @State private var newChatSwipeCompletionOffset: CGFloat = 0
@State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth @State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth
@@ -42,10 +38,6 @@ struct SybilWorkspaceView: View {
private let customWorkspaceNavigationContentInset: CGFloat = 96 private let customWorkspaceNavigationContentInset: CGFloat = 96
private let composerOverlayContentInset: CGFloat = 112 private let composerOverlayContentInset: CGFloat = 112
private var visibleTranscriptTailSpacerHeight: CGFloat {
viewModel.showsComposer && !viewModel.isSearchMode ? transcriptTailSpacerHeight : 0
}
private var isSettingsSelected: Bool { private var isSettingsSelected: Bool {
if case .settings = viewModel.selectedItem { if case .settings = viewModel.selectedItem {
return true return true
@@ -153,17 +145,6 @@ struct SybilWorkspaceView: View {
} }
resetNewChatSwipe(animated: false) resetNewChatSwipe(animated: false)
} }
.onChange(of: transcriptScrollContextID) { _, _ in
handleTranscriptContextChange()
}
.onChange(of: viewModel.isSendingVisibleChat) { wasSending, isSending in
handleVisibleChatSendingChange(wasSending: wasSending, isSending: isSending)
}
.onChange(of: viewModel.errorMessage) { _, message in
if message != nil && !viewModel.isSendingVisibleChat {
resetTranscriptTailSpacer(animated: true)
}
}
.task(id: composerFocusPolicyID) { .task(id: composerFocusPolicyID) {
await applyComposerFocusPolicy() await applyComposerFocusPolicy()
} }
@@ -213,14 +194,7 @@ struct SybilWorkspaceView: View {
isLoading: viewModel.isLoadingSelection, isLoading: viewModel.isLoadingSelection,
isSending: viewModel.isSendingVisibleChat, isSending: viewModel.isSendingVisibleChat,
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0, topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0, bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0
tailSpacerHeight: visibleTranscriptTailSpacerHeight,
onViewportHeightChange: { height in
handleTranscriptViewportHeightChange(height)
},
onPendingAssistantHeightChange: { height in
handlePendingAssistantHeightChange(height)
}
) )
.id(transcriptScrollContextID) .id(transcriptScrollContextID)
} }
@@ -258,13 +232,7 @@ struct SybilWorkspaceView: View {
HStack(spacing: 14) { HStack(spacing: 14) {
workspaceNavigationLeadingControl workspaceNavigationLeadingControl
Text(viewModel.selectedTitle) customWorkspaceNavigationTitle
.font(.sybil(size: 16, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.minimumScaleFactor(0.78)
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
workspaceNavigationTrailingControl workspaceNavigationTrailingControl
} }
@@ -277,6 +245,32 @@ struct SybilWorkspaceView: View {
} }
} }
private var selectedProviderModelSubtitle: String {
let selectedModel = viewModel.model.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedModel.isEmpty else {
return viewModel.provider.displayName
}
return "\(viewModel.provider.displayName)\(selectedModel)"
}
private var customWorkspaceNavigationTitle: some View {
VStack(alignment: .leading, spacing: 2) {
Text(viewModel.selectedTitle)
.font(.sybil(size: 16, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.minimumScaleFactor(0.78)
Text(selectedProviderModelSubtitle)
.font(.sybil(size: 10, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
.lineLimit(1)
.minimumScaleFactor(0.82)
}
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
}
@ViewBuilder @ViewBuilder
private var workspaceNavigationLeadingControl: some View { private var workspaceNavigationLeadingControl: some View {
switch navigationLeadingControl { switch navigationLeadingControl {
@@ -311,86 +305,6 @@ struct SybilWorkspaceView: View {
} }
} }
private func handleTranscriptContextChange() {
resetTranscriptTailSpacer(animated: false)
}
private func handleVisibleChatSendingChange(wasSending: Bool, isSending: Bool) {
guard !viewModel.isSearchMode else {
resetTranscriptTailSpacer(animated: true)
return
}
if isSending {
prepareTranscriptTailSpacerForReply(animated: false)
return
}
if wasSending {
if viewModel.errorMessage != nil {
resetTranscriptTailSpacer(animated: true)
}
pendingAssistantBaselineHeight = nil
}
}
private func handleTranscriptViewportHeightChange(_ height: CGFloat) {
transcriptViewportHeight = height
if viewModel.isSendingVisibleChat,
transcriptTailSpacerTargetHeight <= SybilTranscriptTailSpacer.minimumHeight {
prepareTranscriptTailSpacerForReply(animated: false)
}
}
private func handlePendingAssistantHeightChange(_ height: CGFloat) {
guard viewModel.isSendingVisibleChat, !viewModel.isSearchMode, height > 0 else {
return
}
if pendingAssistantBaselineHeight == nil {
pendingAssistantBaselineHeight = height
}
let measuredHeight = SybilTranscriptTailSpacer.placeholderHeight(
targetHeight: transcriptTailSpacerTargetHeight,
baselineAssistantHeight: pendingAssistantBaselineHeight ?? height,
currentAssistantHeight: height
)
let nextHeight = min(transcriptTailSpacerHeight, measuredHeight)
setTranscriptTailSpacer(nextHeight, animated: false)
}
private func prepareTranscriptTailSpacerForReply(animated: Bool) {
let targetHeight = SybilTranscriptTailSpacer.replyBufferHeight(for: transcriptViewportHeight)
transcriptTailSpacerTargetHeight = targetHeight
pendingAssistantBaselineHeight = nil
setTranscriptTailSpacer(targetHeight, animated: animated)
}
private func resetTranscriptTailSpacer(animated: Bool) {
transcriptTailSpacerTargetHeight = SybilTranscriptTailSpacer.minimumHeight
pendingAssistantBaselineHeight = nil
setTranscriptTailSpacer(SybilTranscriptTailSpacer.minimumHeight, animated: animated)
}
private func setTranscriptTailSpacer(_ height: CGFloat, animated: Bool) {
let nextHeight = SybilTranscriptTailSpacer.clampedHeight(height)
guard abs(nextHeight - transcriptTailSpacerHeight) >= 0.5 else {
return
}
let update = {
transcriptTailSpacerHeight = nextHeight
}
if animated {
withAnimation(.easeOut(duration: 0.22), update)
} else {
update()
}
}
private func beginNewChatSwipe(containerWidth: CGFloat) { private func beginNewChatSwipe(containerWidth: CGFloat) {
let update = { let update = {
newChatSwipeContainerWidth = max(containerWidth, 1) newChatSwipeContainerWidth = max(containerWidth, 1)
@@ -808,14 +722,8 @@ struct SybilWorkspaceView: View {
return return
} }
if !viewModel.isSearchMode {
prepareTranscriptTailSpacerForReply(animated: false)
}
#if !targetEnvironment(macCatalyst) #if !targetEnvironment(macCatalyst)
if !viewModel.isSearchMode {
composerFocused = false composerFocused = false
}
#endif #endif
Task { Task {
@@ -881,37 +789,6 @@ struct SybilWorkspaceView: View {
} }
} }
enum SybilTranscriptTailSpacer {
static let minimumHeight: CGFloat = 20
static let replyBufferMin: CGFloat = 288
static let replyBufferMax: CGFloat = 576
static let replyBufferViewportRatio: CGFloat = 0.52
static func replyBufferHeight(for viewportHeight: CGFloat) -> CGFloat {
guard viewportHeight > 0 else {
return replyBufferMin
}
return min(
replyBufferMax,
max(replyBufferMin, (viewportHeight * replyBufferViewportRatio).rounded())
)
}
static func clampedHeight(_ height: CGFloat) -> CGFloat {
max(minimumHeight, height.rounded(.up))
}
static func placeholderHeight(
targetHeight: CGFloat,
baselineAssistantHeight: CGFloat,
currentAssistantHeight: CGFloat
) -> CGFloat {
let consumedHeight = max(currentAssistantHeight - baselineAssistantHeight, 0)
return clampedHeight(targetHeight - consumedHeight)
}
}
enum NewChatSwipeMetrics { enum NewChatSwipeMetrics {
static let referenceWidth: CGFloat = 390 static let referenceWidth: CGFloat = 390
static let horizontalActivationDistance: CGFloat = 18 static let horizontalActivationDistance: CGFloat = 18

View File

@@ -4,6 +4,7 @@ import Testing
@testable import Sybil @testable import Sybil
private struct MockClientCallSnapshot: Sendable { private struct MockClientCallSnapshot: Sendable {
var listWorkspaceItems = 0
var listChats = 0 var listChats = 0
var listSearches = 0 var listSearches = 0
var createChat = 0 var createChat = 0
@@ -27,6 +28,7 @@ private struct UnexpectedClientCall: Error {}
private actor MockSybilClient: SybilAPIClienting { private actor MockSybilClient: SybilAPIClienting {
private let chatsResponse: [ChatSummary] private let chatsResponse: [ChatSummary]
private let searchesResponse: [SearchSummary] private let searchesResponse: [SearchSummary]
private let workspaceItemsResponse: [WorkspaceItem]
private let chatDetails: [String: ChatDetail] private let chatDetails: [String: ChatDetail]
private let searchDetails: [String: SearchDetail] private let searchDetails: [String: SearchDetail]
private let createChatResponse: ChatSummary? private let createChatResponse: ChatSummary?
@@ -36,6 +38,8 @@ private actor MockSybilClient: SybilAPIClienting {
private var lastCreateChatCall: ChatCreateCallSnapshot? private var lastCreateChatCall: ChatCreateCallSnapshot?
private var lastCompletionStreamBody: CompletionStreamRequest? private var lastCompletionStreamBody: CompletionStreamRequest?
private var completionStreamEvents: [CompletionStreamEvent]? private var completionStreamEvents: [CompletionStreamEvent]?
private var listChatsDelayNanoseconds: UInt64 = 0
private var listSearchesDelayNanoseconds: UInt64 = 0
private var getChatDelayNanoseconds: UInt64 = 0 private var getChatDelayNanoseconds: UInt64 = 0
private var getSearchDelayNanoseconds: UInt64 = 0 private var getSearchDelayNanoseconds: UInt64 = 0
private var completionStreamNetworkErrorMessage: String? private var completionStreamNetworkErrorMessage: String?
@@ -53,16 +57,22 @@ private actor MockSybilClient: SybilAPIClienting {
chatDetails: [String: ChatDetail] = [:], chatDetails: [String: ChatDetail] = [:],
searchDetails: [String: SearchDetail] = [:], searchDetails: [String: SearchDetail] = [:],
createChatResponse: ChatSummary? = nil, createChatResponse: ChatSummary? = nil,
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse() activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
workspaceItemsResponse: [WorkspaceItem]? = nil
) { ) {
self.chatsResponse = chatsResponse self.chatsResponse = chatsResponse
self.searchesResponse = searchesResponse self.searchesResponse = searchesResponse
self.workspaceItemsResponse = workspaceItemsResponse ?? Self.makeWorkspaceItems(chats: chatsResponse, searches: searchesResponse)
self.chatDetails = chatDetails self.chatDetails = chatDetails
self.searchDetails = searchDetails self.searchDetails = searchDetails
self.createChatResponse = createChatResponse self.createChatResponse = createChatResponse
self.activeRunsResponse = activeRunsResponse self.activeRunsResponse = activeRunsResponse
} }
private static func makeWorkspaceItems(chats: [ChatSummary], searches: [SearchSummary]) -> [WorkspaceItem] {
(chats.map { WorkspaceItem(chat: $0) } + searches.map { WorkspaceItem(search: $0) }).sorted { $0.updatedAt > $1.updatedAt }
}
func currentSnapshot() -> MockClientCallSnapshot { func currentSnapshot() -> MockClientCallSnapshot {
snapshot snapshot
} }
@@ -85,6 +95,11 @@ private actor MockSybilClient: SybilAPIClienting {
completionStreamDelayNanoseconds = delayNanoseconds completionStreamDelayNanoseconds = delayNanoseconds
} }
func setListDelays(chats: UInt64 = 0, searches: UInt64 = 0) {
listChatsDelayNanoseconds = chats
listSearchesDelayNanoseconds = searches
}
func setGetChatDelay(_ delayNanoseconds: UInt64) { func setGetChatDelay(_ delayNanoseconds: UInt64) {
getChatDelayNanoseconds = delayNanoseconds getChatDelayNanoseconds = delayNanoseconds
} }
@@ -120,8 +135,20 @@ private actor MockSybilClient: SybilAPIClienting {
AuthSession(authenticated: true, mode: "open") AuthSession(authenticated: true, mode: "open")
} }
func listWorkspaceItems() async throws -> [WorkspaceItem] {
snapshot.listWorkspaceItems += 1
let delay = max(listChatsDelayNanoseconds, listSearchesDelayNanoseconds)
if delay > 0 {
try await Task.sleep(nanoseconds: delay)
}
return workspaceItemsResponse
}
func listChats() async throws -> [ChatSummary] { func listChats() async throws -> [ChatSummary] {
snapshot.listChats += 1 snapshot.listChats += 1
if listChatsDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: listChatsDelayNanoseconds)
}
return chatsResponse return chatsResponse
} }
@@ -165,6 +192,9 @@ private actor MockSybilClient: SybilAPIClienting {
func listSearches() async throws -> [SearchSummary] { func listSearches() async throws -> [SearchSummary] {
snapshot.listSearches += 1 snapshot.listSearches += 1
if listSearchesDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: listSearchesDelayNanoseconds)
}
return searchesResponse return searchesResponse
} }
@@ -376,13 +406,41 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false) await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)
let snapshot = await client.currentSnapshot() let snapshot = await client.currentSnapshot()
#expect(snapshot.listChats == 1) #expect(snapshot.listWorkspaceItems == 1)
#expect(snapshot.listSearches == 1) #expect(snapshot.listChats == 0)
#expect(snapshot.listSearches == 0)
#expect(snapshot.getChat == 0) #expect(snapshot.getChat == 0)
#expect(snapshot.getSearch == 0) #expect(snapshot.getSearch == 0)
#expect(viewModel.selectedItem == .chat("chat-1")) #expect(viewModel.selectedItem == .chat("chat-1"))
} }
@MainActor
@Test func pullToRefreshCompletesWhenRefreshableTaskIsCancelled() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_050)
let chat = makeChatSummary(id: "chat-cancelled", date: date)
let search = makeSearchSummary(id: "search-cancelled", date: date)
let client = MockSybilClient(
chatsResponse: [chat],
searchesResponse: [search]
)
await client.setListDelays(chats: 50_000_000, searches: 50_000_000)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
let refreshTask = Task {
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
}
try await Task.sleep(nanoseconds: 10_000_000)
refreshTask.cancel()
await refreshTask.value
#expect(viewModel.errorMessage == nil)
#expect(!viewModel.isLoadingCollections)
#expect(viewModel.chats.map(\.id) == ["chat-cancelled"])
#expect(viewModel.searches.map(\.id) == ["search-cancelled"])
}
@MainActor @MainActor
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws { @Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_100) let date = Date(timeIntervalSince1970: 1_700_000_100)
@@ -396,6 +454,7 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true) await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
let snapshot = await client.currentSnapshot() let snapshot = await client.currentSnapshot()
#expect(snapshot.listWorkspaceItems == 0)
#expect(snapshot.listChats == 0) #expect(snapshot.listChats == 0)
#expect(snapshot.listSearches == 0) #expect(snapshot.listSearches == 0)
#expect(snapshot.getChat == 1) #expect(snapshot.getChat == 1)
@@ -415,6 +474,7 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true) await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
let snapshot = await client.currentSnapshot() let snapshot = await client.currentSnapshot()
#expect(snapshot.listWorkspaceItems == 0)
#expect(snapshot.listChats == 0) #expect(snapshot.listChats == 0)
#expect(snapshot.listSearches == 0) #expect(snapshot.listSearches == 0)
#expect(snapshot.getSearch == 1) #expect(snapshot.getSearch == 1)
@@ -784,23 +844,3 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
#expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false)) #expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false))
#expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true)) #expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true))
} }
@Test func transcriptTailSpacerContractsAsContentGrows() async throws {
let targetHeight: CGFloat = 320
let baselineAssistantHeight: CGFloat = 28
#expect(
SybilTranscriptTailSpacer.placeholderHeight(
targetHeight: targetHeight,
baselineAssistantHeight: baselineAssistantHeight,
currentAssistantHeight: baselineAssistantHeight + 120
) == 200
)
#expect(
SybilTranscriptTailSpacer.placeholderHeight(
targetHeight: targetHeight,
baselineAssistantHeight: baselineAssistantHeight,
currentAssistantHeight: baselineAssistantHeight + 500
) == SybilTranscriptTailSpacer.minimumHeight
)
}

View File

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

View File

@@ -51,6 +51,9 @@ model Chat {
lastUsedProvider Provider? lastUsedProvider Provider?
lastUsedModel String? lastUsedModel String?
additionalSystemPrompt String?
enabledTools Json?
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])
userId String? userId String?

View File

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

View File

@@ -9,7 +9,11 @@ import { z } from "zod";
import { env } from "../env.js"; import { env } from "../env.js";
import { exaClient } from "../search/exa.js"; import { exaClient } from "../search/exa.js";
import { searchSearxng } from "../search/searxng.js"; import { searchSearxng } from "../search/searxng.js";
import { buildOpenAIConversationMessage, buildOpenAIResponsesInputMessage } from "./message-content.js"; import {
buildOpenAIConversationMessage,
buildOpenAIResponsesInputMessage,
buildSystemPromptAugmentationMessage,
} from "./message-content.js";
import type { ChatMessage } from "./types.js"; import type { ChatMessage } from "./types.js";
const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS; const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
@@ -188,7 +192,43 @@ const CHAT_TOOLS: any[] = [
...(env.CHAT_SHELL_TOOL_ENABLED ? [SHELL_EXEC_TOOL] : []), ...(env.CHAT_SHELL_TOOL_ENABLED ? [SHELL_EXEC_TOOL] : []),
]; ];
const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => { function getToolName(tool: any) {
return typeof tool?.function?.name === "string" ? tool.function.name : null;
}
export function getAvailableChatTools() {
return CHAT_TOOLS.map((tool) => {
const name = getToolName(tool);
if (!name) return null;
return {
name,
description: typeof tool?.function?.description === "string" ? tool.function.description : "",
};
}).filter((tool): tool is { name: string; description: string } => tool !== null);
}
export function normalizeEnabledChatTools(value: unknown) {
if (!Array.isArray(value)) return getAvailableChatTools().map((tool) => tool.name);
const available = new Set(getAvailableChatTools().map((tool) => tool.name));
return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))].filter((name) =>
available.has(name)
);
}
function getEnabledToolSet(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
return new Set(normalizeEnabledChatTools(params.enabledTools));
}
function getEnabledChatTools(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
const enabled = getEnabledToolSet(params);
return CHAT_TOOLS.filter((tool) => {
const name = getToolName(tool);
return name ? enabled.has(name) : false;
});
}
function toResponsesChatTools(tools: any[]) {
return tools.map((tool) => {
if (tool?.type !== "function") return tool; if (tool?.type !== "function") return tool;
return { return {
type: "function", type: "function",
@@ -198,6 +238,7 @@ const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => {
strict: false, strict: false,
}; };
}); });
}
export const CHAT_TOOL_SYSTEM_PROMPT = export const CHAT_TOOL_SYSTEM_PROMPT =
"You can use tools to gather up-to-date web information when needed. " + "You can use tools to gather up-to-date web information when needed. " +
@@ -239,6 +280,8 @@ type ToolAwareCompletionParams = {
client: OpenAI; client: OpenAI;
model: string; model: string;
messages: ChatMessage[]; messages: ChatMessage[];
enabledTools?: string[];
userLocation?: string;
temperature?: number; temperature?: number;
maxTokens?: number; maxTokens?: number;
onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>; onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>;
@@ -379,20 +422,38 @@ function extractHtmlTitle(html: string) {
); );
} }
function normalizeIncomingMessages(messages: ChatMessage[]) { 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 normalizeIncomingMessages(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
const normalized = messages.map((message) => buildOpenAIConversationMessage(message)); const normalized = messages.map((message) => buildOpenAIConversationMessage(message));
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized]; return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
} }
function normalizePlainIncomingMessages(messages: ChatMessage[]) { function normalizePlainIncomingMessages(messages: ChatMessage[], userLocation?: string) {
return messages.map((message) => buildOpenAIConversationMessage(message)); return [buildSystemPromptAugmentationMessage(userLocation), ...messages.map((message) => buildOpenAIConversationMessage(message))];
} }
function normalizeIncomingResponsesInput(messages: ChatMessage[]) { function normalizeIncomingResponsesInput(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message)); const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message));
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized]; return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
} }
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> { async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
@@ -957,7 +1018,8 @@ async function executeToolCallAndBuildEvent(
} }
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> { export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const input: any[] = normalizeIncomingResponsesInput(params.messages); const enabledTools = getEnabledChatTools(params);
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
const rawResponses: unknown[] = []; const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = []; const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -971,7 +1033,7 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
input, input,
temperature: params.temperature, temperature: params.temperature,
max_output_tokens: params.maxTokens, max_output_tokens: params.maxTokens,
tools: RESPONSES_CHAT_TOOLS, tools: toResponsesChatTools(enabledTools),
tool_choice: "auto", tool_choice: "auto",
parallel_tool_calls: true, parallel_tool_calls: true,
// Tool loops pass response output items back as input; reasoning items need persistence. // Tool loops pass response output items back as input; reasoning items need persistence.
@@ -1026,7 +1088,8 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
} }
export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> { export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const conversation: any[] = normalizeIncomingMessages(params.messages); const enabledTools = getEnabledChatTools(params);
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
const rawResponses: unknown[] = []; const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = []; const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -1040,7 +1103,7 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
messages: conversation, messages: conversation,
temperature: params.temperature, temperature: params.temperature,
max_tokens: params.maxTokens, max_tokens: params.maxTokens,
tools: CHAT_TOOLS, tools: enabledTools,
tool_choice: "auto", tool_choice: "auto",
} as any); } as any);
rawResponses.push(completion); rawResponses.push(completion);
@@ -1114,7 +1177,7 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
export async function runPlainChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> { export async function runPlainChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const completion = await params.client.chat.completions.create({ const completion = await params.client.chat.completions.create({
model: params.model, model: params.model,
messages: normalizePlainIncomingMessages(params.messages), messages: normalizePlainIncomingMessages(params.messages, params.userLocation),
temperature: params.temperature, temperature: params.temperature,
max_tokens: params.maxTokens, max_tokens: params.maxTokens,
} as any); } as any);
@@ -1134,7 +1197,8 @@ export async function runPlainChatCompletions(params: ToolAwareCompletionParams)
export async function* runToolAwareOpenAIChatStream( export async function* runToolAwareOpenAIChatStream(
params: ToolAwareCompletionParams params: ToolAwareCompletionParams
): AsyncGenerator<ToolAwareStreamingEvent> { ): AsyncGenerator<ToolAwareStreamingEvent> {
const input: any[] = normalizeIncomingResponsesInput(params.messages); const enabledTools = getEnabledChatTools(params);
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
const rawResponses: unknown[] = []; const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = []; const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -1148,7 +1212,7 @@ export async function* runToolAwareOpenAIChatStream(
input, input,
temperature: params.temperature, temperature: params.temperature,
max_output_tokens: params.maxTokens, max_output_tokens: params.maxTokens,
tools: RESPONSES_CHAT_TOOLS, tools: toResponsesChatTools(enabledTools),
tool_choice: "auto", tool_choice: "auto",
parallel_tool_calls: true, parallel_tool_calls: true,
// Tool loops pass response output items back as input; reasoning items need persistence. // Tool loops pass response output items back as input; reasoning items need persistence.
@@ -1260,7 +1324,8 @@ export async function* runToolAwareOpenAIChatStream(
export async function* runToolAwareChatCompletionsStream( export async function* runToolAwareChatCompletionsStream(
params: ToolAwareCompletionParams params: ToolAwareCompletionParams
): AsyncGenerator<ToolAwareStreamingEvent> { ): AsyncGenerator<ToolAwareStreamingEvent> {
const conversation: any[] = normalizeIncomingMessages(params.messages); const enabledTools = getEnabledChatTools(params);
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
const rawResponses: unknown[] = []; const rawResponses: unknown[] = [];
const toolEvents: ToolExecutionEvent[] = []; const toolEvents: ToolExecutionEvent[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -1274,7 +1339,7 @@ export async function* runToolAwareChatCompletionsStream(
messages: conversation, messages: conversation,
temperature: params.temperature, temperature: params.temperature,
max_tokens: params.maxTokens, max_tokens: params.maxTokens,
tools: CHAT_TOOLS, tools: enabledTools,
tool_choice: "auto", tool_choice: "auto",
stream: true, stream: true,
stream_options: { include_usage: true }, stream_options: { include_usage: true },
@@ -1403,7 +1468,7 @@ export async function* runPlainChatCompletionsStream(
const stream = await params.client.chat.completions.create({ const stream = await params.client.chat.completions.create({
model: params.model, model: params.model,
messages: normalizePlainIncomingMessages(params.messages), messages: normalizePlainIncomingMessages(params.messages, params.userLocation),
temperature: params.temperature, temperature: params.temperature,
max_tokens: params.maxTokens, max_tokens: params.maxTokens,
stream: true, stream: true,

View File

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

View File

@@ -13,6 +13,7 @@ export type ModelCatalogSnapshot = Partial<Record<Provider, ProviderModelSnapsho
const baseProviders: Provider[] = ["openai", "anthropic", "xai"]; const baseProviders: Provider[] = ["openai", "anthropic", "xai"];
const MODEL_FETCH_TIMEOUT_MS = 15000; const MODEL_FETCH_TIMEOUT_MS = 15000;
const MODEL_CATALOG_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
const modelCatalog: ModelCatalogSnapshot = { const modelCatalog: ModelCatalogSnapshot = {
openai: { models: [], loadedAt: null, error: null }, openai: { models: [], loadedAt: null, error: null },
@@ -20,6 +21,8 @@ const modelCatalog: ModelCatalogSnapshot = {
xai: { models: [], loadedAt: null, error: null }, xai: { models: [], loadedAt: null, error: null },
}; };
let catalogRefreshPromise: Promise<void> | null = null;
function getCatalogProviders(): Provider[] { function getCatalogProviders(): Provider[] {
return isHermesAgentConfigured() ? [...baseProviders, "hermes-agent"] : baseProviders; return isHermesAgentConfigured() ? [...baseProviders, "hermes-agent"] : baseProviders;
} }
@@ -86,17 +89,42 @@ async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLog
logger?.info({ provider, modelCount: models.length }, "model catalog loaded"); logger?.info({ provider, modelCount: models.length }, "model catalog loaded");
} catch (err: any) { } catch (err: any) {
const message = err?.message ?? String(err); const message = err?.message ?? String(err);
const previous = modelCatalog[provider];
const fallbackModels = provider === "hermes-agent" && env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [];
modelCatalog[provider] = { modelCatalog[provider] = {
models: provider === "hermes-agent" && env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [], models: previous?.models.length ? previous.models : fallbackModels,
loadedAt: new Date().toISOString(), loadedAt: previous?.loadedAt ?? null,
error: message, error: message,
}; };
logger?.warn({ provider, err: message }, "failed to load provider model catalog"); logger?.warn({ provider, err: message }, "failed to load provider model catalog");
} }
} }
export async function refreshModelCatalog(logger?: FastifyBaseLogger) {
if (catalogRefreshPromise) return catalogRefreshPromise;
catalogRefreshPromise = Promise.all(getCatalogProviders().map((provider) => refreshProviderModels(provider, logger)))
.then(() => undefined)
.finally(() => {
catalogRefreshPromise = null;
});
return catalogRefreshPromise;
}
export async function warmModelCatalog(logger?: FastifyBaseLogger) { export async function warmModelCatalog(logger?: FastifyBaseLogger) {
await Promise.all(getCatalogProviders().map((provider) => refreshProviderModels(provider, logger))); await refreshModelCatalog(logger);
}
export function startModelCatalogRefreshLoop(logger?: FastifyBaseLogger) {
const timer = setInterval(() => {
void refreshModelCatalog(logger);
}, MODEL_CATALOG_REFRESH_INTERVAL_MS);
timer.unref?.();
return () => {
clearInterval(timer);
};
} }
export function getModelCatalogSnapshot(): ModelCatalogSnapshot { export function getModelCatalogSnapshot(): ModelCatalogSnapshot {

View File

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

View File

@@ -3,6 +3,7 @@ import { prisma } from "../db.js";
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js"; import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
import { import {
buildToolLogMessageData, buildToolLogMessageData,
normalizeEnabledChatTools,
runPlainChatCompletionsStream, runPlainChatCompletionsStream,
runToolAwareChatCompletionsStream, runToolAwareChatCompletionsStream,
runToolAwareOpenAIChatStream, runToolAwareOpenAIChatStream,
@@ -76,12 +77,15 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
try { try {
if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") { if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient(); const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
const streamEvents = const streamEvents =
req.provider === "openai" req.provider === "openai" && enabledTools.length > 0
? runToolAwareOpenAIChatStream({ ? runToolAwareOpenAIChatStream({
client, client,
model: req.model, model: req.model,
messages: req.messages, messages: req.messages,
enabledTools,
userLocation: req.userLocation,
temperature: req.temperature, temperature: req.temperature,
maxTokens: req.maxTokens, maxTokens: req.maxTokens,
logContext: { logContext: {
@@ -90,11 +94,12 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
chatId: chatId ?? undefined, chatId: chatId ?? undefined,
}, },
}) })
: req.provider === "hermes-agent" : req.provider === "hermes-agent" || enabledTools.length === 0
? runPlainChatCompletionsStream({ ? runPlainChatCompletionsStream({
client, client,
model: req.model, model: req.model,
messages: req.messages, messages: req.messages,
userLocation: req.userLocation,
temperature: req.temperature, temperature: req.temperature,
maxTokens: req.maxTokens, maxTokens: req.maxTokens,
logContext: { logContext: {
@@ -107,6 +112,8 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
client, client,
model: req.model, model: req.model,
messages: req.messages, messages: req.messages,
enabledTools,
userLocation: req.userLocation,
temperature: req.temperature, temperature: req.temperature,
maxTokens: req.maxTokens, maxTokens: req.maxTokens,
logContext: { logContext: {
@@ -146,7 +153,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
} else if (req.provider === "anthropic") { } else if (req.provider === "anthropic") {
const client = anthropicClient(); const client = anthropicClient();
const system = getAnthropicSystemPrompt(req.messages); const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message)); const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
const stream = await client.messages.create({ const stream = await client.messages.create({

View File

@@ -36,6 +36,9 @@ export type MultiplexRequest = {
provider: Provider; provider: Provider;
model: string; model: string;
messages: ChatMessage[]; messages: ChatMessage[];
additionalSystemPrompt?: string;
enabledTools?: string[];
userLocation?: string;
temperature?: number; temperature?: number;
maxTokens?: number; maxTokens?: number;
}; };

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import type {
SearchDetail, SearchDetail,
SearchSummary, SearchSummary,
ToolCallEvent, ToolCallEvent,
WorkspaceItem,
} from "./types.js"; } from "./types.js";
type SidebarSelection = { kind: "chat" | "search"; id: string }; type SidebarSelection = { kind: "chat" | "search"; id: string };
@@ -93,9 +94,38 @@ function getSearchTitle(search: Pick<SearchSummary, "title" | "query">) {
return "New search"; return "New search";
} }
function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] { function chatWorkspaceItem(chat: ChatSummary): WorkspaceItem {
const items: SidebarItem[] = [ return { type: "chat", ...chat };
...chats.map((chat) => ({ }
function searchWorkspaceItem(search: SearchSummary): WorkspaceItem {
return { type: "search", ...search };
}
function splitWorkspaceItems(items: WorkspaceItem[]) {
const chats: ChatSummary[] = [];
const searches: SearchSummary[] = [];
for (const item of items) {
if (item.type === "chat") {
const { type: _type, ...chat } = item;
chats.push(chat);
} else {
const { type: _type, ...search } = item;
searches.push(search);
}
}
return { chats, searches };
}
function upsertWorkspaceItem(items: WorkspaceItem[], item: WorkspaceItem) {
return [item, ...items.filter((existing) => existing.type !== item.type || existing.id !== item.id)];
}
function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
return items.map((item) => {
if (item.type === "chat") {
const chat = item;
return {
kind: "chat" as const, kind: "chat" as const,
id: chat.id, id: chat.id,
title: getChatTitle(chat), title: getChatTitle(chat),
@@ -105,8 +135,11 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
initiatedModel: chat.initiatedModel, initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider, lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel, lastUsedModel: chat.lastUsedModel,
})), };
...searches.map((search) => ({ }
const search = item;
return {
kind: "search" as const, kind: "search" as const,
id: search.id, id: search.id,
title: getSearchTitle(search), title: getSearchTitle(search),
@@ -116,10 +149,8 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
initiatedModel: null, initiatedModel: null,
lastUsedProvider: null, lastUsedProvider: null,
lastUsedModel: null, lastUsedModel: null,
})), };
]; });
return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
} }
function asToolLogMetadata(value: unknown): ToolLogMetadata | null { function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
@@ -195,6 +226,7 @@ async function main() {
let authMode: "open" | "token" | null = null; let authMode: "open" | "token" | null = null;
let chats: ChatSummary[] = []; let chats: ChatSummary[] = [];
let searches: SearchSummary[] = []; let searches: SearchSummary[] = [];
let workspaceItems: WorkspaceItem[] = [];
let selectedItem: SidebarSelection | null = null; let selectedItem: SidebarSelection | null = null;
let selectedChat: ChatDetail | null = null; let selectedChat: ChatDetail | null = null;
let selectedSearch: SearchDetail | null = null; let selectedSearch: SearchDetail | null = null;
@@ -377,7 +409,7 @@ async function main() {
} }
function getSidebarItems() { function getSidebarItems() {
return buildSidebarItems(chats, searches); return buildSidebarItems(workspaceItems);
} }
function getSelectedChatSummary() { function getSelectedChatSummary() {
@@ -701,6 +733,7 @@ async function main() {
function resetWorkspaceState() { function resetWorkspaceState() {
chats = []; chats = [];
searches = []; searches = [];
workspaceItems = [];
selectedItem = null; selectedItem = null;
selectedChat = null; selectedChat = null;
selectedSearch = null; selectedSearch = null;
@@ -767,11 +800,13 @@ async function main() {
updateUI(); updateUI();
try { try {
const [nextChats, nextSearches] = await Promise.all([api.listChats(), api.listSearches()]); const nextWorkspaceItems = await api.listWorkspaceItems();
const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems);
workspaceItems = nextWorkspaceItems;
chats = nextChats; chats = nextChats;
searches = nextSearches; searches = nextSearches;
const nextItems = buildSidebarItems(nextChats, nextSearches); const nextItems = buildSidebarItems(nextWorkspaceItems);
if (options?.preferredSelection && hasItem(nextItems, options.preferredSelection)) { if (options?.preferredSelection && hasItem(nextItems, options.preferredSelection)) {
selectedItem = options.preferredSelection; selectedItem = options.preferredSelection;
draftKind = null; draftKind = null;
@@ -876,6 +911,7 @@ async function main() {
try { try {
const updated = await api.suggestChatTitle({ chatId, content }); const updated = await api.suggestChatTitle({ chatId, content });
chats = chats.map((chat) => (chat.id === updated.id ? { ...chat, title: updated.title, updatedAt: updated.updatedAt } : chat)); chats = chats.map((chat) => (chat.id === updated.id ? { ...chat, title: updated.title, updatedAt: updated.updatedAt } : chat));
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
if (selectedChat?.id === updated.id) { if (selectedChat?.id === updated.id) {
selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt }; selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt };
} }
@@ -920,6 +956,7 @@ async function main() {
chatId = chat.id; chatId = chat.id;
draftKind = null; draftKind = null;
chats = [chat, ...chats.filter((existing) => existing.id !== chat.id)]; chats = [chat, ...chats.filter((existing) => existing.id !== chat.id)];
workspaceItems = upsertWorkspaceItem(workspaceItems, chatWorkspaceItem(chat));
selectedItem = { kind: "chat", id: chat.id }; selectedItem = { kind: "chat", id: chat.id };
pendingChatState = pendingChatState ? { ...pendingChatState, chatId } : pendingChatState; pendingChatState = pendingChatState ? { ...pendingChatState, chatId } : pendingChatState;
selectedChat = { selectedChat = {
@@ -1085,6 +1122,7 @@ async function main() {
draftKind = null; draftKind = null;
selectedItem = { kind: "search", id: searchId }; selectedItem = { kind: "search", id: searchId };
searches = [search, ...searches.filter((existing) => existing.id !== search.id)]; searches = [search, ...searches.filter((existing) => existing.id !== search.id)];
workspaceItems = upsertWorkspaceItem(workspaceItems, searchWorkspaceItem(search));
selectedChat = null; selectedChat = null;
forceScrollToBottom = true; forceScrollToBottom = true;
updateUI(); updateUI();

View File

@@ -29,6 +29,16 @@ export type SearchSummary = {
updatedAt: string; updatedAt: string;
}; };
export type ChatWorkspaceItem = ChatSummary & {
type: "chat";
};
export type SearchWorkspaceItem = SearchSummary & {
type: "search";
};
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem;
export type Message = { export type Message = {
id: string; id: string;
createdAt: string; createdAt: string;

View File

@@ -1,5 +1,20 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { Check, ChevronDown, Globe2, LoaderCircle, Menu, MessageSquare, Paperclip, Plus, Rabbit, Search, SendHorizontal, Trash2, X } from "lucide-preact"; import {
Check,
ChevronDown,
Globe2,
LoaderCircle,
Menu,
MessageSquare,
Paperclip,
Plus,
Rabbit,
Search,
SendHorizontal,
Settings2,
Trash2,
X,
} from "lucide-preact";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@@ -18,13 +33,14 @@ import {
attachSearchStream, attachSearchStream,
getActiveRuns, getActiveRuns,
getChat, getChat,
listChatTools,
listModels, listModels,
getSearch, getSearch,
listChats, listWorkspaceItems,
listSearches,
runCompletionStream, runCompletionStream,
runSearchStream, runSearchStream,
suggestChatTitle, suggestChatTitle,
updateChatSettings,
getMessageAttachments, getMessageAttachments,
type ChatAttachment, type ChatAttachment,
type ActiveRunsResponse, type ActiveRunsResponse,
@@ -32,11 +48,13 @@ import {
type Provider, type Provider,
type ChatDetail, type ChatDetail,
type ChatSummary, type ChatSummary,
type ChatToolInfo,
type CompletionRequestMessage, type CompletionRequestMessage,
type Message, type Message,
type SearchDetail, type SearchDetail,
type SearchSummary, type SearchSummary,
type ToolCallEvent, type ToolCallEvent,
type WorkspaceItem,
} from "@/lib/api"; } from "@/lib/api";
import { useSessionAuth } from "@/hooks/use-session-auth"; import { useSessionAuth } from "@/hooks/use-session-auth";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -371,6 +389,30 @@ function getProviderLabel(provider: Provider | null | undefined) {
return ""; return "";
} }
function getToolLabel(name: string) {
if (name === "web_search") return "Web search";
if (name === "fetch_url") return "Fetch URL";
if (name === "codex_exec") return "Codex";
if (name === "shell_exec") return "Shell";
return name
.split("_")
.filter(Boolean)
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
.join(" ");
}
function getDefaultEnabledTools(availableTools: ChatToolInfo[]) {
return availableTools.map((tool) => tool.name);
}
function normalizeEnabledTools(value: unknown, availableTools: ChatToolInfo[]) {
const available = new Set(availableTools.map((tool) => tool.name));
if (!Array.isArray(value)) return getDefaultEnabledTools(availableTools);
return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))].filter((name) =>
available.has(name)
);
}
function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "lastUsedModel"> | Pick<ChatDetail, "lastUsedProvider" | "lastUsedModel"> | null) { function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "lastUsedModel"> | Pick<ChatDetail, "lastUsedProvider" | "lastUsedModel"> | null) {
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null; if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
return { return {
@@ -588,9 +630,34 @@ function getSearchTitle(search: Pick<SearchSummary, "title" | "query">) {
return "New search"; return "New search";
} }
function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] { function chatWorkspaceItem(chat: ChatSummary): WorkspaceItem {
const items: SidebarItem[] = [ return { type: "chat", ...chat };
...chats.map((chat) => ({ }
function searchWorkspaceItem(search: SearchSummary): WorkspaceItem {
return { type: "search", ...search };
}
function splitWorkspaceItems(items: WorkspaceItem[]) {
const chats: ChatSummary[] = [];
const searches: SearchSummary[] = [];
for (const item of items) {
if (item.type === "chat") {
const { type: _type, ...chat } = item;
chats.push(chat);
} else {
const { type: _type, ...search } = item;
searches.push(search);
}
}
return { chats, searches };
}
function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
return items.map((item) => {
if (item.type === "chat") {
const chat = item;
return {
kind: "chat" as const, kind: "chat" as const,
id: chat.id, id: chat.id,
title: getChatTitle(chat), title: getChatTitle(chat),
@@ -600,8 +667,11 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
initiatedModel: chat.initiatedModel, initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider, lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel, lastUsedModel: chat.lastUsedModel,
})), };
...searches.map((search) => ({ }
const search = item;
return {
kind: "search" as const, kind: "search" as const,
id: search.id, id: search.id,
title: getSearchTitle(search), title: getSearchTitle(search),
@@ -611,10 +681,21 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
initiatedModel: null, initiatedModel: null,
lastUsedProvider: null, lastUsedProvider: null,
lastUsedModel: null, lastUsedModel: null,
})), };
]; });
}
return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); function upsertWorkspaceItem(items: WorkspaceItem[], item: WorkspaceItem, moveToFront = true) {
const withoutExisting = items.filter((existing) => existing.type !== item.type || existing.id !== item.id);
if (moveToFront) {
return [item, ...withoutExisting];
}
const existingIndex = items.findIndex((existing) => existing.type === item.type && existing.id === item.id);
if (existingIndex < 0) return [item, ...items];
const next = [...items];
next[existingIndex] = item;
return next;
} }
function buildActiveRunsState(activeRuns: ActiveRunsResponse): ActiveRunsState { function buildActiveRunsState(activeRuns: ActiveRunsResponse): ActiveRunsState {
@@ -675,6 +756,7 @@ export default function App() {
const [chats, setChats] = useState<ChatSummary[]>([]); const [chats, setChats] = useState<ChatSummary[]>([]);
const [searches, setSearches] = useState<SearchSummary[]>([]); const [searches, setSearches] = useState<SearchSummary[]>([]);
const [workspaceItems, setWorkspaceItems] = useState<WorkspaceItem[]>([]);
const [selectedItem, setSelectedItem] = useState<SidebarSelection | null>(null); const [selectedItem, setSelectedItem] = useState<SidebarSelection | null>(null);
const [selectedChat, setSelectedChat] = useState<ChatDetail | null>(null); const [selectedChat, setSelectedChat] = useState<ChatDetail | null>(null);
const [selectedSearch, setSelectedSearch] = useState<SearchDetail | null>(null); const [selectedSearch, setSelectedSearch] = useState<SearchDetail | null>(null);
@@ -690,6 +772,7 @@ export default function App() {
const [isComposerDropActive, setIsComposerDropActive] = useState(false); const [isComposerDropActive, setIsComposerDropActive] = useState(false);
const [provider, setProvider] = useState<Provider>("openai"); const [provider, setProvider] = useState<Provider>("openai");
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG); const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG);
const [availableChatTools, setAvailableChatTools] = useState<ChatToolInfo[]>([]);
const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences()); const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences());
const [model, setModel] = useState(() => { const [model, setModel] = useState(() => {
const stored = loadStoredModelPreferences(); const stored = loadStoredModelPreferences();
@@ -712,6 +795,9 @@ export default function App() {
const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false); const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false);
const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null); const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isChatSettingsOpen, setIsChatSettingsOpen] = useState(false);
const [additionalSystemPrompt, setAdditionalSystemPrompt] = useState("");
const [enabledTools, setEnabledTools] = useState<string[]>([]);
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP); const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
const transcriptContainerRef = useRef<HTMLDivElement>(null); const transcriptContainerRef = useRef<HTMLDivElement>(null);
const transcriptEndRef = useRef<HTMLDivElement>(null); const transcriptEndRef = useRef<HTMLDivElement>(null);
@@ -801,7 +887,7 @@ export default function App() {
pendingAttachmentsRef.current = pendingAttachments; pendingAttachmentsRef.current = pendingAttachments;
}, [pendingAttachments]); }, [pendingAttachments]);
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]); const sidebarItems = useMemo(() => buildSidebarItems(workspaceItems), [workspaceItems]);
const filteredSidebarItems = useMemo(() => { const filteredSidebarItems = useMemo(() => {
const query = sidebarQuery.trim().toLowerCase(); const query = sidebarQuery.trim().toLowerCase();
if (!query) return sidebarItems; if (!query) return sidebarItems;
@@ -817,6 +903,7 @@ export default function App() {
const resetWorkspaceState = () => { const resetWorkspaceState = () => {
setChats([]); setChats([]);
setSearches([]); setSearches([]);
setWorkspaceItems([]);
setSelectedItem(null); setSelectedItem(null);
setSelectedChat(null); setSelectedChat(null);
setSelectedSearch(null); setSelectedSearch(null);
@@ -835,6 +922,9 @@ export default function App() {
searchRunCountersRef.current.clear(); searchRunCountersRef.current.clear();
setComposer(""); setComposer("");
setPendingAttachments([]); setPendingAttachments([]);
setIsChatSettingsOpen(false);
setAdditionalSystemPrompt("");
setEnabledTools([]);
setIsQuickQuestionOpen(false); setIsQuickQuestionOpen(false);
setQuickPrompt(""); setQuickPrompt("");
setQuickSubmittedPrompt(null); setQuickSubmittedPrompt(null);
@@ -852,15 +942,16 @@ export default function App() {
const refreshCollections = async (preferredSelection?: SidebarSelection) => { const refreshCollections = async (preferredSelection?: SidebarSelection) => {
setIsLoadingCollections(true); setIsLoadingCollections(true);
try { try {
const [nextChats, nextSearches] = await Promise.all([listChats(), listSearches()]); const nextWorkspaceItems = await listWorkspaceItems();
const nextItems = buildSidebarItems(nextChats, nextSearches); const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems);
setWorkspaceItems(nextWorkspaceItems);
setChats(nextChats); setChats(nextChats);
setSearches(nextSearches); setSearches(nextSearches);
setSelectedItem((current) => { setSelectedItem((current) => {
const hasItem = (candidate: SidebarSelection | null) => { const hasItem = (candidate: SidebarSelection | null) => {
if (!candidate) return false; if (!candidate) return false;
return nextItems.some((item) => item.kind === candidate.kind && item.id === candidate.id); return nextWorkspaceItems.some((item) => item.type === candidate.kind && item.id === candidate.id);
}; };
if (preferredSelection && hasItem(preferredSelection)) { if (preferredSelection && hasItem(preferredSelection)) {
@@ -869,8 +960,8 @@ export default function App() {
if (hasItem(current)) { if (hasItem(current)) {
return current; return current;
} }
const first = nextItems[0]; const first = nextWorkspaceItems[0];
return first ? { kind: first.kind, id: first.id } : null; return first ? { kind: first.type, id: first.id } : null;
}); });
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
@@ -898,6 +989,21 @@ export default function App() {
} }
}; };
const refreshChatTools = async () => {
try {
const tools = await listChatTools();
setAvailableChatTools(tools);
setEnabledTools((current) => normalizeEnabledTools(current.length ? current : null, tools));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setError(message);
}
}
};
const refreshActiveRuns = async () => { const refreshActiveRuns = async () => {
try { try {
const data = await getActiveRuns(); const data = await getActiveRuns();
@@ -950,7 +1056,7 @@ export default function App() {
if (!isAuthenticated) return; if (!isAuthenticated) return;
const preferredSelection = initialRouteSelectionRef.current; const preferredSelection = initialRouteSelectionRef.current;
initialRouteSelectionRef.current = null; initialRouteSelectionRef.current = null;
void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshActiveRuns()]); void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshChatTools(), refreshActiveRuns()]);
}, [isAuthenticated]); }, [isAuthenticated]);
useEffect(() => { useEffect(() => {
@@ -1212,6 +1318,19 @@ export default function App() {
setModel(nextSelection.model); setModel(nextSelection.model);
}, [draftKind, selectedChat, selectedChatSummary, selectedItem]); }, [draftKind, selectedChat, selectedChatSummary, selectedItem]);
useEffect(() => {
if (draftKind === "chat") {
setAdditionalSystemPrompt("");
setEnabledTools(getDefaultEnabledTools(availableChatTools));
return;
}
if (selectedItem?.kind !== "chat") return;
const chat = selectedChat?.id === selectedItem.id ? selectedChat : selectedChatSummary;
if (!chat) return;
setAdditionalSystemPrompt(chat.additionalSystemPrompt ?? "");
setEnabledTools(normalizeEnabledTools(chat.enabledTools, availableChatTools));
}, [availableChatTools, draftKind, selectedChat, selectedChatSummary, selectedItem]);
const selectedTitle = useMemo(() => { const selectedTitle = useMemo(() => {
if (draftKind === "chat") return "New chat"; if (draftKind === "chat") return "New chat";
if (draftKind === "search") return "New search"; if (draftKind === "search") return "New search";
@@ -1551,6 +1670,7 @@ export default function App() {
const withoutExisting = current.filter((existing) => existing.id !== chat.id); const withoutExisting = current.filter((existing) => existing.id !== chat.id);
return [chat, ...withoutExisting]; return [chat, ...withoutExisting];
}); });
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
setSelectedItem({ kind: "chat", id: chatId }); setSelectedItem({ kind: "chat", id: chatId });
setSelectedChat({ setSelectedChat({
id: chat.id, id: chat.id,
@@ -1616,6 +1736,7 @@ export default function App() {
return { ...chat, title: updatedChat.title, updatedAt: updatedChat.updatedAt }; return { ...chat, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
}) })
); );
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(updatedChat), false));
setSelectedChat((current) => { setSelectedChat((current) => {
if (!current || current.id !== updatedChat.id) return current; if (!current || current.id !== updatedChat.id) return current;
return { ...current, title: updatedChat.title, updatedAt: updatedChat.updatedAt }; return { ...current, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
@@ -1748,6 +1869,11 @@ export default function App() {
searchId = search.id; searchId = search.id;
setDraftKind(null); setDraftKind(null);
setSelectedItem({ kind: "search", id: searchId }); setSelectedItem({ kind: "search", id: searchId });
setSearches((current) => {
const withoutExisting = current.filter((existing) => existing.id !== search.id);
return [search, ...withoutExisting];
});
setWorkspaceItems((current) => upsertWorkspaceItem(current, searchWorkspaceItem(search)));
} }
if (!searchId) { if (!searchId) {
@@ -2121,6 +2247,7 @@ export default function App() {
const withoutExisting = current.filter((existing) => existing.id !== chat.id); const withoutExisting = current.filter((existing) => existing.id !== chat.id);
return [chat, ...withoutExisting]; return [chat, ...withoutExisting];
}); });
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
setSelectedItem({ kind: "chat", id: chat.id }); setSelectedItem({ kind: "chat", id: chat.id });
setSelectedChat({ setSelectedChat({
id: chat.id, id: chat.id,
@@ -2296,6 +2423,7 @@ export default function App() {
const withoutExisting = current.filter((existing) => existing.id !== chat.id); const withoutExisting = current.filter((existing) => existing.id !== chat.id);
return [chat, ...withoutExisting]; return [chat, ...withoutExisting];
}); });
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
setSelectedItem({ kind: "chat", id: chat.id }); setSelectedItem({ kind: "chat", id: chat.id });
setSelectedChat({ setSelectedChat({
id: chat.id, id: chat.id,
@@ -2510,7 +2638,7 @@ export default function App() {
onContextMenu={(event) => openContextMenu(event, { kind: item.kind, id: item.id })} onContextMenu={(event) => openContextMenu(event, { kind: item.kind, id: item.id })}
type="button" type="button"
> >
<div className="flex items-center gap-2"> <div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1">
<span <span
className={cn( className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border", "flex h-5 w-5 shrink-0 items-center justify-center rounded-md border",
@@ -2528,11 +2656,15 @@ export default function App() {
/> />
) : null} ) : null}
</span> </span>
<span className={cn("ml-auto shrink-0 text-xs", active ? "text-violet-100/86" : "text-violet-200/50")}>{formatDate(item.updatedAt)}</span> <span className="col-start-2 flex min-w-0 items-center gap-2">
</div> <span className="shrink-0 text-xs text-secondary-foreground/70">{formatDate(item.updatedAt)}</span>
{initiatedLabel ? ( {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} ) : null}
</span>
</div>
</button> </button>
); );
})} })}

View File

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