Compare commits
9 Commits
7d69cb4979
...
codex/syst
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3bb8503aa | ||
|
|
93e34d086f | ||
| f79e5e02c5 | |||
| 411790ee04 | |||
| a8e765e026 | |||
| 29c6dce0e5 | |||
| 5855b7edb8 | |||
| ac6d55f617 | |||
| 1e045db7f4 |
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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?,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,6 +219,11 @@ struct SybilQuickQuestionView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func submitQuestion() {
|
private func submitQuestion() {
|
||||||
|
guard viewModel.canSendQuickQuestion else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
promptFocused = false
|
||||||
_ = viewModel.sendQuickQuestion()
|
_ = viewModel.sendQuickQuestion()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,10 +159,7 @@ struct SybilSidebarItemList: View {
|
|||||||
.padding(10)
|
.padding(10)
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await viewModel.refreshVisibleContent(
|
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
||||||
refreshCollections: true,
|
|
||||||
refreshSelection: false
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Chat" ADD COLUMN "additionalSystemPrompt" TEXT;
|
||||||
|
ALTER TABLE "Chat" ADD COLUMN "enabledTools" JSONB;
|
||||||
@@ -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?
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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, """);
|
return value.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
@@ -198,11 +212,18 @@ export function buildOpenAIResponsesInputMessage(message: ChatMessage) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildSystemPromptAugmentationMessage(userLocation?: string) {
|
||||||
|
return {
|
||||||
|
role: "system",
|
||||||
|
content: buildSystemPromptAugmentation(userLocation),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const ANTHROPIC_NO_SERVER_TOOLS_PROMPT =
|
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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
server/tests/message-content.test.ts
Normal file
26
server/tests/message-content.test.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { buildSystemPromptAugmentation, getAnthropicSystemPrompt } from "../src/llm/message-content.js";
|
||||||
|
|
||||||
|
test("system prompt augmentation includes date and default location", () => {
|
||||||
|
const prompt = buildSystemPromptAugmentation(undefined, new Date("2026-05-24T15:30:00Z"));
|
||||||
|
|
||||||
|
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: San Francisco, CA.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("system prompt augmentation uses provided user location", () => {
|
||||||
|
const prompt = buildSystemPromptAugmentation("New York, NY", new Date("2026-05-24T15:30:00Z"));
|
||||||
|
|
||||||
|
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: New York, NY.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Anthropic system prompt includes runtime context with existing system messages", () => {
|
||||||
|
const prompt = getAnthropicSystemPrompt(
|
||||||
|
[{ role: "system", content: "Use concise answers." }],
|
||||||
|
"Los Angeles, CA"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(prompt, /Current date: \d{4}-\d{2}-\d{2}\./);
|
||||||
|
assert.match(prompt, /User location: Los Angeles, CA\./);
|
||||||
|
assert.match(prompt, /Use concise answers\./);
|
||||||
|
});
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
176
web/src/App.tsx
176
web/src/App.tsx
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user