2 Commits

23 changed files with 561 additions and 1235 deletions

View File

@@ -72,8 +72,6 @@ Behavior notes:
"title": "optional title",
"createdAt": "2026-02-14T00:00:00.000Z",
"updatedAt": "2026-02-14T00:00:00.000Z",
"starred": true,
"starredAt": "2026-02-14T01:00:00.000Z",
"initiatedProvider": "openai",
"initiatedModel": "gpt-4.1-mini",
"lastUsedProvider": "openai",
@@ -85,9 +83,7 @@ Behavior notes:
"title": "optional title",
"query": "search query",
"createdAt": "2026-02-14T00:00:00.000Z",
"updatedAt": "2026-02-14T00:00:00.000Z",
"starred": false,
"starredAt": null
"updatedAt": "2026-02-14T00:00:00.000Z"
}
]
}
@@ -97,7 +93,6 @@ 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.
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
## Chats
@@ -131,20 +126,8 @@ Behavior notes:
### `PATCH /v1/chats/:chatId`
- Body: `{ "title": string }`
- Response: `{ "chat": ChatSummary }`
- Blank titles are rejected. The server trims surrounding whitespace before storing the title.
- Renaming updates the returned chat's `updatedAt`.
- Not found: `404 { "message": "chat not found" }`
### `PATCH /v1/chats/:chatId/star`
- Body: `{ "starred": boolean }`
- Response: `{ "chat": ChatSummary }`
- Not found: `404 { "message": "chat not found" }`
Behavior notes:
- Starring adds the chat to the reserved `starred` project and sets `starredAt` to the membership creation time.
- Unstarring removes that membership and returns `starred: false`, `starredAt: null`.
- This does not modify the chat transcript or chat `updatedAt`.
### `POST /v1/chats/title/suggest`
- Body:
```json
@@ -157,8 +140,7 @@ Behavior notes:
Behavior notes:
- If the chat already has a non-empty title, server returns the existing chat unchanged.
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
- When no title exists at write time, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
- Server always uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
### `DELETE /v1/chats/:chatId`
- Response: `{ "deleted": true }`
@@ -297,16 +279,6 @@ Behavior notes:
- Body: `{ "title"?: string, "query"?: string }`
- Response: `{ "search": SearchSummary }`
### `PATCH /v1/searches/:searchId/star`
- Body: `{ "starred": boolean }`
- Response: `{ "search": SearchSummary }`
- Not found: `404 { "message": "search not found" }`
Behavior notes:
- Starring adds the search to the reserved `starred` project and sets `starredAt` to the membership creation time.
- Unstarring removes that membership and returns `starred: false`, `starredAt: null`.
- This does not modify the search results or search `updatedAt`.
### `DELETE /v1/searches/:searchId`
- Response: `{ "deleted": true }`
- Not found: `404 { "message": "search not found" }`
@@ -379,8 +351,6 @@ Behavior notes:
"title": null,
"createdAt": "...",
"updatedAt": "...",
"starred": false,
"starredAt": null,
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
"initiatedModel": "string|null",
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
@@ -429,8 +399,6 @@ Behavior notes:
"title": null,
"createdAt": "...",
"updatedAt": "...",
"starred": false,
"starredAt": null,
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
"initiatedModel": "string|null",
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
@@ -441,7 +409,7 @@ Behavior notes:
`SearchSummary`
```json
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "...", "starred": false, "starredAt": null }
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "..." }
```
`SearchDetail`
@@ -452,8 +420,6 @@ Behavior notes:
"query": "...",
"createdAt": "...",
"updatedAt": "...",
"starred": false,
"starredAt": null,
"requestId": "...",
"latencyMs": 123,
"error": null,

View File

@@ -74,26 +74,6 @@ actor SybilAPIClient: SybilAPIClienting {
return response.chat
}
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
let response = try await request(
"/v1/chats/\(chatID)",
method: "PATCH",
body: AnyEncodable(ChatTitleUpdateBody(title: title)),
responseType: ChatCreateResponse.self
)
return response.chat
}
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
let response = try await request(
"/v1/chats/\(chatID)/star",
method: "PATCH",
body: AnyEncodable(StarUpdateBody(starred: starred)),
responseType: ChatCreateResponse.self
)
return response.chat
}
func deleteChat(chatID: String) async throws {
_ = try await request("/v1/chats/\(chatID)", method: "DELETE", responseType: DeleteResponse.self)
}
@@ -138,16 +118,6 @@ actor SybilAPIClient: SybilAPIClienting {
return response.chat
}
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary {
let response = try await request(
"/v1/searches/\(searchID)/star",
method: "PATCH",
body: AnyEncodable(StarUpdateBody(starred: starred)),
responseType: SearchCreateResponse.self
)
return response.search
}
func deleteSearch(searchID: String) async throws {
_ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self)
}
@@ -661,6 +631,7 @@ struct CompletionStreamRequest: Codable, Sendable {
var provider: Provider
var model: String
var messages: [CompletionRequestMessage]
var userLocation: String? = nil
}
private struct ChatCreateBody: Encodable {
@@ -670,14 +641,6 @@ private struct ChatCreateBody: Encodable {
var messages: [CompletionRequestMessage]?
}
private struct ChatTitleUpdateBody: Encodable {
var title: String
}
private struct StarUpdateBody: Encodable {
var starred: Bool
}
private struct SearchCreateBody: Encodable {
var title: String?
var query: String?

View File

@@ -11,15 +11,12 @@ protocol SybilAPIClienting: Sendable {
messages: [CompletionRequestMessage]?
) async throws -> ChatSummary
func getChat(chatID: String) async throws -> ChatDetail
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
func deleteChat(chatID: String) async throws
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
func listSearches() async throws -> [SearchSummary]
func createSearch(title: String?, query: String?) async throws -> SearchSummary
func getSearch(searchID: String) async throws -> SearchDetail
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary
func deleteSearch(searchID: String) async throws
func listModels() async throws -> ModelCatalogResponse
func getActiveRuns() async throws -> ActiveRunsResponse

View File

@@ -154,8 +154,6 @@ public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
public var title: String?
public var createdAt: Date
public var updatedAt: Date
public var starred = false
public var starredAt: Date?
public var initiatedProvider: Provider?
public var initiatedModel: String?
public var lastUsedProvider: Provider?
@@ -168,8 +166,6 @@ public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
public var query: String?
public var createdAt: Date
public var updatedAt: Date
public var starred = false
public var starredAt: Date?
}
public enum WorkspaceItemType: String, Codable, Hashable, Sendable {
@@ -184,8 +180,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
public var query: String?
public var createdAt: Date
public var updatedAt: Date
public var starred = false
public var starredAt: Date?
public var initiatedProvider: Provider?
public var initiatedModel: String?
public var lastUsedProvider: Provider?
@@ -198,8 +192,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
self.query = nil
self.createdAt = chat.createdAt
self.updatedAt = chat.updatedAt
self.starred = chat.starred
self.starredAt = chat.starredAt
self.initiatedProvider = chat.initiatedProvider
self.initiatedModel = chat.initiatedModel
self.lastUsedProvider = chat.lastUsedProvider
@@ -213,8 +205,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
self.query = search.query
self.createdAt = search.createdAt
self.updatedAt = search.updatedAt
self.starred = search.starred
self.starredAt = search.starredAt
self.initiatedProvider = nil
self.initiatedModel = nil
self.lastUsedProvider = nil
@@ -228,8 +218,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
title: title,
createdAt: createdAt,
updatedAt: updatedAt,
starred: starred,
starredAt: starredAt,
initiatedProvider: initiatedProvider,
initiatedModel: initiatedModel,
lastUsedProvider: lastUsedProvider,
@@ -244,9 +232,7 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
title: title,
query: query,
createdAt: createdAt,
updatedAt: updatedAt,
starred: starred,
starredAt: starredAt
updatedAt: updatedAt
)
}
}
@@ -391,8 +377,6 @@ public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
public var title: String?
public var createdAt: Date
public var updatedAt: Date
public var starred = false
public var starredAt: Date?
public var initiatedProvider: Provider?
public var initiatedModel: String?
public var lastUsedProvider: Provider?
@@ -431,8 +415,6 @@ public struct SearchDetail: Codable, Identifiable, Hashable, Sendable {
public var query: String?
public var createdAt: Date
public var updatedAt: Date
public var starred = false
public var starredAt: Date?
public var requestId: String?
public var latencyMs: Int?
public var error: String?

View File

@@ -111,108 +111,56 @@ struct SybilSidebarItemList: View {
@Bindable var viewModel: SybilViewModel
var isSelected: (SidebarItem) -> Bool
var onSelect: (SidebarItem) -> Void
@State private var renameTarget: SidebarItem?
@State private var renameTitle = ""
private var isRenameAlertPresented: Binding<Bool> {
Binding {
renameTarget != nil
} set: { isPresented in
if !isPresented {
renameTarget = nil
renameTitle = ""
}
}
}
var body: some View {
Group {
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ProgressView()
.tint(SybilTheme.primary)
Text("Loading conversations…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(16)
} else if viewModel.sidebarItems.isEmpty {
VStack(spacing: 10) {
Image(systemName: "message.badge")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.")
.font(.sybil(.footnote))
.multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(viewModel.sidebarItems) { item in
Button {
onSelect(item)
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ProgressView()
.tint(SybilTheme.primary)
Text("Loading conversations…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(16)
} else if viewModel.sidebarItems.isEmpty {
VStack(spacing: 10) {
Image(systemName: "message.badge")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.")
.font(.sybil(.footnote))
.multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(viewModel.sidebarItems) { item in
Button {
onSelect(item)
} label: {
SybilSidebarRow(item: item, isSelected: isSelected(item))
}
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
Task {
await viewModel.deleteItem(item.selection)
}
} label: {
SybilSidebarRow(item: item, isSelected: isSelected(item))
}
.buttonStyle(.plain)
.contextMenu {
Button {
Task {
await viewModel.setItemStarred(item.selection, starred: !item.starred)
}
} label: {
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
}
if item.kind == .chat {
Button {
renameTarget = item
renameTitle = item.title
} label: {
Label("Rename", systemImage: "pencil")
}
}
Button(role: .destructive) {
Task {
await viewModel.deleteItem(item.selection)
}
} label: {
Label("Delete", systemImage: "trash")
}
Label("Delete", systemImage: "trash")
}
}
}
.padding(10)
}
.refreshable {
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
}
.padding(10)
}
}
.alert("Rename Chat", isPresented: isRenameAlertPresented) {
TextField("Title", text: $renameTitle)
Button("Cancel", role: .cancel) {
renameTarget = nil
renameTitle = ""
.refreshable {
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
}
Button("Save") {
let target = renameTarget
let title = renameTitle
renameTarget = nil
renameTitle = ""
if let target, case let .chat(chatID) = target.selection {
Task {
await viewModel.renameChat(chatID: chatID, title: title)
}
}
}
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
@@ -253,12 +201,6 @@ struct SybilSidebarRow: View {
.lineLimit(1)
.layoutPriority(1)
if item.starred {
Image(systemName: "star.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.yellow)
}
Spacer(minLength: 8)
if item.isRunning {

View File

@@ -34,8 +34,6 @@ struct SidebarItem: Identifiable, Hashable {
var kind: Kind
var title: String
var updatedAt: Date
var starred: Bool
var starredAt: Date?
var initiatedLabel: String?
var isRunning: Bool
}
@@ -410,8 +408,6 @@ final class SybilViewModel {
kind: .chat,
title: chatTitle(title: item.title, messages: nil),
updatedAt: item.updatedAt,
starred: item.starred,
starredAt: item.starredAt,
initiatedLabel: initiatedLabel,
isRunning: isChatRowRunning(item.id)
)
@@ -422,8 +418,6 @@ final class SybilViewModel {
kind: .search,
title: searchTitle(title: item.title, query: item.query),
updatedAt: item.updatedAt,
starred: item.starred,
starredAt: item.starredAt,
initiatedLabel: "exa",
isRunning: isSearchRowRunning(item.id)
)
@@ -687,8 +681,6 @@ final class SybilViewModel {
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
starred: chat.starred,
starredAt: chat.starredAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
@@ -859,57 +851,6 @@ final class SybilViewModel {
}
}
func renameChat(chatID: String, title: String) async {
guard isAuthenticated else {
return
}
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedTitle.isEmpty else {
errorMessage = "Enter a chat title."
return
}
SybilLog.info(SybilLog.ui, "Renaming chat \(chatID)")
errorMessage = nil
do {
let updated = try await client().updateChatTitle(chatID: chatID, title: trimmedTitle)
applyChatSummary(updated, moveToFront: true)
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Rename failed", error: error)
}
}
func setItemStarred(_ selection: SidebarSelection, starred: Bool) async {
guard isAuthenticated else {
return
}
guard case .settings = selection else {
errorMessage = nil
do {
let client = try client()
switch selection {
case let .chat(chatID):
let updated = try await client.updateChatStar(chatID: chatID, starred: starred)
applyChatSummary(updated, moveToFront: false)
case let .search(searchID):
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
applySearchSummary(updated, moveToFront: false)
case .settings:
break
}
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Star update failed", error: error)
}
return
}
}
func refreshAfterSettingsChange() async {
SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
settings.persist()
@@ -1440,47 +1381,6 @@ final class SybilViewModel {
searches = items.compactMap(\.searchSummary)
}
private func applyChatSummary(_ chat: ChatSummary, moveToFront: Bool) {
if let existingIndex = chats.firstIndex(where: { $0.id == chat.id }) {
chats.remove(at: existingIndex)
chats.insert(chat, at: moveToFront ? 0 : existingIndex)
} else {
chats.insert(chat, at: 0)
}
upsertWorkspaceChat(chat, moveToFront: moveToFront)
if selectedChat?.id == chat.id {
selectedChat?.title = chat.title
selectedChat?.updatedAt = chat.updatedAt
selectedChat?.starred = chat.starred
selectedChat?.starredAt = chat.starredAt
selectedChat?.initiatedProvider = chat.initiatedProvider
selectedChat?.initiatedModel = chat.initiatedModel
selectedChat?.lastUsedProvider = chat.lastUsedProvider
selectedChat?.lastUsedModel = chat.lastUsedModel
}
}
private func applySearchSummary(_ search: SearchSummary, moveToFront: Bool) {
if let existingIndex = searches.firstIndex(where: { $0.id == search.id }) {
searches.remove(at: existingIndex)
searches.insert(search, at: moveToFront ? 0 : existingIndex)
} else {
searches.insert(search, at: 0)
}
upsertWorkspaceSearch(search, moveToFront: moveToFront)
if selectedSearch?.id == search.id {
selectedSearch?.title = search.title
selectedSearch?.query = search.query
selectedSearch?.updatedAt = search.updatedAt
selectedSearch?.starred = search.starred
selectedSearch?.starredAt = search.starredAt
}
}
private func upsertWorkspaceChat(_ chat: ChatSummary, moveToFront: Bool = true) {
upsertWorkspaceItem(WorkspaceItem(chat: chat), moveToFront: moveToFront)
}
@@ -1845,8 +1745,6 @@ final class SybilViewModel {
title: created.title,
createdAt: created.createdAt,
updatedAt: created.updatedAt,
starred: created.starred,
starredAt: created.starredAt,
initiatedProvider: created.initiatedProvider,
initiatedModel: created.initiatedModel,
lastUsedProvider: created.lastUsedProvider,
@@ -1907,7 +1805,18 @@ final class SybilViewModel {
let titleSeed = !content.isEmpty ? content : SybilChatAttachmentSupport.attachmentSummary(attachments)
let updated = try await client.suggestChatTitle(chatID: chatID, content: titleSeed.isEmpty ? "Uploaded files" : titleSeed)
await MainActor.run {
self.applyChatSummary(updated, moveToFront: false)
self.chats = self.chats.map { existing in
if existing.id == updated.id {
return updated
}
return existing
}
self.upsertWorkspaceChat(updated, moveToFront: false)
if self.selectedChat?.id == updated.id {
self.selectedChat?.title = updated.title
self.selectedChat?.updatedAt = updated.updatedAt
}
}
} catch {
SybilLog.warning(SybilLog.app, "Chat title suggestion failed: \(SybilLog.describe(error))")
@@ -2066,8 +1975,6 @@ final class SybilViewModel {
query: query,
createdAt: currentSelectedSearch?.createdAt ?? now,
updatedAt: now,
starred: currentSelectedSearch?.starred ?? false,
starredAt: currentSelectedSearch?.starredAt,
requestId: nil,
latencyMs: nil,
error: nil,

View File

@@ -9,9 +9,6 @@ private struct MockClientCallSnapshot: Sendable {
var listSearches = 0
var createChat = 0
var getChat = 0
var updateChatTitle = 0
var updateChatStar = 0
var updateSearchStar = 0
var getSearch = 0
var getActiveRuns = 0
var runCompletionStream = 0
@@ -35,9 +32,6 @@ private actor MockSybilClient: SybilAPIClienting {
private let chatDetails: [String: ChatDetail]
private let searchDetails: [String: SearchDetail]
private let createChatResponse: ChatSummary?
private let updateChatTitleResponses: [String: ChatSummary]
private let updateChatStarResponses: [String: ChatSummary]
private let updateSearchStarResponses: [String: SearchSummary]
private let activeRunsResponse: ActiveRunsResponse
private var snapshot = MockClientCallSnapshot()
@@ -63,9 +57,6 @@ private actor MockSybilClient: SybilAPIClienting {
chatDetails: [String: ChatDetail] = [:],
searchDetails: [String: SearchDetail] = [:],
createChatResponse: ChatSummary? = nil,
updateChatTitleResponses: [String: ChatSummary] = [:],
updateChatStarResponses: [String: ChatSummary] = [:],
updateSearchStarResponses: [String: SearchSummary] = [:],
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
workspaceItemsResponse: [WorkspaceItem]? = nil
) {
@@ -75,9 +66,6 @@ private actor MockSybilClient: SybilAPIClienting {
self.chatDetails = chatDetails
self.searchDetails = searchDetails
self.createChatResponse = createChatResponse
self.updateChatTitleResponses = updateChatTitleResponses
self.updateChatStarResponses = updateChatStarResponses
self.updateSearchStarResponses = updateSearchStarResponses
self.activeRunsResponse = activeRunsResponse
}
@@ -194,22 +182,6 @@ private actor MockSybilClient: SybilAPIClienting {
return detail
}
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
snapshot.updateChatTitle += 1
guard let summary = updateChatTitleResponses[chatID] else {
throw UnexpectedClientCall()
}
return summary
}
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
snapshot.updateChatStar += 1
guard let summary = updateChatStarResponses[chatID] else {
throw UnexpectedClientCall()
}
return summary
}
func deleteChat(chatID: String) async throws {
throw UnexpectedClientCall()
}
@@ -245,14 +217,6 @@ private actor MockSybilClient: SybilAPIClienting {
throw UnexpectedClientCall()
}
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary {
snapshot.updateSearchStar += 1
guard let summary = updateSearchStarResponses[searchID] else {
throw UnexpectedClientCall()
}
return summary
}
func deleteSearch(searchID: String) async throws {
throw UnexpectedClientCall()
}
@@ -497,77 +461,6 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
}
@MainActor
@Test func renameChatUpdatesSidebarAndSelectedTranscriptTitle() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_150)
let original = makeChatSummary(id: "chat-rename", date: date)
let renamed = ChatSummary(
id: "chat-rename",
title: "Renamed chat",
createdAt: date,
updatedAt: date.addingTimeInterval(60),
initiatedProvider: .openai,
initiatedModel: "gpt-4.1-mini",
lastUsedProvider: .openai,
lastUsedModel: "gpt-4.1-mini"
)
let detail = makeChatDetail(id: "chat-rename", date: date, body: "existing transcript")
let client = MockSybilClient(
chatsResponse: [original],
updateChatTitleResponses: ["chat-rename": renamed]
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.chats = [original]
viewModel.workspaceItems = [WorkspaceItem(chat: original)]
viewModel.selectedItem = .chat("chat-rename")
viewModel.selectedChat = detail
await viewModel.renameChat(chatID: "chat-rename", title: " Renamed chat ")
let snapshot = await client.currentSnapshot()
#expect(snapshot.updateChatTitle == 1)
#expect(viewModel.sidebarItems.first?.title == "Renamed chat")
#expect(viewModel.selectedChat?.title == "Renamed chat")
#expect(viewModel.errorMessage == nil)
}
@MainActor
@Test func starringItemsUpdatesSidebarState() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_175)
let chat = makeChatSummary(id: "chat-star", date: date)
let search = makeSearchSummary(id: "search-star", date: date)
var starredChat = chat
starredChat.starred = true
starredChat.starredAt = date.addingTimeInterval(5)
var starredSearch = search
starredSearch.starred = true
starredSearch.starredAt = date.addingTimeInterval(10)
let client = MockSybilClient(
chatsResponse: [chat],
searchesResponse: [search],
updateChatStarResponses: ["chat-star": starredChat],
updateSearchStarResponses: ["search-star": starredSearch]
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.chats = [chat]
viewModel.searches = [search]
viewModel.workspaceItems = [WorkspaceItem(chat: chat), WorkspaceItem(search: search)]
await viewModel.setItemStarred(.chat("chat-star"), starred: true)
await viewModel.setItemStarred(.search("search-star"), starred: true)
let snapshot = await client.currentSnapshot()
#expect(snapshot.updateChatStar == 1)
#expect(snapshot.updateSearchStar == 1)
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat("chat-star") })?.starred == true)
#expect(viewModel.sidebarItems.first(where: { $0.selection == .search("search-star") })?.starred == true)
}
@MainActor
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_200)

View File

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

View File

@@ -1,44 +0,0 @@
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"kind" TEXT NOT NULL DEFAULT 'folder',
"title" TEXT NOT NULL,
"userId" TEXT,
CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ProjectItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"projectId" TEXT NOT NULL,
"chatId" TEXT,
"searchId" TEXT,
CONSTRAINT "ProjectItem_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ProjectItem_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ProjectItem_searchId_fkey" FOREIGN KEY ("searchId") REFERENCES "Search" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ProjectItem_one_target_check" CHECK (("chatId" IS NOT NULL AND "searchId" IS NULL) OR ("chatId" IS NULL AND "searchId" IS NOT NULL))
);
-- CreateIndex
CREATE INDEX "Project_kind_idx" ON "Project"("kind");
-- CreateIndex
CREATE INDEX "Project_userId_idx" ON "Project"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectItem_projectId_chatId_key" ON "ProjectItem"("projectId", "chatId");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectItem_projectId_searchId_key" ON "ProjectItem"("projectId", "searchId");
-- CreateIndex
CREATE INDEX "ProjectItem_projectId_createdAt_idx" ON "ProjectItem"("projectId", "createdAt");
-- CreateIndex
CREATE INDEX "ProjectItem_chatId_idx" ON "ProjectItem"("chatId");
-- CreateIndex
CREATE INDEX "ProjectItem_searchId_idx" ON "ProjectItem"("searchId");

View File

@@ -27,11 +27,6 @@ enum SearchSource {
exa
}
enum ProjectKind {
starred
folder
}
model User {
id String @id @default(cuid())
createdAt DateTime @default(now())
@@ -42,7 +37,6 @@ model User {
chats Chat[]
searches Search[]
projects Project[]
}
model Chat {
@@ -57,12 +51,14 @@ model Chat {
lastUsedProvider Provider?
lastUsedModel String?
additionalSystemPrompt String?
enabledTools Json?
user User? @relation(fields: [userId], references: [id])
userId String?
messages Message[]
calls LlmCall[]
projectItems ProjectItem[]
@@index([userId])
}
@@ -136,7 +132,6 @@ model Search {
userId String?
results SearchResult[]
projectItems ProjectItem[]
@@index([updatedAt])
@@index([userId])
@@ -164,40 +159,3 @@ model SearchResult {
@@index([searchId, rank])
}
model Project {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
kind ProjectKind @default(folder)
title String
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String?
items ProjectItem[]
@@index([kind])
@@index([userId])
}
model ProjectItem {
id String @id @default(cuid())
createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String
chat Chat? @relation(fields: [chatId], references: [id], onDelete: Cascade)
chatId String?
search Search? @relation(fields: [searchId], references: [id], onDelete: Cascade)
searchId String?
@@unique([projectId, chatId])
@@unique([projectId, searchId])
@@index([projectId, createdAt])
@@index([chatId])
@@index([searchId])
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import { env } from "./env.js";
import { buildComparableAttachments } from "./llm/message-content.js";
import { runMultiplex } from "./llm/multiplexer.js";
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
import { getAvailableChatTools, normalizeEnabledChatTools } from "./llm/chat-tools.js";
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
import { openaiClient } from "./llm/providers.js";
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
@@ -15,6 +16,8 @@ import { exaClient } from "./search/exa.js";
import type { ChatAttachment } from "./llm/types.js";
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000;
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
type IncomingChatMessage = {
role: "system" | "user" | "assistant" | "tool";
@@ -47,6 +50,43 @@ function isToolCallLogMessage(message: { role: string; metadata: unknown }) {
return message.role === "tool" && isToolCallLogMetadata(message.metadata);
}
function getHeaderString(req: FastifyRequest, name: string) {
const value = req.headers[name.toLowerCase()];
if (Array.isArray(value)) return value.find((item) => item.trim());
return typeof value === "string" && value.trim() ? value : undefined;
}
function decodeHeaderPart(value: string | undefined) {
if (!value) return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
try {
return decodeURIComponent(trimmed);
} catch {
return trimmed;
}
}
function inferRequestUserLocation(req: FastifyRequest) {
const explicit = decodeHeaderPart(getHeaderString(req, "x-user-location"));
if (explicit) return explicit;
const vercelCity = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-city"));
const vercelRegion = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-country-region"));
const vercelCountry = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-country"));
const vercelLocation = [vercelCity, vercelRegion, vercelCountry].filter(Boolean).join(", ");
if (vercelLocation) return vercelLocation;
const cfCity = decodeHeaderPart(getHeaderString(req, "cf-ipcity"));
const cfRegion = decodeHeaderPart(getHeaderString(req, "cf-region"));
const cfCountry = decodeHeaderPart(getHeaderString(req, "cf-ipcountry"));
return [cfCity, cfRegion, cfCountry].filter(Boolean).join(", ") || undefined;
}
function withRequestUserLocation<T extends { userLocation?: string }>(body: T, req: FastifyRequest): T {
return body.userLocation ? body : { ...body, userLocation: inferRequestUserLocation(req) };
}
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
const incoming = messages.filter((m) => m.role !== "assistant");
if (!incoming.length) return;
@@ -131,6 +171,9 @@ const CompletionStreamBody = z
provider: ProviderSchema,
model: z.string().min(1),
messages: z.array(CompletionMessageSchema),
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
enabledTools: EnabledToolsSchema.optional(),
userLocation: z.string().trim().min(1).max(200).optional(),
temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().int().positive().optional(),
})
@@ -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({
query: z.string().trim().min(1).optional(),
title: z.string().trim().min(1).optional(),
@@ -321,34 +399,6 @@ type SearchRunRequest = z.infer<typeof SearchRunBody>;
const activeChatStreams = new Map<string, ActiveSseStream>();
const activeSearchStreams = new Map<string, ActiveSseStream>();
const STARRED_PROJECT_ID = "starred";
const starredProjectItemsSelect = {
where: { projectId: STARRED_PROJECT_ID },
select: { createdAt: true },
take: 1,
} as const;
const chatSummarySelect = {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
projectItems: starredProjectItemsSelect,
} as const;
const searchSummarySelect = {
id: true,
title: true,
query: true,
createdAt: true,
updatedAt: true,
projectItems: starredProjectItemsSelect,
} as const;
function getErrorMessage(err: unknown) {
return err instanceof Error ? err.message : String(err);
@@ -358,111 +408,34 @@ function compareUpdatedAtDesc(a: { updatedAt: Date | string }, b: { updatedAt: D
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
}
function serializeStarFields(item: { projectItems?: Array<{ createdAt: Date }> }) {
const star = item.projectItems?.[0];
return {
starred: Boolean(star),
starredAt: star?.createdAt ?? null,
};
}
function serializeChatLike<T extends Record<string, any>>(chat: T) {
const { projectItems: _projectItems, ...rest } = chat;
return {
...serializeProviderFields(rest),
...serializeStarFields(chat),
};
}
function serializeSearchLike<T extends Record<string, any>>(search: T) {
const { projectItems: _projectItems, ...rest } = search;
return {
...rest,
...serializeStarFields(search),
};
}
async function ensureStarredProject() {
await prisma.project.upsert({
where: { id: STARRED_PROJECT_ID },
update: {},
create: {
id: STARRED_PROJECT_ID,
kind: "starred" as any,
title: "Starred",
},
});
}
async function getChatSummary(chatId: string) {
const chat = await prisma.chat.findUnique({
where: { id: chatId },
select: chatSummarySelect,
});
return chat ? serializeChatLike(chat) : null;
}
async function getSearchSummary(searchId: string) {
const search = await prisma.search.findUnique({
where: { id: searchId },
select: searchSummarySelect,
});
return search ? serializeSearchLike(search) : null;
}
async function setChatStarred(chatId: string, starred: boolean) {
const exists = await prisma.chat.findUnique({ where: { id: chatId }, select: { id: true } });
if (!exists) return null;
if (starred) {
await ensureStarredProject();
await prisma.projectItem.upsert({
where: { projectId_chatId: { projectId: STARRED_PROJECT_ID, chatId } },
update: {},
create: { projectId: STARRED_PROJECT_ID, chatId },
});
} else {
await prisma.projectItem.deleteMany({ where: { projectId: STARRED_PROJECT_ID, chatId } });
}
return getChatSummary(chatId);
}
async function setSearchStarred(searchId: string, starred: boolean) {
const exists = await prisma.search.findUnique({ where: { id: searchId }, select: { id: true } });
if (!exists) return null;
if (starred) {
await ensureStarredProject();
await prisma.projectItem.upsert({
where: { projectId_searchId: { projectId: STARRED_PROJECT_ID, searchId } },
update: {},
create: { projectId: STARRED_PROJECT_ID, searchId },
});
} else {
await prisma.projectItem.deleteMany({ where: { projectId: STARRED_PROJECT_ID, searchId } });
}
return getSearchSummary(searchId);
}
async function listWorkspaceItems() {
const [chats, searches] = await Promise.all([
prisma.chat.findMany({
orderBy: { updatedAt: "desc" },
take: 100,
select: chatSummarySelect,
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: searchSummarySelect,
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
}),
]);
return [
...chats.map((chat) => ({ type: "chat" as const, ...serializeChatLike(chat) })),
...searches.map((search) => ({ type: "search" as const, ...serializeSearchLike(search) })),
...chats.map((chat) => ({ type: "chat" as const, ...serializeProviderFields(chat) })),
...searches.map((search) => ({ type: "search" as const, ...search })),
].sort(compareUpdatedAtDesc);
}
@@ -669,15 +642,12 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
const search = await prisma.search.findUnique({
where: { id: searchId },
include: {
results: { orderBy: { rank: "asc" } },
projectItems: starredProjectItemsSelect,
},
include: { results: { orderBy: { rank: "asc" } } },
});
if (!search) {
stream.complete({ event: "error", data: { message: "search not found" } });
} else {
stream.complete({ event: "done", data: { search: serializeSearchLike(search) } });
stream.complete({ event: "done", data: { search } });
}
} catch (err) {
const message = getErrorMessage(err);
@@ -713,6 +683,11 @@ export async function registerRoutes(app: FastifyInstance) {
return { providers: getModelCatalogSnapshot() };
});
app.get("/v1/chat-tools", async (req) => {
requireAdmin(req);
return { tools: getAvailableChatTools() };
});
app.get("/v1/active-runs", async (req) => {
requireAdmin(req);
return {
@@ -731,9 +706,20 @@ export async function registerRoutes(app: FastifyInstance) {
const chats = await prisma.chat.findMany({
orderBy: { updatedAt: "desc" },
take: 100,
select: chatSummarySelect,
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
},
});
return { chats: chats.map((chat) => serializeChatLike(chat)) };
return { chats: chats.map((chat) => serializeProviderFields(chat)) };
});
app.post("/v1/chats", async (req) => {
@@ -743,6 +729,8 @@ export async function registerRoutes(app: FastifyInstance) {
title: z.string().optional(),
provider: ProviderSchema.optional(),
model: z.string().trim().min(1).optional(),
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
enabledTools: EnabledToolsSchema.optional(),
messages: z.array(CompletionMessageSchema).optional(),
})
.superRefine((value, ctx) => {
@@ -771,6 +759,8 @@ export async function registerRoutes(app: FastifyInstance) {
initiatedModel: body.model,
lastUsedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
lastUsedModel: body.model,
additionalSystemPrompt: normalizeAdditionalSystemPrompt(body.additionalSystemPrompt),
enabledTools: body.enabledTools as any,
messages: body.messages?.length
? {
create: body.messages.map((message) => ({
@@ -782,40 +772,62 @@ export async function registerRoutes(app: FastifyInstance) {
}
: undefined,
},
select: chatSummarySelect,
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
},
});
return { chat: serializeChatLike(chat) };
return { chat: serializeProviderFields(chat) };
});
app.patch("/v1/chats/:chatId", async (req) => {
requireAdmin(req);
const Params = z.object({ chatId: z.string() });
const Body = z.object({ title: z.string().trim().min(1) });
const Body = z.object({
title: z.string().trim().min(1).optional(),
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).nullable().optional(),
enabledTools: EnabledToolsSchema.optional(),
});
const { chatId } = Params.parse(req.params);
const body = Body.parse(req.body ?? {});
const data: Record<string, unknown> = {};
if (body.title !== undefined) data.title = body.title;
if (body.additionalSystemPrompt !== undefined) data.additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
if (body.enabledTools !== undefined) data.enabledTools = body.enabledTools;
const updated = await prisma.chat.updateMany({
where: { id: chatId },
data: { title: body.title },
data: data as any,
});
if (updated.count === 0) return app.httpErrors.notFound("chat not found");
const chat = await getChatSummary(chatId);
const chat = await prisma.chat.findUnique({
where: { id: chatId },
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
},
});
if (!chat) return app.httpErrors.notFound("chat not found");
return { chat };
});
app.patch("/v1/chats/:chatId/star", async (req) => {
requireAdmin(req);
const Params = z.object({ chatId: z.string() });
const Body = z.object({ starred: z.boolean() });
const { chatId } = Params.parse(req.params);
const body = Body.parse(req.body ?? {});
const chat = await setChatStarred(chatId, body.starred);
if (!chat) return app.httpErrors.notFound("chat not found");
return { chat };
return { chat: serializeProviderFields(chat) };
});
app.post("/v1/chats/title/suggest", async (req) => {
@@ -828,24 +840,44 @@ export async function registerRoutes(app: FastifyInstance) {
const existing = await prisma.chat.findUnique({
where: { id: body.chatId },
select: chatSummarySelect,
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
},
});
if (!existing) return app.httpErrors.notFound("chat not found");
if (existing.title?.trim()) return { chat: serializeChatLike(existing) };
if (existing.title?.trim()) return { chat: serializeProviderFields(existing) };
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
const suggestedRaw = await generateChatTitle(body.content);
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
await prisma.chat.updateMany({
where: { id: body.chatId, title: existing.title },
const chat = await prisma.chat.update({
where: { id: body.chatId },
data: { title },
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
},
});
const chat = await getChatSummary(body.chatId);
if (!chat) return app.httpErrors.notFound("chat not found");
return { chat };
return { chat: serializeProviderFields(chat) };
});
app.delete("/v1/chats/:chatId", async (req) => {
@@ -870,9 +902,9 @@ export async function registerRoutes(app: FastifyInstance) {
const searches = await prisma.search.findMany({
orderBy: { updatedAt: "desc" },
take: 100,
select: searchSummarySelect,
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
});
return { searches: searches.map((search) => serializeSearchLike(search)) };
return { searches };
});
app.post("/v1/searches", async (req) => {
@@ -886,20 +918,8 @@ export async function registerRoutes(app: FastifyInstance) {
title: title || null,
query,
},
select: searchSummarySelect,
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
});
return { search: serializeSearchLike(search) };
});
app.patch("/v1/searches/:searchId/star", async (req) => {
requireAdmin(req);
const Params = z.object({ searchId: z.string() });
const Body = z.object({ starred: z.boolean() });
const { searchId } = Params.parse(req.params);
const body = Body.parse(req.body ?? {});
const search = await setSearchStarred(searchId, body.starred);
if (!search) return app.httpErrors.notFound("search not found");
return { search };
});
@@ -926,13 +946,10 @@ export async function registerRoutes(app: FastifyInstance) {
const search = await prisma.search.findUnique({
where: { id: searchId },
include: {
results: { orderBy: { rank: "asc" } },
projectItems: starredProjectItemsSelect,
},
include: { results: { orderBy: { rank: "asc" } } },
});
if (!search) return app.httpErrors.notFound("search not found");
return { search: serializeSearchLike(search) };
return { search };
});
app.post("/v1/searches/:searchId/chat", async (req) => {
@@ -968,10 +985,21 @@ export async function registerRoutes(app: FastifyInstance) {
},
},
},
select: chatSummarySelect,
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
additionalSystemPrompt: true,
enabledTools: true,
},
});
return { chat: serializeChatLike(chat) };
return { chat: serializeProviderFields(chat) };
});
app.post("/v1/searches/:searchId/run", async (req) => {
@@ -1056,13 +1084,10 @@ export async function registerRoutes(app: FastifyInstance) {
const search = await prisma.search.findUnique({
where: { id: searchId },
include: {
results: { orderBy: { rank: "asc" } },
projectItems: starredProjectItemsSelect,
},
include: { results: { orderBy: { rank: "asc" } } },
});
if (!search) return app.httpErrors.notFound("search not found");
return { search: serializeSearchLike(search) };
return { search };
} catch (err: any) {
await prisma.search.update({
where: { id: searchId },
@@ -1117,14 +1142,10 @@ export async function registerRoutes(app: FastifyInstance) {
const chat = await prisma.chat.findUnique({
where: { id: chatId },
include: {
messages: { orderBy: { createdAt: "asc" } },
calls: { orderBy: { createdAt: "desc" } },
projectItems: starredProjectItemsSelect,
},
include: { messages: { orderBy: { createdAt: "asc" } }, calls: { orderBy: { createdAt: "desc" } } },
});
if (!chat) return app.httpErrors.notFound("chat not found");
return { chat: serializeChatLike(chat) };
return { chat: serializeProviderFields(chat) };
});
app.post("/v1/chats/:chatId/messages", async (req) => {
@@ -1174,13 +1195,16 @@ export async function registerRoutes(app: FastifyInstance) {
provider: ProviderSchema,
model: z.string().min(1),
messages: z.array(CompletionMessageSchema),
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
enabledTools: EnabledToolsSchema.optional(),
userLocation: z.string().trim().min(1).max(200).optional(),
temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().int().positive().optional(),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = parsed.data;
const body = withRequestUserLocation(parsed.data, req);
// ensure chat exists if provided
if (body.chatId) {
@@ -1193,7 +1217,7 @@ export async function registerRoutes(app: FastifyInstance) {
await storeNonAssistantMessages(body.chatId, body.messages);
}
const result = await runMultiplex(body);
const result = await runMultiplex(await applyStoredChatSettings(body));
return {
chatId: body.chatId ?? null,
@@ -1207,7 +1231,7 @@ export async function registerRoutes(app: FastifyInstance) {
const parsed = CompletionStreamBody.safeParse(req.body);
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = parsed.data;
const body = withRequestUserLocation(parsed.data, req);
// ensure chat exists if provided
if (body.chatId) {
@@ -1224,14 +1248,14 @@ export async function registerRoutes(app: FastifyInstance) {
if (activeChatStreams.has(body.chatId)) {
return app.httpErrors.conflict("chat completion already running");
}
const stream = startActiveChatStream(body.chatId, body);
const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body));
return streamActiveRun(req, reply, stream);
}
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
reply.raw.flushHeaders();
for await (const ev of runMultiplexStream(body)) {
for await (const ev of runMultiplexStream(await applyStoredChatSettings(body))) {
writeSseEvent(reply, mapChatStreamEvent(ev));
}

View File

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

View File

@@ -60,22 +60,6 @@ export class SybilApiClient {
return data.chat;
}
async updateChatTitle(chatId: string, title: string) {
const data = await this.request<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
method: "PATCH",
body: { title },
});
return data.chat;
}
async updateChatStar(chatId: string, starred: boolean) {
const data = await this.request<{ chat: ChatSummary }>(`/v1/chats/${chatId}/star`, {
method: "PATCH",
body: { starred },
});
return data.chat;
}
async suggestChatTitle(body: { chatId: string; content: string }) {
const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
method: "POST",
@@ -106,14 +90,6 @@ export class SybilApiClient {
return data.search;
}
async updateSearchStar(searchId: string, starred: boolean) {
const data = await this.request<{ search: SearchSummary }>(`/v1/searches/${searchId}/star`, {
method: "PATCH",
body: { starred },
});
return data.search;
}
async deleteSearch(searchId: string) {
await this.request<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
}
@@ -124,6 +100,7 @@ export class SybilApiClient {
provider: Provider;
model: string;
messages: CompletionRequestMessage[];
userLocation?: string;
},
handlers: CompletionStreamHandlers,
options?: { signal?: AbortSignal }

View File

@@ -20,8 +20,6 @@ type SidebarItem = SidebarSelection & {
title: string;
updatedAt: string;
createdAt: string;
starred: boolean;
starredAt: string | null;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
@@ -133,8 +131,6 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
title: getChatTitle(chat),
updatedAt: chat.updatedAt,
createdAt: chat.createdAt,
starred: chat.starred,
starredAt: chat.starredAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
@@ -149,8 +145,6 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
title: getSearchTitle(search),
updatedAt: search.updatedAt,
createdAt: search.createdAt,
starred: search.starred,
starredAt: search.starredAt,
initiatedProvider: null,
initiatedModel: null,
lastUsedProvider: null,
@@ -260,7 +254,6 @@ async function main() {
let renderedSidebarItems: SidebarItem[] = [];
let renderedSidebarLines: string[] = [];
let suppressedSidebarSelectEvents = 0;
let isRenamePromptOpen = false;
const screen = blessed.screen({
smartCSR: true,
@@ -368,26 +361,6 @@ async function main() {
},
});
const renamePrompt = (blessed as any).prompt({
parent: screen,
label: " Rename chat ",
border: "line",
tags: true,
keys: true,
vi: true,
mouse: true,
top: "center",
left: "center",
width: "50%",
height: "shrink",
hidden: true,
style: {
border: { fg: "cyan" },
label: { fg: "cyan" },
fg: "white",
},
});
const focusables = [sidebar, transcript, composer] as const;
function getTranscriptViewportHeight() {
@@ -527,13 +500,12 @@ async function main() {
? ["No chats/searches yet. Press n or /. "]
: items.map((item) => {
const kind = item.kind === "chat" ? "C" : "S";
const star = item.starred ? "{yellow-fg}★{/yellow-fg} " : " ";
const title = truncate(item.title, 36);
const initiatedLabel =
item.kind === "chat" && item.initiatedModel
? ` | ${getProviderLabel(item.initiatedProvider)} ${truncate(item.initiatedModel, 16)}`
: "";
return `${star}${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`;
return `${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`;
});
const linesChanged =
@@ -708,7 +680,7 @@ async function main() {
const top = `{bold}${escapeTags(getSelectedTitle())}{/bold} {gray-fg}- Sybil TUI${modeLabel}${isSearchMode ? " • Exa Search" : ""}{/gray-fg}`;
let controls =
"{gray-fg}Controls:{/gray-fg} [tab] focus [esc] command mode [↑/↓] highlight [enter] send/select [n] new chat [/] new search [s] star [r] rename [d] delete [C-r] refresh [q] quit";
"{gray-fg}Controls:{/gray-fg} [tab] focus [esc] command mode [↑/↓] highlight [enter] send/select [n] new chat [/] new search [d] delete [q] quit";
if (!isSearchMode) {
controls += `\n{gray-fg}Model:{/gray-fg} provider {cyan-fg}${provider}{/cyan-fg} [p] model {cyan-fg}${escapeTags(model)}{/cyan-fg} [m]`;
controls += providerModelOptions.length === 0 ? " {red-fg}(no models){/red-fg}" : "";
@@ -870,27 +842,6 @@ async function main() {
composer.readInput();
}
function shouldIgnoreGlobalShortcut() {
return isRenamePromptOpen || isTextInputFocused(screen, composer);
}
function promptForChatTitle(currentTitle: string) {
isRenamePromptOpen = true;
updateUI();
return new Promise<string | null>((resolve) => {
renamePrompt.input("Title:", currentTitle, (err: Error | null, value: string | null) => {
isRenamePromptOpen = false;
renamePrompt.hide();
screen.render();
if (err || value === null || value === undefined) {
resolve(null);
return;
}
resolve(value);
});
});
}
function cycleFocus(step: 1 | -1) {
const focused = screen.focused;
const currentIndex = focusables.findIndex((node) => node === focused);
@@ -959,20 +910,10 @@ async function main() {
pendingTitleGeneration.add(chatId);
try {
const updated = await api.suggestChatTitle({ chatId, content });
chats = chats.map((chat) => (chat.id === updated.id ? updated : 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) {
selectedChat = {
...selectedChat,
title: updated.title,
updatedAt: updated.updatedAt,
starred: updated.starred,
starredAt: updated.starredAt,
initiatedProvider: updated.initiatedProvider,
initiatedModel: updated.initiatedModel,
lastUsedProvider: updated.lastUsedProvider,
lastUsedModel: updated.lastUsedModel,
};
selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt };
}
updateUI();
} catch {
@@ -1023,8 +964,6 @@ async function main() {
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
starred: chat.starred,
starredAt: chat.starredAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
@@ -1201,8 +1140,6 @@ async function main() {
query,
createdAt: nowIso,
updatedAt: nowIso,
starred: false,
starredAt: null,
requestId: null,
latencyMs: null,
error: null,
@@ -1365,88 +1302,6 @@ async function main() {
await refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true });
}
async function handleRenameSelection() {
if (!selectedItem || selectedItem.kind !== "chat") return;
const chatId = selectedItem.id;
const summary = chats.find((chat) => chat.id === chatId);
const currentTitle = selectedChat?.id === chatId ? getChatTitle(selectedChat, selectedChat.messages) : summary ? getChatTitle(summary) : "New chat";
const value = await promptForChatTitle(currentTitle);
const title = value?.trim();
if (!title) {
updateUI();
return;
}
setError(null);
const updated = await api.updateChatTitle(chatId, title);
chats = [updated, ...chats.filter((chat) => chat.id !== updated.id)];
workspaceItems = upsertWorkspaceItem(workspaceItems, chatWorkspaceItem(updated));
if (selectedChat?.id === updated.id) {
selectedChat = {
...selectedChat,
title: updated.title,
updatedAt: updated.updatedAt,
initiatedProvider: updated.initiatedProvider,
initiatedModel: updated.initiatedModel,
lastUsedProvider: updated.lastUsedProvider,
lastUsedModel: updated.lastUsedModel,
};
}
updateUI();
}
async function handleToggleStarSelection() {
if (!selectedItem) return;
const currentItem = getSidebarItems().find((item) => item.kind === selectedItem?.kind && item.id === selectedItem?.id);
const nextStarred = !currentItem?.starred;
setError(null);
if (selectedItem.kind === "chat") {
const updated = await api.updateChatStar(selectedItem.id, nextStarred);
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
if (!chats.some((chat) => chat.id === updated.id)) chats = [updated, ...chats];
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
if (!workspaceItems.some((item) => item.type === "chat" && item.id === updated.id)) {
workspaceItems = [chatWorkspaceItem(updated), ...workspaceItems];
}
if (selectedChat?.id === updated.id) {
selectedChat = {
...selectedChat,
title: updated.title,
updatedAt: updated.updatedAt,
starred: updated.starred,
starredAt: updated.starredAt,
initiatedProvider: updated.initiatedProvider,
initiatedModel: updated.initiatedModel,
lastUsedProvider: updated.lastUsedProvider,
lastUsedModel: updated.lastUsedModel,
};
}
} else {
const updated = await api.updateSearchStar(selectedItem.id, nextStarred);
searches = searches.map((search) => (search.id === updated.id ? updated : search));
if (!searches.some((search) => search.id === updated.id)) searches = [updated, ...searches];
workspaceItems = workspaceItems.map((item) => (item.type === "search" && item.id === updated.id ? searchWorkspaceItem(updated) : item));
if (!workspaceItems.some((item) => item.type === "search" && item.id === updated.id)) {
workspaceItems = [searchWorkspaceItem(updated), ...workspaceItems];
}
if (selectedSearch?.id === updated.id) {
selectedSearch = {
...selectedSearch,
title: updated.title,
query: updated.query,
updatedAt: updated.updatedAt,
starred: updated.starred,
starredAt: updated.starredAt,
};
}
}
updateUI();
}
function cycleProvider() {
const visibleProviders = getVisibleProviders(modelCatalog);
const cycleProviders = visibleProviders.length ? visibleProviders : BASE_PROVIDERS;
@@ -1532,18 +1387,18 @@ async function main() {
});
screen.key(["q"], () => {
if (shouldIgnoreGlobalShortcut()) return;
if (isTextInputFocused(screen, composer)) return;
screen.destroy();
process.exit(0);
});
screen.key(["tab"], () => {
if (shouldIgnoreGlobalShortcut()) return;
if (isTextInputFocused(screen, composer)) return;
cycleFocus(1);
});
screen.key(["S-tab", "backtab"], () => {
if (shouldIgnoreGlobalShortcut()) return;
if (isTextInputFocused(screen, composer)) return;
cycleFocus(-1);
});
@@ -1560,50 +1415,36 @@ async function main() {
});
screen.key(["n"], () => {
if (shouldIgnoreGlobalShortcut()) return;
if (isTextInputFocused(screen, composer)) return;
handleCreateChat();
});
screen.key(["/"], () => {
if (shouldIgnoreGlobalShortcut()) return;
if (isTextInputFocused(screen, composer)) return;
handleCreateSearch();
});
screen.key(["d"], () => {
if (shouldIgnoreGlobalShortcut()) return;
if (isTextInputFocused(screen, composer)) return;
void runAction(async () => {
await handleDeleteSelection();
});
});
screen.key(["s"], () => {
if (shouldIgnoreGlobalShortcut()) return;
void runAction(async () => {
await handleToggleStarSelection();
});
});
screen.key(["p"], () => {
if (shouldIgnoreGlobalShortcut()) return;
if (isTextInputFocused(screen, composer)) return;
if (getIsSearchMode() || isSending) return;
cycleProvider();
});
screen.key(["m"], () => {
if (shouldIgnoreGlobalShortcut()) return;
if (isTextInputFocused(screen, composer)) return;
if (getIsSearchMode() || isSending) return;
cycleModel();
});
screen.key(["r"], () => {
if (shouldIgnoreGlobalShortcut()) return;
void runAction(async () => {
await handleRenameSelection();
});
});
screen.key(["C-r"], () => {
if (shouldIgnoreGlobalShortcut()) return;
if (isTextInputFocused(screen, composer)) return;
void runAction(async () => {
await refreshCollections({ loadSelection: true });
await refreshModels();

View File

@@ -15,8 +15,6 @@ export type ChatSummary = {
title: string | null;
createdAt: string;
updatedAt: string;
starred: boolean;
starredAt: string | null;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
@@ -29,8 +27,6 @@ export type SearchSummary = {
query: string | null;
createdAt: string;
updatedAt: string;
starred: boolean;
starredAt: string | null;
};
export type ChatWorkspaceItem = ChatSummary & {
@@ -70,8 +66,6 @@ export type ChatDetail = {
title: string | null;
createdAt: string;
updatedAt: string;
starred: boolean;
starredAt: string | null;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
@@ -101,8 +95,6 @@ export type SearchDetail = {
query: string | null;
createdAt: string;
updatedAt: string;
starred: boolean;
starredAt: string | null;
requestId: string | null;
latencyMs: number | null;
error: string | null;

View File

@@ -1,5 +1,20 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { Check, ChevronDown, Globe2, LoaderCircle, Menu, MessageSquare, Paperclip, Pencil, Plus, Rabbit, Search, SendHorizontal, Star, Trash2, X } from "lucide-preact";
import {
Check,
ChevronDown,
Globe2,
LoaderCircle,
Menu,
MessageSquare,
Paperclip,
Plus,
Rabbit,
Search,
SendHorizontal,
Settings2,
Trash2,
X,
} from "lucide-preact";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
@@ -18,15 +33,14 @@ import {
attachSearchStream,
getActiveRuns,
getChat,
listChatTools,
listModels,
getSearch,
listWorkspaceItems,
runCompletionStream,
runSearchStream,
suggestChatTitle,
updateChatTitle,
updateChatStar,
updateSearchStar,
updateChatSettings,
getMessageAttachments,
type ChatAttachment,
type ActiveRunsResponse,
@@ -34,6 +48,7 @@ import {
type Provider,
type ChatDetail,
type ChatSummary,
type ChatToolInfo,
type CompletionRequestMessage,
type Message,
type SearchDetail,
@@ -50,8 +65,6 @@ type SidebarItem = SidebarSelection & {
title: string;
updatedAt: string;
createdAt: string;
starred: boolean;
starredAt: string | null;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
@@ -62,9 +75,6 @@ type ContextMenuState = {
x: number;
y: number;
};
type RenameChatDialogState = {
chatId: string;
};
type PendingChatState = {
messages: Message[];
};
@@ -379,6 +389,30 @@ function getProviderLabel(provider: Provider | null | undefined) {
return "";
}
function getToolLabel(name: string) {
if (name === "web_search") return "Web search";
if (name === "fetch_url") return "Fetch URL";
if (name === "codex_exec") return "Codex";
if (name === "shell_exec") return "Shell";
return name
.split("_")
.filter(Boolean)
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
.join(" ");
}
function getDefaultEnabledTools(availableTools: ChatToolInfo[]) {
return availableTools.map((tool) => tool.name);
}
function normalizeEnabledTools(value: unknown, availableTools: ChatToolInfo[]) {
const available = new Set(availableTools.map((tool) => tool.name));
if (!Array.isArray(value)) return getDefaultEnabledTools(availableTools);
return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))].filter((name) =>
available.has(name)
);
}
function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "lastUsedModel"> | Pick<ChatDetail, "lastUsedProvider" | "lastUsedModel"> | null) {
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
return {
@@ -629,8 +663,6 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
title: getChatTitle(chat),
updatedAt: chat.updatedAt,
createdAt: chat.createdAt,
starred: chat.starred,
starredAt: chat.starredAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
@@ -645,8 +677,6 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
title: getSearchTitle(search),
updatedAt: search.updatedAt,
createdAt: search.createdAt,
starred: search.starred,
starredAt: search.starredAt,
initiatedProvider: null,
initiatedModel: null,
lastUsedProvider: null,
@@ -698,13 +728,7 @@ function getSidebarSectionLabel(value: string) {
}
function buildSidebarSections(items: SidebarItem[]) {
const starred = items
.filter((item) => item.starred)
.sort((a, b) => new Date(b.starredAt ?? b.updatedAt).getTime() - new Date(a.starredAt ?? a.updatedAt).getTime());
const unstarred = items.filter((item) => !item.starred);
const sections = starred.length ? [{ label: "STARRED", items: starred }] : [];
return unstarred.reduce<Array<{ label: string; items: SidebarItem[] }>>((sections, item) => {
return items.reduce<Array<{ label: string; items: SidebarItem[] }>>((sections, item) => {
const label = getSidebarSectionLabel(item.updatedAt);
const section = sections.find((candidate) => candidate.label === label);
if (section) {
@@ -713,7 +737,7 @@ function buildSidebarSections(items: SidebarItem[]) {
sections.push({ label, items: [item] });
}
return sections;
}, sections);
}, []);
}
export default function App() {
@@ -748,6 +772,7 @@ export default function App() {
const [isComposerDropActive, setIsComposerDropActive] = useState(false);
const [provider, setProvider] = useState<Provider>("openai");
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG);
const [availableChatTools, setAvailableChatTools] = useState<ChatToolInfo[]>([]);
const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences());
const [model, setModel] = useState(() => {
const stored = loadStoredModelPreferences();
@@ -770,15 +795,13 @@ export default function App() {
const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false);
const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [renameChatDialog, setRenameChatDialog] = useState<RenameChatDialogState | null>(null);
const [renameChatDraft, setRenameChatDraft] = useState("");
const [renameChatError, setRenameChatError] = useState<string | null>(null);
const [isRenamingChat, setIsRenamingChat] = useState(false);
const [isChatSettingsOpen, setIsChatSettingsOpen] = useState(false);
const [additionalSystemPrompt, setAdditionalSystemPrompt] = useState("");
const [enabledTools, setEnabledTools] = useState<string[]>([]);
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
const transcriptContainerRef = useRef<HTMLDivElement>(null);
const transcriptEndRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const renameChatInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0);
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
@@ -899,17 +922,15 @@ export default function App() {
searchRunCountersRef.current.clear();
setComposer("");
setPendingAttachments([]);
setIsChatSettingsOpen(false);
setAdditionalSystemPrompt("");
setEnabledTools([]);
setIsQuickQuestionOpen(false);
setQuickPrompt("");
setQuickSubmittedPrompt(null);
setQuickSubmittedModelSelection(null);
setQuickQuestionMessages([]);
setQuickQuestionError(null);
setContextMenu(null);
setRenameChatDialog(null);
setRenameChatDraft("");
setRenameChatError(null);
setIsRenamingChat(false);
setError(null);
};
@@ -968,6 +989,21 @@ export default function App() {
}
};
const refreshChatTools = async () => {
try {
const tools = await listChatTools();
setAvailableChatTools(tools);
setEnabledTools((current) => normalizeEnabledTools(current.length ? current : null, tools));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setError(message);
}
}
};
const refreshActiveRuns = async () => {
try {
const data = await getActiveRuns();
@@ -1020,7 +1056,7 @@ export default function App() {
if (!isAuthenticated) return;
const preferredSelection = initialRouteSelectionRef.current;
initialRouteSelectionRef.current = null;
void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshActiveRuns()]);
void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshChatTools(), refreshActiveRuns()]);
}, [isAuthenticated]);
useEffect(() => {
@@ -1267,11 +1303,6 @@ export default function App() {
return chats.find((chat) => chat.id === selectedItem.id) ?? null;
}, [chats, selectedItem]);
const selectedSidebarItem = useMemo(() => {
if (!selectedItem) return null;
return sidebarItems.find((item) => item.kind === selectedItem.kind && item.id === selectedItem.id) ?? null;
}, [selectedItem, sidebarItems]);
const selectedSearchSummary = useMemo(() => {
if (!selectedItem || selectedItem.kind !== "search") return null;
return searches.find((search) => search.id === selectedItem.id) ?? null;
@@ -1287,6 +1318,19 @@ export default function App() {
setModel(nextSelection.model);
}, [draftKind, selectedChat, selectedChatSummary, selectedItem]);
useEffect(() => {
if (draftKind === "chat") {
setAdditionalSystemPrompt("");
setEnabledTools(getDefaultEnabledTools(availableChatTools));
return;
}
if (selectedItem?.kind !== "chat") return;
const chat = selectedChat?.id === selectedItem.id ? selectedChat : selectedChatSummary;
if (!chat) return;
setAdditionalSystemPrompt(chat.additionalSystemPrompt ?? "");
setEnabledTools(normalizeEnabledTools(chat.enabledTools, availableChatTools));
}, [availableChatTools, draftKind, selectedChat, selectedChatSummary, selectedItem]);
const selectedTitle = useMemo(() => {
if (draftKind === "chat") return "New chat";
if (draftKind === "search") return "New search";
@@ -1410,136 +1454,16 @@ export default function App() {
return () => window.removeEventListener("keydown", handleKeyDown);
}, [filteredSidebarItems, isAuthenticated, isQuickQuestionOpen]);
const getRenameSeedTitle = (chatId: string) => {
if (selectedChat?.id === chatId) return getChatTitle(selectedChat, selectedChat.messages);
const summary = chats.find((chat) => chat.id === chatId);
if (summary) return getChatTitle(summary);
const sidebarItem = sidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
return sidebarItem?.title ?? "New chat";
};
const applyChatSummary = (updatedChat: ChatSummary, moveToFront = true) => {
setChats((current) => {
const withoutExisting = current.filter((chat) => chat.id !== updatedChat.id);
if (moveToFront) return [updatedChat, ...withoutExisting];
const existingIndex = current.findIndex((chat) => chat.id === updatedChat.id);
if (existingIndex < 0) return [updatedChat, ...current];
const next = [...current];
next[existingIndex] = updatedChat;
return next;
});
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(updatedChat), moveToFront));
setSelectedChat((current) => {
if (!current || current.id !== updatedChat.id) return current;
return {
...current,
title: updatedChat.title,
updatedAt: updatedChat.updatedAt,
starred: updatedChat.starred,
starredAt: updatedChat.starredAt,
initiatedProvider: updatedChat.initiatedProvider,
initiatedModel: updatedChat.initiatedModel,
lastUsedProvider: updatedChat.lastUsedProvider,
lastUsedModel: updatedChat.lastUsedModel,
};
});
};
const applySearchSummary = (updatedSearch: SearchSummary, moveToFront = true) => {
setSearches((current) => {
const withoutExisting = current.filter((search) => search.id !== updatedSearch.id);
if (moveToFront) return [updatedSearch, ...withoutExisting];
const existingIndex = current.findIndex((search) => search.id === updatedSearch.id);
if (existingIndex < 0) return [updatedSearch, ...current];
const next = [...current];
next[existingIndex] = updatedSearch;
return next;
});
setWorkspaceItems((current) => upsertWorkspaceItem(current, searchWorkspaceItem(updatedSearch), moveToFront));
setSelectedSearch((current) => {
if (!current || current.id !== updatedSearch.id) return current;
return {
...current,
title: updatedSearch.title,
query: updatedSearch.query,
updatedAt: updatedSearch.updatedAt,
starred: updatedSearch.starred,
starredAt: updatedSearch.starredAt,
};
});
};
const openRenameChatDialog = (chatId: string) => {
setContextMenu(null);
setRenameChatDraft(getRenameSeedTitle(chatId));
setRenameChatError(null);
setRenameChatDialog({ chatId });
};
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
event.preventDefault();
const menuWidth = 176;
const menuHeight = item.kind === "chat" ? 120 : 80;
const menuWidth = 160;
const menuHeight = 40;
const padding = 8;
const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding);
const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding);
setContextMenu({ item, x: Math.max(padding, x), y: Math.max(padding, y) });
};
const handleRenameChatSubmit = async (event?: Event) => {
event?.preventDefault();
if (!renameChatDialog || isRenamingChat) return;
const title = renameChatDraft.trim();
if (!title) {
setRenameChatError("Enter a chat title.");
return;
}
setIsRenamingChat(true);
setRenameChatError(null);
setError(null);
try {
const updatedChat = await updateChatTitle(renameChatDialog.chatId, title);
applyChatSummary(updatedChat);
setRenameChatDialog(null);
setRenameChatDraft("");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setRenameChatError(message);
}
} finally {
setIsRenamingChat(false);
}
};
const handleToggleStar = async (target: SidebarSelection) => {
const current = sidebarItems.find((item) => item.kind === target.kind && item.id === target.id);
const nextStarred = !current?.starred;
setContextMenu(null);
setError(null);
try {
if (target.kind === "chat") {
const updatedChat = await updateChatStar(target.id, nextStarred);
applyChatSummary(updatedChat, false);
} else {
const updatedSearch = await updateSearchStar(target.id, nextStarred);
applySearchSummary(updatedSearch, false);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setError(message);
}
}
};
const handleDeleteFromContextMenu = async () => {
if (!contextMenu || isItemRunning(contextMenu.item)) return;
const target = contextMenu.item;
@@ -1579,15 +1503,6 @@ export default function App() {
};
}, [contextMenu]);
useEffect(() => {
if (!renameChatDialog) return;
const timer = window.setTimeout(() => {
renameChatInputRef.current?.focus();
renameChatInputRef.current?.select();
}, 0);
return () => window.clearTimeout(timer);
}, [renameChatDialog]);
useEffect(() => {
if (!isQuickQuestionOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
@@ -1762,8 +1677,6 @@ export default function App() {
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
starred: chat.starred,
starredAt: chat.starredAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
@@ -1984,8 +1897,6 @@ export default function App() {
query,
createdAt: currentSearch?.createdAt ?? nowIso,
updatedAt: nowIso,
starred: currentSearch?.starred ?? false,
starredAt: currentSearch?.starredAt ?? null,
requestId: null,
latencyMs: null,
error: null,
@@ -2343,8 +2254,6 @@ export default function App() {
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
starred: chat.starred,
starredAt: chat.starredAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
@@ -2521,8 +2430,6 @@ export default function App() {
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
starred: chat.starred,
starredAt: chat.starredAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
@@ -2742,12 +2649,6 @@ export default function App() {
</span>
<span className="flex min-w-0 flex-1 items-center gap-1.5">
<span className="truncate text-sm font-semibold">{item.title}</span>
{item.starred ? (
<Star
className={cn("h-3.5 w-3.5 shrink-0 fill-amber-300", active ? "text-amber-200" : "text-amber-300/90")}
aria-label="Starred"
/>
) : null}
{itemIsRunning ? (
<LoaderCircle
className={cn("h-3.5 w-3.5 shrink-0 animate-spin", active ? "text-cyan-100" : "text-cyan-300/90")}
@@ -2786,34 +2687,8 @@ export default function App() {
<Menu className="h-4 w-4" />
</Button>
<div className="flex min-w-0 items-center gap-1.5">
<h1 className="truncate text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
{draftKind === null && selectedItem ? (
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0 text-violet-100/72 hover:text-violet-50"
onClick={() => void handleToggleStar(selectedItem)}
title={selectedSidebarItem?.starred ? "Unstar" : "Star"}
aria-label={selectedSidebarItem?.starred ? "Unstar" : "Star"}
>
<Star className={cn("h-3.5 w-3.5", selectedSidebarItem?.starred ? "fill-amber-300 text-amber-300" : "")} />
</Button>
) : null}
{draftKind === null && selectedItem?.kind === "chat" ? (
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0 text-violet-100/72 hover:text-violet-50"
onClick={() => openRenameChatDialog(selectedItem.id)}
title="Rename chat"
aria-label="Rename chat"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
) : null}
<div>
<h1 className="text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
</div>
</div>
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
@@ -2985,31 +2860,6 @@ export default function App() {
style={{ left: contextMenu.x, top: contextMenu.y }}
onContextMenu={(event) => event.preventDefault()}
>
<button
type="button"
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-violet-100 transition hover:bg-violet-400/12"
onClick={() => void handleToggleStar(contextMenu.item)}
>
<Star
className={cn(
"h-3.5 w-3.5",
sidebarItems.find((item) => item.kind === contextMenu.item.kind && item.id === contextMenu.item.id)?.starred
? "fill-amber-300 text-amber-300"
: ""
)}
/>
{sidebarItems.find((item) => item.kind === contextMenu.item.kind && item.id === contextMenu.item.id)?.starred ? "Unstar" : "Star"}
</button>
{contextMenu.item.kind === "chat" ? (
<button
type="button"
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-violet-100 transition hover:bg-violet-400/12"
onClick={() => openRenameChatDialog(contextMenu.item.id)}
>
<Pencil className="h-3.5 w-3.5" />
Rename
</button>
) : null}
<button
type="button"
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-rose-300 transition hover:bg-rose-500/12 disabled:text-muted-foreground"
@@ -3021,61 +2871,6 @@ export default function App() {
</button>
</div>
) : null}
{renameChatDialog ? (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !isRenamingChat) setRenameChatDialog(null);
}}
>
<form
role="dialog"
aria-modal="true"
aria-labelledby="rename-chat-title"
className="glass-panel w-full max-w-md rounded-2xl border border-violet-300/24 p-4 shadow-2xl shadow-black/45 md:p-5"
onSubmit={(event) => void handleRenameChatSubmit(event)}
>
<div className="mb-4 flex items-center justify-between gap-3">
<h2 id="rename-chat-title" className="text-sm font-semibold text-violet-50">
Rename chat
</h2>
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => setRenameChatDialog(null)}
disabled={isRenamingChat}
aria-label="Close rename dialog"
>
<X className="h-4 w-4" />
</Button>
</div>
<input
ref={renameChatInputRef}
value={renameChatDraft}
onInput={(event) => {
setRenameChatDraft(event.currentTarget.value);
if (renameChatError) setRenameChatError(null);
}}
maxLength={120}
className="h-11 w-full rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
aria-label="Chat title"
disabled={isRenamingChat}
/>
{renameChatError ? <p className="mt-2 text-sm text-rose-300">{renameChatError}</p> : null}
<div className="mt-4 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setRenameChatDialog(null)} disabled={isRenamingChat}>
Cancel
</Button>
<Button type="submit" disabled={isRenamingChat || !renameChatDraft.trim()}>
{isRenamingChat ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
Save
</Button>
</div>
</form>
</div>
) : null}
{isQuickQuestionOpen ? (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"

View File

@@ -3,12 +3,12 @@ export type ChatSummary = {
title: string | null;
createdAt: string;
updatedAt: string;
starred: boolean;
starredAt: string | null;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
lastUsedModel: string | null;
additionalSystemPrompt: string | null;
enabledTools: string[] | null;
};
export type SearchSummary = {
@@ -17,8 +17,6 @@ export type SearchSummary = {
query: string | null;
createdAt: string;
updatedAt: string;
starred: boolean;
starredAt: string | null;
};
export type ChatWorkspaceItem = ChatSummary & {
@@ -58,12 +56,12 @@ export type ChatDetail = {
title: string | null;
createdAt: string;
updatedAt: string;
starred: boolean;
starredAt: string | null;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
lastUsedModel: string | null;
additionalSystemPrompt: string | null;
enabledTools: string[] | null;
messages: Message[];
};
@@ -89,8 +87,6 @@ export type SearchDetail = {
query: string | null;
createdAt: string;
updatedAt: string;
starred: boolean;
starredAt: string | null;
requestId: string | null;
latencyMs: number | null;
error: string | null;
@@ -157,6 +153,11 @@ export type ModelCatalogResponse = {
providers: Partial<Record<Provider, ProviderModelInfo>>;
};
export type ChatToolInfo = {
name: string;
description: string;
};
export type ActiveRunsResponse = {
chats: string[];
searches: string[];
@@ -182,6 +183,8 @@ type CreateChatRequest = {
title?: string;
provider?: Provider;
model?: string;
additionalSystemPrompt?: string;
enabledTools?: string[];
messages?: CompletionRequestMessage[];
};
@@ -245,6 +248,11 @@ export async function listModels() {
return api<ModelCatalogResponse>("/v1/models");
}
export async function listChatTools() {
const data = await api<{ tools: ChatToolInfo[] }>("/v1/chat-tools");
return data.tools;
}
export async function getActiveRuns() {
return api<ActiveRunsResponse>("/v1/active-runs");
}
@@ -271,10 +279,10 @@ export async function updateChatTitle(chatId: string, title: string) {
return data.chat;
}
export async function updateChatStar(chatId: string, starred: boolean) {
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}/star`, {
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({ starred }),
body: JSON.stringify(body),
});
return data.chat;
}
@@ -309,14 +317,6 @@ export async function getSearch(searchId: string) {
return data.search;
}
export async function updateSearchStar(searchId: string, starred: boolean) {
const data = await api<{ search: SearchSummary }>(`/v1/searches/${searchId}/star`, {
method: "PATCH",
body: JSON.stringify({ starred }),
});
return data.search;
}
export async function createChatFromSearch(searchId: string, body?: { title?: string }) {
const data = await api<{ chat: ChatSummary }>(`/v1/searches/${searchId}/chat`, {
method: "POST",
@@ -593,6 +593,9 @@ export async function runCompletion(body: {
provider: Provider;
model: string;
messages: CompletionRequestMessage[];
additionalSystemPrompt?: string;
enabledTools?: string[];
userLocation?: string;
}) {
return api<CompletionResponse>("/v1/chat-completions", {
method: "POST",
@@ -607,6 +610,9 @@ export async function runCompletionStream(
provider: Provider;
model: string;
messages: CompletionRequestMessage[];
additionalSystemPrompt?: string;
enabledTools?: string[];
userLocation?: string;
},
handlers: CompletionStreamHandlers,
options?: { signal?: AbortSignal }

View File

@@ -106,8 +106,6 @@ export default function SearchRoutePage() {
query: trimmed,
createdAt: nowIso,
updatedAt: nowIso,
starred: false,
starredAt: null,
requestId: null,
latencyMs: null,
error: null,
@@ -134,8 +132,6 @@ export default function SearchRoutePage() {
query: created.query,
createdAt: created.createdAt,
updatedAt: created.updatedAt,
starred: created.starred,
starredAt: created.starredAt,
}
: current
);