Compare commits
2 Commits
codex/syst
...
a6c2ec664b
| Author | SHA1 | Date | |
|---|---|---|---|
| a6c2ec664b | |||
| cb8ea935fa |
@@ -72,6 +72,8 @@ Behavior notes:
|
|||||||
"title": "optional title",
|
"title": "optional title",
|
||||||
"createdAt": "2026-02-14T00:00:00.000Z",
|
"createdAt": "2026-02-14T00:00:00.000Z",
|
||||||
"updatedAt": "2026-02-14T00:00:00.000Z",
|
"updatedAt": "2026-02-14T00:00:00.000Z",
|
||||||
|
"starred": true,
|
||||||
|
"starredAt": "2026-02-14T01:00:00.000Z",
|
||||||
"initiatedProvider": "openai",
|
"initiatedProvider": "openai",
|
||||||
"initiatedModel": "gpt-4.1-mini",
|
"initiatedModel": "gpt-4.1-mini",
|
||||||
"lastUsedProvider": "openai",
|
"lastUsedProvider": "openai",
|
||||||
@@ -83,7 +85,9 @@ Behavior notes:
|
|||||||
"title": "optional title",
|
"title": "optional title",
|
||||||
"query": "search query",
|
"query": "search query",
|
||||||
"createdAt": "2026-02-14T00:00:00.000Z",
|
"createdAt": "2026-02-14T00:00:00.000Z",
|
||||||
"updatedAt": "2026-02-14T00:00:00.000Z"
|
"updatedAt": "2026-02-14T00:00:00.000Z",
|
||||||
|
"starred": false,
|
||||||
|
"starredAt": null
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -93,6 +97,7 @@ Behavior notes:
|
|||||||
- This endpoint is intended for combined conversation/search lists such as sidebars.
|
- 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 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.
|
- 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
|
## Chats
|
||||||
|
|
||||||
@@ -126,8 +131,20 @@ Behavior notes:
|
|||||||
### `PATCH /v1/chats/:chatId`
|
### `PATCH /v1/chats/:chatId`
|
||||||
- Body: `{ "title": string }`
|
- Body: `{ "title": string }`
|
||||||
- Response: `{ "chat": ChatSummary }`
|
- 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" }`
|
- 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`
|
### `POST /v1/chats/title/suggest`
|
||||||
- Body:
|
- Body:
|
||||||
```json
|
```json
|
||||||
@@ -140,7 +157,8 @@ Behavior notes:
|
|||||||
|
|
||||||
Behavior notes:
|
Behavior notes:
|
||||||
- If the chat already has a non-empty title, server returns the existing chat unchanged.
|
- If the chat already has a non-empty title, server returns the existing chat unchanged.
|
||||||
- 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.
|
- 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.
|
||||||
|
|
||||||
### `DELETE /v1/chats/:chatId`
|
### `DELETE /v1/chats/:chatId`
|
||||||
- Response: `{ "deleted": true }`
|
- Response: `{ "deleted": true }`
|
||||||
@@ -279,6 +297,16 @@ Behavior notes:
|
|||||||
- Body: `{ "title"?: string, "query"?: string }`
|
- Body: `{ "title"?: string, "query"?: string }`
|
||||||
- Response: `{ "search": SearchSummary }`
|
- 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`
|
### `DELETE /v1/searches/:searchId`
|
||||||
- Response: `{ "deleted": true }`
|
- Response: `{ "deleted": true }`
|
||||||
- Not found: `404 { "message": "search not found" }`
|
- Not found: `404 { "message": "search not found" }`
|
||||||
@@ -351,6 +379,8 @@ Behavior notes:
|
|||||||
"title": null,
|
"title": null,
|
||||||
"createdAt": "...",
|
"createdAt": "...",
|
||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
|
"starred": false,
|
||||||
|
"starredAt": null,
|
||||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
"initiatedModel": "string|null",
|
"initiatedModel": "string|null",
|
||||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
@@ -399,6 +429,8 @@ Behavior notes:
|
|||||||
"title": null,
|
"title": null,
|
||||||
"createdAt": "...",
|
"createdAt": "...",
|
||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
|
"starred": false,
|
||||||
|
"starredAt": null,
|
||||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
"initiatedModel": "string|null",
|
"initiatedModel": "string|null",
|
||||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
@@ -409,7 +441,7 @@ Behavior notes:
|
|||||||
|
|
||||||
`SearchSummary`
|
`SearchSummary`
|
||||||
```json
|
```json
|
||||||
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "..." }
|
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "...", "starred": false, "starredAt": null }
|
||||||
```
|
```
|
||||||
|
|
||||||
`SearchDetail`
|
`SearchDetail`
|
||||||
@@ -420,6 +452,8 @@ Behavior notes:
|
|||||||
"query": "...",
|
"query": "...",
|
||||||
"createdAt": "...",
|
"createdAt": "...",
|
||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
|
"starred": false,
|
||||||
|
"starredAt": null,
|
||||||
"requestId": "...",
|
"requestId": "...",
|
||||||
"latencyMs": 123,
|
"latencyMs": 123,
|
||||||
"error": null,
|
"error": null,
|
||||||
|
|||||||
@@ -74,6 +74,26 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
return response.chat
|
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 {
|
func deleteChat(chatID: String) async throws {
|
||||||
_ = try await request("/v1/chats/\(chatID)", method: "DELETE", responseType: DeleteResponse.self)
|
_ = try await request("/v1/chats/\(chatID)", method: "DELETE", responseType: DeleteResponse.self)
|
||||||
}
|
}
|
||||||
@@ -118,6 +138,16 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
return response.chat
|
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 {
|
func deleteSearch(searchID: String) async throws {
|
||||||
_ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self)
|
_ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self)
|
||||||
}
|
}
|
||||||
@@ -631,7 +661,6 @@ struct CompletionStreamRequest: Codable, Sendable {
|
|||||||
var provider: Provider
|
var provider: Provider
|
||||||
var model: String
|
var model: String
|
||||||
var messages: [CompletionRequestMessage]
|
var messages: [CompletionRequestMessage]
|
||||||
var userLocation: String? = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ChatCreateBody: Encodable {
|
private struct ChatCreateBody: Encodable {
|
||||||
@@ -641,6 +670,14 @@ private struct ChatCreateBody: Encodable {
|
|||||||
var messages: [CompletionRequestMessage]?
|
var messages: [CompletionRequestMessage]?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ChatTitleUpdateBody: Encodable {
|
||||||
|
var title: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StarUpdateBody: Encodable {
|
||||||
|
var starred: Bool
|
||||||
|
}
|
||||||
|
|
||||||
private struct SearchCreateBody: Encodable {
|
private struct SearchCreateBody: Encodable {
|
||||||
var title: String?
|
var title: String?
|
||||||
var query: String?
|
var query: String?
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ protocol SybilAPIClienting: Sendable {
|
|||||||
messages: [CompletionRequestMessage]?
|
messages: [CompletionRequestMessage]?
|
||||||
) async throws -> ChatSummary
|
) async throws -> ChatSummary
|
||||||
func getChat(chatID: String) async throws -> ChatDetail
|
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 deleteChat(chatID: String) async throws
|
||||||
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
|
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
|
||||||
func listSearches() async throws -> [SearchSummary]
|
func listSearches() async throws -> [SearchSummary]
|
||||||
func createSearch(title: String?, query: String?) async throws -> SearchSummary
|
func createSearch(title: String?, query: String?) async throws -> SearchSummary
|
||||||
func getSearch(searchID: String) async throws -> SearchDetail
|
func getSearch(searchID: String) async throws -> SearchDetail
|
||||||
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary
|
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 deleteSearch(searchID: String) async throws
|
||||||
func listModels() async throws -> ModelCatalogResponse
|
func listModels() async throws -> ModelCatalogResponse
|
||||||
func getActiveRuns() async throws -> ActiveRunsResponse
|
func getActiveRuns() async throws -> ActiveRunsResponse
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var title: String?
|
public var title: String?
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
|
public var starred = false
|
||||||
|
public var starredAt: Date?
|
||||||
public var initiatedProvider: Provider?
|
public var initiatedProvider: Provider?
|
||||||
public var initiatedModel: String?
|
public var initiatedModel: String?
|
||||||
public var lastUsedProvider: Provider?
|
public var lastUsedProvider: Provider?
|
||||||
@@ -166,6 +168,8 @@ public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var query: String?
|
public var query: String?
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
|
public var starred = false
|
||||||
|
public var starredAt: Date?
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum WorkspaceItemType: String, Codable, Hashable, Sendable {
|
public enum WorkspaceItemType: String, Codable, Hashable, Sendable {
|
||||||
@@ -180,6 +184,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var query: String?
|
public var query: String?
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
|
public var starred = false
|
||||||
|
public var starredAt: Date?
|
||||||
public var initiatedProvider: Provider?
|
public var initiatedProvider: Provider?
|
||||||
public var initiatedModel: String?
|
public var initiatedModel: String?
|
||||||
public var lastUsedProvider: Provider?
|
public var lastUsedProvider: Provider?
|
||||||
@@ -192,6 +198,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
self.query = nil
|
self.query = nil
|
||||||
self.createdAt = chat.createdAt
|
self.createdAt = chat.createdAt
|
||||||
self.updatedAt = chat.updatedAt
|
self.updatedAt = chat.updatedAt
|
||||||
|
self.starred = chat.starred
|
||||||
|
self.starredAt = chat.starredAt
|
||||||
self.initiatedProvider = chat.initiatedProvider
|
self.initiatedProvider = chat.initiatedProvider
|
||||||
self.initiatedModel = chat.initiatedModel
|
self.initiatedModel = chat.initiatedModel
|
||||||
self.lastUsedProvider = chat.lastUsedProvider
|
self.lastUsedProvider = chat.lastUsedProvider
|
||||||
@@ -205,6 +213,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
self.query = search.query
|
self.query = search.query
|
||||||
self.createdAt = search.createdAt
|
self.createdAt = search.createdAt
|
||||||
self.updatedAt = search.updatedAt
|
self.updatedAt = search.updatedAt
|
||||||
|
self.starred = search.starred
|
||||||
|
self.starredAt = search.starredAt
|
||||||
self.initiatedProvider = nil
|
self.initiatedProvider = nil
|
||||||
self.initiatedModel = nil
|
self.initiatedModel = nil
|
||||||
self.lastUsedProvider = nil
|
self.lastUsedProvider = nil
|
||||||
@@ -218,6 +228,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
title: title,
|
title: title,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
|
starred: starred,
|
||||||
|
starredAt: starredAt,
|
||||||
initiatedProvider: initiatedProvider,
|
initiatedProvider: initiatedProvider,
|
||||||
initiatedModel: initiatedModel,
|
initiatedModel: initiatedModel,
|
||||||
lastUsedProvider: lastUsedProvider,
|
lastUsedProvider: lastUsedProvider,
|
||||||
@@ -232,7 +244,9 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
title: title,
|
title: title,
|
||||||
query: query,
|
query: query,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: updatedAt
|
updatedAt: updatedAt,
|
||||||
|
starred: starred,
|
||||||
|
starredAt: starredAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,6 +391,8 @@ public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var title: String?
|
public var title: String?
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
|
public var starred = false
|
||||||
|
public var starredAt: Date?
|
||||||
public var initiatedProvider: Provider?
|
public var initiatedProvider: Provider?
|
||||||
public var initiatedModel: String?
|
public var initiatedModel: String?
|
||||||
public var lastUsedProvider: Provider?
|
public var lastUsedProvider: Provider?
|
||||||
@@ -415,6 +431,8 @@ public struct SearchDetail: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var query: String?
|
public var query: String?
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
|
public var starred = false
|
||||||
|
public var starredAt: Date?
|
||||||
public var requestId: String?
|
public var requestId: String?
|
||||||
public var latencyMs: Int?
|
public var latencyMs: Int?
|
||||||
public var error: String?
|
public var error: String?
|
||||||
|
|||||||
@@ -111,56 +111,108 @@ struct SybilSidebarItemList: View {
|
|||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
var isSelected: (SidebarItem) -> Bool
|
var isSelected: (SidebarItem) -> Bool
|
||||||
var onSelect: (SidebarItem) -> Void
|
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 {
|
var body: some View {
|
||||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
Group {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
||||||
ProgressView()
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
.tint(SybilTheme.primary)
|
ProgressView()
|
||||||
Text("Loading conversations…")
|
.tint(SybilTheme.primary)
|
||||||
.font(.sybil(.footnote))
|
Text("Loading conversations…")
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.font(.sybil(.footnote))
|
||||||
}
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
}
|
||||||
.padding(16)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
} else if viewModel.sidebarItems.isEmpty {
|
.padding(16)
|
||||||
VStack(spacing: 10) {
|
} else if viewModel.sidebarItems.isEmpty {
|
||||||
Image(systemName: "message.badge")
|
VStack(spacing: 10) {
|
||||||
.font(.system(size: 20, weight: .medium))
|
Image(systemName: "message.badge")
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.font(.system(size: 20, weight: .medium))
|
||||||
Text("Start a chat or run your first search.")
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
.font(.sybil(.footnote))
|
Text("Start a chat or run your first search.")
|
||||||
.multilineTextAlignment(.center)
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.multilineTextAlignment(.center)
|
||||||
}
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
}
|
||||||
.padding(16)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else {
|
.padding(16)
|
||||||
ScrollView {
|
} else {
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
ScrollView {
|
||||||
ForEach(viewModel.sidebarItems) { item in
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
Button {
|
ForEach(viewModel.sidebarItems) { item in
|
||||||
onSelect(item)
|
Button {
|
||||||
} label: {
|
onSelect(item)
|
||||||
SybilSidebarRow(item: item, isSelected: isSelected(item))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.contextMenu {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
Task {
|
|
||||||
await viewModel.deleteItem(item.selection)
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Label("Delete", systemImage: "trash")
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
||||||
}
|
}
|
||||||
.padding(10)
|
|
||||||
}
|
}
|
||||||
.refreshable {
|
}
|
||||||
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
.alert("Rename Chat", isPresented: isRenameAlertPresented) {
|
||||||
|
TextField("Title", text: $renameTitle)
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
renameTarget = nil
|
||||||
|
renameTitle = ""
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,6 +253,12 @@ struct SybilSidebarRow: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.layoutPriority(1)
|
.layoutPriority(1)
|
||||||
|
|
||||||
|
if item.starred {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
if item.isRunning {
|
if item.isRunning {
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ struct SidebarItem: Identifiable, Hashable {
|
|||||||
var kind: Kind
|
var kind: Kind
|
||||||
var title: String
|
var title: String
|
||||||
var updatedAt: Date
|
var updatedAt: Date
|
||||||
|
var starred: Bool
|
||||||
|
var starredAt: Date?
|
||||||
var initiatedLabel: String?
|
var initiatedLabel: String?
|
||||||
var isRunning: Bool
|
var isRunning: Bool
|
||||||
}
|
}
|
||||||
@@ -408,6 +410,8 @@ final class SybilViewModel {
|
|||||||
kind: .chat,
|
kind: .chat,
|
||||||
title: chatTitle(title: item.title, messages: nil),
|
title: chatTitle(title: item.title, messages: nil),
|
||||||
updatedAt: item.updatedAt,
|
updatedAt: item.updatedAt,
|
||||||
|
starred: item.starred,
|
||||||
|
starredAt: item.starredAt,
|
||||||
initiatedLabel: initiatedLabel,
|
initiatedLabel: initiatedLabel,
|
||||||
isRunning: isChatRowRunning(item.id)
|
isRunning: isChatRowRunning(item.id)
|
||||||
)
|
)
|
||||||
@@ -418,6 +422,8 @@ final class SybilViewModel {
|
|||||||
kind: .search,
|
kind: .search,
|
||||||
title: searchTitle(title: item.title, query: item.query),
|
title: searchTitle(title: item.title, query: item.query),
|
||||||
updatedAt: item.updatedAt,
|
updatedAt: item.updatedAt,
|
||||||
|
starred: item.starred,
|
||||||
|
starredAt: item.starredAt,
|
||||||
initiatedLabel: "exa",
|
initiatedLabel: "exa",
|
||||||
isRunning: isSearchRowRunning(item.id)
|
isRunning: isSearchRowRunning(item.id)
|
||||||
)
|
)
|
||||||
@@ -681,6 +687,8 @@ final class SybilViewModel {
|
|||||||
title: chat.title,
|
title: chat.title,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
|
starred: chat.starred,
|
||||||
|
starredAt: chat.starredAt,
|
||||||
initiatedProvider: chat.initiatedProvider,
|
initiatedProvider: chat.initiatedProvider,
|
||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
@@ -851,6 +859,57 @@ 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 {
|
func refreshAfterSettingsChange() async {
|
||||||
SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
|
SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
|
||||||
settings.persist()
|
settings.persist()
|
||||||
@@ -1381,6 +1440,47 @@ final class SybilViewModel {
|
|||||||
searches = items.compactMap(\.searchSummary)
|
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) {
|
private func upsertWorkspaceChat(_ chat: ChatSummary, moveToFront: Bool = true) {
|
||||||
upsertWorkspaceItem(WorkspaceItem(chat: chat), moveToFront: moveToFront)
|
upsertWorkspaceItem(WorkspaceItem(chat: chat), moveToFront: moveToFront)
|
||||||
}
|
}
|
||||||
@@ -1745,6 +1845,8 @@ final class SybilViewModel {
|
|||||||
title: created.title,
|
title: created.title,
|
||||||
createdAt: created.createdAt,
|
createdAt: created.createdAt,
|
||||||
updatedAt: created.updatedAt,
|
updatedAt: created.updatedAt,
|
||||||
|
starred: created.starred,
|
||||||
|
starredAt: created.starredAt,
|
||||||
initiatedProvider: created.initiatedProvider,
|
initiatedProvider: created.initiatedProvider,
|
||||||
initiatedModel: created.initiatedModel,
|
initiatedModel: created.initiatedModel,
|
||||||
lastUsedProvider: created.lastUsedProvider,
|
lastUsedProvider: created.lastUsedProvider,
|
||||||
@@ -1805,18 +1907,7 @@ final class SybilViewModel {
|
|||||||
let titleSeed = !content.isEmpty ? content : SybilChatAttachmentSupport.attachmentSummary(attachments)
|
let titleSeed = !content.isEmpty ? content : SybilChatAttachmentSupport.attachmentSummary(attachments)
|
||||||
let updated = try await client.suggestChatTitle(chatID: chatID, content: titleSeed.isEmpty ? "Uploaded files" : titleSeed)
|
let updated = try await client.suggestChatTitle(chatID: chatID, content: titleSeed.isEmpty ? "Uploaded files" : titleSeed)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.chats = self.chats.map { existing in
|
self.applyChatSummary(updated, moveToFront: false)
|
||||||
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 {
|
} catch {
|
||||||
SybilLog.warning(SybilLog.app, "Chat title suggestion failed: \(SybilLog.describe(error))")
|
SybilLog.warning(SybilLog.app, "Chat title suggestion failed: \(SybilLog.describe(error))")
|
||||||
@@ -1975,6 +2066,8 @@ final class SybilViewModel {
|
|||||||
query: query,
|
query: query,
|
||||||
createdAt: currentSelectedSearch?.createdAt ?? now,
|
createdAt: currentSelectedSearch?.createdAt ?? now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
starred: currentSelectedSearch?.starred ?? false,
|
||||||
|
starredAt: currentSelectedSearch?.starredAt,
|
||||||
requestId: nil,
|
requestId: nil,
|
||||||
latencyMs: nil,
|
latencyMs: nil,
|
||||||
error: nil,
|
error: nil,
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ private struct MockClientCallSnapshot: Sendable {
|
|||||||
var listSearches = 0
|
var listSearches = 0
|
||||||
var createChat = 0
|
var createChat = 0
|
||||||
var getChat = 0
|
var getChat = 0
|
||||||
|
var updateChatTitle = 0
|
||||||
|
var updateChatStar = 0
|
||||||
|
var updateSearchStar = 0
|
||||||
var getSearch = 0
|
var getSearch = 0
|
||||||
var getActiveRuns = 0
|
var getActiveRuns = 0
|
||||||
var runCompletionStream = 0
|
var runCompletionStream = 0
|
||||||
@@ -32,6 +35,9 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
private let chatDetails: [String: ChatDetail]
|
private let chatDetails: [String: ChatDetail]
|
||||||
private let searchDetails: [String: SearchDetail]
|
private let searchDetails: [String: SearchDetail]
|
||||||
private let createChatResponse: ChatSummary?
|
private let createChatResponse: ChatSummary?
|
||||||
|
private let updateChatTitleResponses: [String: ChatSummary]
|
||||||
|
private let updateChatStarResponses: [String: ChatSummary]
|
||||||
|
private let updateSearchStarResponses: [String: SearchSummary]
|
||||||
private let activeRunsResponse: ActiveRunsResponse
|
private let activeRunsResponse: ActiveRunsResponse
|
||||||
|
|
||||||
private var snapshot = MockClientCallSnapshot()
|
private var snapshot = MockClientCallSnapshot()
|
||||||
@@ -57,6 +63,9 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
chatDetails: [String: ChatDetail] = [:],
|
chatDetails: [String: ChatDetail] = [:],
|
||||||
searchDetails: [String: SearchDetail] = [:],
|
searchDetails: [String: SearchDetail] = [:],
|
||||||
createChatResponse: ChatSummary? = nil,
|
createChatResponse: ChatSummary? = nil,
|
||||||
|
updateChatTitleResponses: [String: ChatSummary] = [:],
|
||||||
|
updateChatStarResponses: [String: ChatSummary] = [:],
|
||||||
|
updateSearchStarResponses: [String: SearchSummary] = [:],
|
||||||
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
||||||
workspaceItemsResponse: [WorkspaceItem]? = nil
|
workspaceItemsResponse: [WorkspaceItem]? = nil
|
||||||
) {
|
) {
|
||||||
@@ -66,6 +75,9 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
self.chatDetails = chatDetails
|
self.chatDetails = chatDetails
|
||||||
self.searchDetails = searchDetails
|
self.searchDetails = searchDetails
|
||||||
self.createChatResponse = createChatResponse
|
self.createChatResponse = createChatResponse
|
||||||
|
self.updateChatTitleResponses = updateChatTitleResponses
|
||||||
|
self.updateChatStarResponses = updateChatStarResponses
|
||||||
|
self.updateSearchStarResponses = updateSearchStarResponses
|
||||||
self.activeRunsResponse = activeRunsResponse
|
self.activeRunsResponse = activeRunsResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +194,22 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
return detail
|
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 {
|
func deleteChat(chatID: String) async throws {
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
@@ -217,6 +245,14 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
throw UnexpectedClientCall()
|
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 {
|
func deleteSearch(searchID: String) async throws {
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
@@ -461,6 +497,77 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
#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
|
@MainActor
|
||||||
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
|
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_200)
|
let date = Date(timeIntervalSince1970: 1_700_000_200)
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Chat" ADD COLUMN "additionalSystemPrompt" TEXT;
|
|
||||||
ALTER TABLE "Chat" ADD COLUMN "enabledTools" JSONB;
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- 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");
|
||||||
@@ -27,6 +27,11 @@ enum SearchSource {
|
|||||||
exa
|
exa
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ProjectKind {
|
||||||
|
starred
|
||||||
|
folder
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -37,6 +42,7 @@ model User {
|
|||||||
|
|
||||||
chats Chat[]
|
chats Chat[]
|
||||||
searches Search[]
|
searches Search[]
|
||||||
|
projects Project[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Chat {
|
model Chat {
|
||||||
@@ -51,14 +57,12 @@ model Chat {
|
|||||||
lastUsedProvider Provider?
|
lastUsedProvider Provider?
|
||||||
lastUsedModel String?
|
lastUsedModel String?
|
||||||
|
|
||||||
additionalSystemPrompt String?
|
|
||||||
enabledTools Json?
|
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
userId String?
|
userId String?
|
||||||
|
|
||||||
messages Message[]
|
messages Message[]
|
||||||
calls LlmCall[]
|
calls LlmCall[]
|
||||||
|
projectItems ProjectItem[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
@@ -132,6 +136,7 @@ model Search {
|
|||||||
userId String?
|
userId String?
|
||||||
|
|
||||||
results SearchResult[]
|
results SearchResult[]
|
||||||
|
projectItems ProjectItem[]
|
||||||
|
|
||||||
@@index([updatedAt])
|
@@index([updatedAt])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@ -159,3 +164,40 @@ model SearchResult {
|
|||||||
|
|
||||||
@@index([searchId, rank])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,11 +9,7 @@ import { z } from "zod";
|
|||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
import { exaClient } from "../search/exa.js";
|
import { exaClient } from "../search/exa.js";
|
||||||
import { searchSearxng } from "../search/searxng.js";
|
import { searchSearxng } from "../search/searxng.js";
|
||||||
import {
|
import { buildOpenAIConversationMessage, buildOpenAIResponsesInputMessage } from "./message-content.js";
|
||||||
buildOpenAIConversationMessage,
|
|
||||||
buildOpenAIResponsesInputMessage,
|
|
||||||
buildSystemPromptAugmentationMessage,
|
|
||||||
} from "./message-content.js";
|
|
||||||
import type { ChatMessage } from "./types.js";
|
import type { ChatMessage } from "./types.js";
|
||||||
|
|
||||||
const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
|
const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
|
||||||
@@ -192,43 +188,7 @@ const CHAT_TOOLS: any[] = [
|
|||||||
...(env.CHAT_SHELL_TOOL_ENABLED ? [SHELL_EXEC_TOOL] : []),
|
...(env.CHAT_SHELL_TOOL_ENABLED ? [SHELL_EXEC_TOOL] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
function getToolName(tool: any) {
|
const RESPONSES_CHAT_TOOLS: any[] = CHAT_TOOLS.map((tool) => {
|
||||||
return typeof tool?.function?.name === "string" ? tool.function.name : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAvailableChatTools() {
|
|
||||||
return CHAT_TOOLS.map((tool) => {
|
|
||||||
const name = getToolName(tool);
|
|
||||||
if (!name) return null;
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
description: typeof tool?.function?.description === "string" ? tool.function.description : "",
|
|
||||||
};
|
|
||||||
}).filter((tool): tool is { name: string; description: string } => tool !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeEnabledChatTools(value: unknown) {
|
|
||||||
if (!Array.isArray(value)) return getAvailableChatTools().map((tool) => tool.name);
|
|
||||||
const available = new Set(getAvailableChatTools().map((tool) => tool.name));
|
|
||||||
return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))].filter((name) =>
|
|
||||||
available.has(name)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEnabledToolSet(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
|
||||||
return new Set(normalizeEnabledChatTools(params.enabledTools));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEnabledChatTools(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
|
||||||
const enabled = getEnabledToolSet(params);
|
|
||||||
return CHAT_TOOLS.filter((tool) => {
|
|
||||||
const name = getToolName(tool);
|
|
||||||
return name ? enabled.has(name) : false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toResponsesChatTools(tools: any[]) {
|
|
||||||
return tools.map((tool) => {
|
|
||||||
if (tool?.type !== "function") return tool;
|
if (tool?.type !== "function") return tool;
|
||||||
return {
|
return {
|
||||||
type: "function",
|
type: "function",
|
||||||
@@ -237,8 +197,7 @@ function toResponsesChatTools(tools: any[]) {
|
|||||||
parameters: tool.function.parameters,
|
parameters: tool.function.parameters,
|
||||||
strict: false,
|
strict: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
export const CHAT_TOOL_SYSTEM_PROMPT =
|
export const CHAT_TOOL_SYSTEM_PROMPT =
|
||||||
"You can use tools to gather up-to-date web information when needed. " +
|
"You can use tools to gather up-to-date web information when needed. " +
|
||||||
@@ -280,8 +239,6 @@ type ToolAwareCompletionParams = {
|
|||||||
client: OpenAI;
|
client: OpenAI;
|
||||||
model: string;
|
model: string;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
enabledTools?: string[];
|
|
||||||
userLocation?: string;
|
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>;
|
onToolEvent?: (event: ToolExecutionEvent) => void | Promise<void>;
|
||||||
@@ -422,38 +379,20 @@ function extractHtmlTitle(html: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildChatToolSystemPrompt(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
function normalizeIncomingMessages(messages: ChatMessage[]) {
|
||||||
const enabled = getEnabledToolSet(params);
|
|
||||||
return (
|
|
||||||
"You can use tools to gather up-to-date web information when needed. " +
|
|
||||||
(enabled.has("web_search") ? "Use web_search for discovery and recent facts. " : "") +
|
|
||||||
(enabled.has("fetch_url") ? "Use fetch_url to read the full content of a specific page. " : "") +
|
|
||||||
"Prefer tools when the user asks for current events, verification, sources, or details you do not already have. " +
|
|
||||||
"When you decide tool use is needed, call the tool immediately in the same response; do not say you are running a tool unless you actually call it. " +
|
|
||||||
(enabled.has("codex_exec")
|
|
||||||
? "Use codex_exec when a request needs substantial coding work, repository inspection, shell commands, tests, debugging, or another complex task suited to a persistent Codex workspace. Provide codex_exec a complete prompt with the goal, constraints, assumptions, and expected report-back format. Never ask codex_exec to wait for user input or run interactive commands. "
|
|
||||||
: "") +
|
|
||||||
(enabled.has("shell_exec")
|
|
||||||
? "Use shell_exec for direct non-interactive command-line work on the remote devbox, including quick Python programs, calculations, file inspection, running tests, and small scripts. "
|
|
||||||
: "") +
|
|
||||||
"Do not fabricate tool outputs; reason only from provided tool results."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeIncomingMessages(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
|
||||||
const normalized = messages.map((message) => buildOpenAIConversationMessage(message));
|
const normalized = messages.map((message) => buildOpenAIConversationMessage(message));
|
||||||
|
|
||||||
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePlainIncomingMessages(messages: ChatMessage[], userLocation?: string) {
|
function normalizePlainIncomingMessages(messages: ChatMessage[]) {
|
||||||
return [buildSystemPromptAugmentationMessage(userLocation), ...messages.map((message) => buildOpenAIConversationMessage(message))];
|
return messages.map((message) => buildOpenAIConversationMessage(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeIncomingResponsesInput(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
function normalizeIncomingResponsesInput(messages: ChatMessage[]) {
|
||||||
const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message));
|
const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message));
|
||||||
|
|
||||||
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
return [{ role: "system", content: CHAT_TOOL_SYSTEM_PROMPT }, ...normalized];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
|
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
|
||||||
@@ -1018,8 +957,7 @@ async function executeToolCallAndBuildEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||||
const enabledTools = getEnabledChatTools(params);
|
const input: any[] = normalizeIncomingResponsesInput(params.messages);
|
||||||
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
|
|
||||||
const rawResponses: unknown[] = [];
|
const rawResponses: unknown[] = [];
|
||||||
const toolEvents: ToolExecutionEvent[] = [];
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
@@ -1033,7 +971,7 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
|
|||||||
input,
|
input,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
max_output_tokens: params.maxTokens,
|
max_output_tokens: params.maxTokens,
|
||||||
tools: toResponsesChatTools(enabledTools),
|
tools: RESPONSES_CHAT_TOOLS,
|
||||||
tool_choice: "auto",
|
tool_choice: "auto",
|
||||||
parallel_tool_calls: true,
|
parallel_tool_calls: true,
|
||||||
// Tool loops pass response output items back as input; reasoning items need persistence.
|
// Tool loops pass response output items back as input; reasoning items need persistence.
|
||||||
@@ -1088,8 +1026,7 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||||
const enabledTools = getEnabledChatTools(params);
|
const conversation: any[] = normalizeIncomingMessages(params.messages);
|
||||||
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
|
|
||||||
const rawResponses: unknown[] = [];
|
const rawResponses: unknown[] = [];
|
||||||
const toolEvents: ToolExecutionEvent[] = [];
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
@@ -1103,7 +1040,7 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
|
|||||||
messages: conversation,
|
messages: conversation,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
max_tokens: params.maxTokens,
|
max_tokens: params.maxTokens,
|
||||||
tools: enabledTools,
|
tools: CHAT_TOOLS,
|
||||||
tool_choice: "auto",
|
tool_choice: "auto",
|
||||||
} as any);
|
} as any);
|
||||||
rawResponses.push(completion);
|
rawResponses.push(completion);
|
||||||
@@ -1177,7 +1114,7 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
|
|||||||
export async function runPlainChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
export async function runPlainChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||||
const completion = await params.client.chat.completions.create({
|
const completion = await params.client.chat.completions.create({
|
||||||
model: params.model,
|
model: params.model,
|
||||||
messages: normalizePlainIncomingMessages(params.messages, params.userLocation),
|
messages: normalizePlainIncomingMessages(params.messages),
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
max_tokens: params.maxTokens,
|
max_tokens: params.maxTokens,
|
||||||
} as any);
|
} as any);
|
||||||
@@ -1197,8 +1134,7 @@ export async function runPlainChatCompletions(params: ToolAwareCompletionParams)
|
|||||||
export async function* runToolAwareOpenAIChatStream(
|
export async function* runToolAwareOpenAIChatStream(
|
||||||
params: ToolAwareCompletionParams
|
params: ToolAwareCompletionParams
|
||||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||||
const enabledTools = getEnabledChatTools(params);
|
const input: any[] = normalizeIncomingResponsesInput(params.messages);
|
||||||
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
|
|
||||||
const rawResponses: unknown[] = [];
|
const rawResponses: unknown[] = [];
|
||||||
const toolEvents: ToolExecutionEvent[] = [];
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
@@ -1212,7 +1148,7 @@ export async function* runToolAwareOpenAIChatStream(
|
|||||||
input,
|
input,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
max_output_tokens: params.maxTokens,
|
max_output_tokens: params.maxTokens,
|
||||||
tools: toResponsesChatTools(enabledTools),
|
tools: RESPONSES_CHAT_TOOLS,
|
||||||
tool_choice: "auto",
|
tool_choice: "auto",
|
||||||
parallel_tool_calls: true,
|
parallel_tool_calls: true,
|
||||||
// Tool loops pass response output items back as input; reasoning items need persistence.
|
// Tool loops pass response output items back as input; reasoning items need persistence.
|
||||||
@@ -1324,8 +1260,7 @@ export async function* runToolAwareOpenAIChatStream(
|
|||||||
export async function* runToolAwareChatCompletionsStream(
|
export async function* runToolAwareChatCompletionsStream(
|
||||||
params: ToolAwareCompletionParams
|
params: ToolAwareCompletionParams
|
||||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||||
const enabledTools = getEnabledChatTools(params);
|
const conversation: any[] = normalizeIncomingMessages(params.messages);
|
||||||
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
|
|
||||||
const rawResponses: unknown[] = [];
|
const rawResponses: unknown[] = [];
|
||||||
const toolEvents: ToolExecutionEvent[] = [];
|
const toolEvents: ToolExecutionEvent[] = [];
|
||||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||||
@@ -1339,7 +1274,7 @@ export async function* runToolAwareChatCompletionsStream(
|
|||||||
messages: conversation,
|
messages: conversation,
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
max_tokens: params.maxTokens,
|
max_tokens: params.maxTokens,
|
||||||
tools: enabledTools,
|
tools: CHAT_TOOLS,
|
||||||
tool_choice: "auto",
|
tool_choice: "auto",
|
||||||
stream: true,
|
stream: true,
|
||||||
stream_options: { include_usage: true },
|
stream_options: { include_usage: true },
|
||||||
@@ -1468,7 +1403,7 @@ export async function* runPlainChatCompletionsStream(
|
|||||||
|
|
||||||
const stream = await params.client.chat.completions.create({
|
const stream = await params.client.chat.completions.create({
|
||||||
model: params.model,
|
model: params.model,
|
||||||
messages: normalizePlainIncomingMessages(params.messages, params.userLocation),
|
messages: normalizePlainIncomingMessages(params.messages),
|
||||||
temperature: params.temperature,
|
temperature: params.temperature,
|
||||||
max_tokens: params.maxTokens,
|
max_tokens: params.maxTokens,
|
||||||
stream: true,
|
stream: true,
|
||||||
|
|||||||
@@ -1,19 +1,5 @@
|
|||||||
import type { ChatAttachment, ChatImageAttachment, ChatMessage, ChatTextAttachment } from "./types.js";
|
import type { ChatAttachment, ChatImageAttachment, ChatMessage, ChatTextAttachment } from "./types.js";
|
||||||
|
|
||||||
const DEFAULT_USER_LOCATION = "San Francisco, CA";
|
|
||||||
|
|
||||||
function currentDateString(now = new Date()) {
|
|
||||||
return now.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveUserLocation(userLocation?: string) {
|
|
||||||
return userLocation?.trim() || process.env.SYBIL_USER_LOCATION?.trim() || DEFAULT_USER_LOCATION;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSystemPromptAugmentation(userLocation?: string, now = new Date()) {
|
|
||||||
return `Current date: ${currentDateString(now)}.\nUser location: ${resolveUserLocation(userLocation)}.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeAttribute(value: string) {
|
function escapeAttribute(value: string) {
|
||||||
return value.replace(/"/g, """);
|
return value.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
@@ -212,18 +198,11 @@ export function buildOpenAIResponsesInputMessage(message: ChatMessage) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSystemPromptAugmentationMessage(userLocation?: string) {
|
|
||||||
return {
|
|
||||||
role: "system",
|
|
||||||
content: buildSystemPromptAugmentation(userLocation),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ANTHROPIC_NO_SERVER_TOOLS_PROMPT =
|
const ANTHROPIC_NO_SERVER_TOOLS_PROMPT =
|
||||||
"This Anthropic backend path does not have server-managed tool calls. Do not claim to run shell commands, Codex tasks, web searches, or fetch URLs. If the user asks for tool execution, explain that they should switch to OpenAI or xAI in this app for tool-enabled chat.";
|
"This Anthropic backend path does not have server-managed tool calls. Do not claim to run shell commands, Codex tasks, web searches, or fetch URLs. If the user asks for tool execution, explain that they should switch to OpenAI or xAI in this app for tool-enabled chat.";
|
||||||
|
|
||||||
export function getAnthropicSystemPrompt(messages: ChatMessage[], userLocation?: string) {
|
export function getAnthropicSystemPrompt(messages: ChatMessage[]) {
|
||||||
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, buildSystemPromptAugmentation(userLocation), messages.find((message) => message.role === "system")?.content]
|
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, messages.find((message) => message.role === "system")?.content]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import { prisma } from "../db.js";
|
import { prisma } from "../db.js";
|
||||||
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
||||||
import { buildToolLogMessageData, normalizeEnabledChatTools, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
|
import { buildToolLogMessageData, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
|
||||||
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
||||||
import { toPrismaProvider } from "./provider-ids.js";
|
import { toPrismaProvider } from "./provider-ids.js";
|
||||||
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
|
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
|
||||||
@@ -47,16 +47,13 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
|||||||
let usage: MultiplexResponse["usage"] | undefined;
|
let usage: MultiplexResponse["usage"] | undefined;
|
||||||
let raw: unknown;
|
let raw: unknown;
|
||||||
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
|
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
|
||||||
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
|
|
||||||
|
|
||||||
if (req.provider === "openai" && enabledTools.length > 0) {
|
if (req.provider === "openai") {
|
||||||
const client = openaiClient();
|
const client = openaiClient();
|
||||||
const r = await runToolAwareOpenAIChat({
|
const r = await runToolAwareOpenAIChat({
|
||||||
client,
|
client,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
messages: req.messages,
|
||||||
enabledTools,
|
|
||||||
userLocation: req.userLocation,
|
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
maxTokens: req.maxTokens,
|
maxTokens: req.maxTokens,
|
||||||
logContext: {
|
logContext: {
|
||||||
@@ -69,14 +66,12 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
|||||||
outText = r.text;
|
outText = r.text;
|
||||||
usage = r.usage;
|
usage = r.usage;
|
||||||
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
||||||
} else if (req.provider === "xai" && enabledTools.length > 0) {
|
} else if (req.provider === "xai") {
|
||||||
const client = xaiClient();
|
const client = xaiClient();
|
||||||
const r = await runToolAwareChatCompletions({
|
const r = await runToolAwareChatCompletions({
|
||||||
client,
|
client,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
messages: req.messages,
|
||||||
enabledTools,
|
|
||||||
userLocation: req.userLocation,
|
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
maxTokens: req.maxTokens,
|
maxTokens: req.maxTokens,
|
||||||
logContext: {
|
logContext: {
|
||||||
@@ -89,13 +84,12 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
|||||||
outText = r.text;
|
outText = r.text;
|
||||||
usage = r.usage;
|
usage = r.usage;
|
||||||
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
||||||
} else if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
|
} else if (req.provider === "hermes-agent") {
|
||||||
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
|
const client = hermesAgentClient();
|
||||||
const r = await runPlainChatCompletions({
|
const r = await runPlainChatCompletions({
|
||||||
client,
|
client,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
messages: req.messages,
|
||||||
userLocation: req.userLocation,
|
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
maxTokens: req.maxTokens,
|
maxTokens: req.maxTokens,
|
||||||
logContext: {
|
logContext: {
|
||||||
@@ -110,7 +104,7 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
|||||||
} else if (req.provider === "anthropic") {
|
} else if (req.provider === "anthropic") {
|
||||||
const client = anthropicClient();
|
const client = anthropicClient();
|
||||||
|
|
||||||
const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
|
const system = getAnthropicSystemPrompt(req.messages);
|
||||||
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
||||||
|
|
||||||
const r = await client.messages.create({
|
const r = await client.messages.create({
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { prisma } from "../db.js";
|
|||||||
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
||||||
import {
|
import {
|
||||||
buildToolLogMessageData,
|
buildToolLogMessageData,
|
||||||
normalizeEnabledChatTools,
|
|
||||||
runPlainChatCompletionsStream,
|
runPlainChatCompletionsStream,
|
||||||
runToolAwareChatCompletionsStream,
|
runToolAwareChatCompletionsStream,
|
||||||
runToolAwareOpenAIChatStream,
|
runToolAwareOpenAIChatStream,
|
||||||
@@ -77,15 +76,12 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
try {
|
try {
|
||||||
if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
|
if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
|
||||||
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
|
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
|
||||||
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
|
|
||||||
const streamEvents =
|
const streamEvents =
|
||||||
req.provider === "openai" && enabledTools.length > 0
|
req.provider === "openai"
|
||||||
? runToolAwareOpenAIChatStream({
|
? runToolAwareOpenAIChatStream({
|
||||||
client,
|
client,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
messages: req.messages,
|
||||||
enabledTools,
|
|
||||||
userLocation: req.userLocation,
|
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
maxTokens: req.maxTokens,
|
maxTokens: req.maxTokens,
|
||||||
logContext: {
|
logContext: {
|
||||||
@@ -94,12 +90,11 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
chatId: chatId ?? undefined,
|
chatId: chatId ?? undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: req.provider === "hermes-agent" || enabledTools.length === 0
|
: req.provider === "hermes-agent"
|
||||||
? runPlainChatCompletionsStream({
|
? runPlainChatCompletionsStream({
|
||||||
client,
|
client,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
messages: req.messages,
|
||||||
userLocation: req.userLocation,
|
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
maxTokens: req.maxTokens,
|
maxTokens: req.maxTokens,
|
||||||
logContext: {
|
logContext: {
|
||||||
@@ -112,8 +107,6 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
client,
|
client,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
messages: req.messages,
|
||||||
enabledTools,
|
|
||||||
userLocation: req.userLocation,
|
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
maxTokens: req.maxTokens,
|
maxTokens: req.maxTokens,
|
||||||
logContext: {
|
logContext: {
|
||||||
@@ -153,7 +146,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
} else if (req.provider === "anthropic") {
|
} else if (req.provider === "anthropic") {
|
||||||
const client = anthropicClient();
|
const client = anthropicClient();
|
||||||
|
|
||||||
const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
|
const system = getAnthropicSystemPrompt(req.messages);
|
||||||
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
||||||
|
|
||||||
const stream = await client.messages.create({
|
const stream = await client.messages.create({
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ export type MultiplexRequest = {
|
|||||||
provider: Provider;
|
provider: Provider;
|
||||||
model: string;
|
model: string;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
additionalSystemPrompt?: string;
|
|
||||||
enabledTools?: string[];
|
|
||||||
userLocation?: string;
|
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { env } from "./env.js";
|
|||||||
import { buildComparableAttachments } from "./llm/message-content.js";
|
import { buildComparableAttachments } from "./llm/message-content.js";
|
||||||
import { runMultiplex } from "./llm/multiplexer.js";
|
import { runMultiplex } from "./llm/multiplexer.js";
|
||||||
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
|
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
|
||||||
import { getAvailableChatTools, normalizeEnabledChatTools } from "./llm/chat-tools.js";
|
|
||||||
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
||||||
import { openaiClient } from "./llm/providers.js";
|
import { openaiClient } from "./llm/providers.js";
|
||||||
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
||||||
@@ -16,8 +15,6 @@ import { exaClient } from "./search/exa.js";
|
|||||||
import type { ChatAttachment } from "./llm/types.js";
|
import type { ChatAttachment } from "./llm/types.js";
|
||||||
|
|
||||||
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
|
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
|
||||||
const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000;
|
|
||||||
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
|
|
||||||
|
|
||||||
type IncomingChatMessage = {
|
type IncomingChatMessage = {
|
||||||
role: "system" | "user" | "assistant" | "tool";
|
role: "system" | "user" | "assistant" | "tool";
|
||||||
@@ -50,43 +47,6 @@ function isToolCallLogMessage(message: { role: string; metadata: unknown }) {
|
|||||||
return message.role === "tool" && isToolCallLogMetadata(message.metadata);
|
return message.role === "tool" && isToolCallLogMetadata(message.metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHeaderString(req: FastifyRequest, name: string) {
|
|
||||||
const value = req.headers[name.toLowerCase()];
|
|
||||||
if (Array.isArray(value)) return value.find((item) => item.trim());
|
|
||||||
return typeof value === "string" && value.trim() ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeHeaderPart(value: string | undefined) {
|
|
||||||
if (!value) return undefined;
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return undefined;
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(trimmed);
|
|
||||||
} catch {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferRequestUserLocation(req: FastifyRequest) {
|
|
||||||
const explicit = decodeHeaderPart(getHeaderString(req, "x-user-location"));
|
|
||||||
if (explicit) return explicit;
|
|
||||||
|
|
||||||
const vercelCity = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-city"));
|
|
||||||
const vercelRegion = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-country-region"));
|
|
||||||
const vercelCountry = decodeHeaderPart(getHeaderString(req, "x-vercel-ip-country"));
|
|
||||||
const vercelLocation = [vercelCity, vercelRegion, vercelCountry].filter(Boolean).join(", ");
|
|
||||||
if (vercelLocation) return vercelLocation;
|
|
||||||
|
|
||||||
const cfCity = decodeHeaderPart(getHeaderString(req, "cf-ipcity"));
|
|
||||||
const cfRegion = decodeHeaderPart(getHeaderString(req, "cf-region"));
|
|
||||||
const cfCountry = decodeHeaderPart(getHeaderString(req, "cf-ipcountry"));
|
|
||||||
return [cfCity, cfRegion, cfCountry].filter(Boolean).join(", ") || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function withRequestUserLocation<T extends { userLocation?: string }>(body: T, req: FastifyRequest): T {
|
|
||||||
return body.userLocation ? body : { ...body, userLocation: inferRequestUserLocation(req) };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
|
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
|
||||||
const incoming = messages.filter((m) => m.role !== "assistant");
|
const incoming = messages.filter((m) => m.role !== "assistant");
|
||||||
if (!incoming.length) return;
|
if (!incoming.length) return;
|
||||||
@@ -171,9 +131,6 @@ const CompletionStreamBody = z
|
|||||||
provider: ProviderSchema,
|
provider: ProviderSchema,
|
||||||
model: z.string().min(1),
|
model: z.string().min(1),
|
||||||
messages: z.array(CompletionMessageSchema),
|
messages: z.array(CompletionMessageSchema),
|
||||||
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
|
|
||||||
enabledTools: EnabledToolsSchema.optional(),
|
|
||||||
userLocation: z.string().trim().min(1).max(200).optional(),
|
|
||||||
temperature: z.number().min(0).max(2).optional(),
|
temperature: z.number().min(0).max(2).optional(),
|
||||||
maxTokens: z.number().int().positive().optional(),
|
maxTokens: z.number().int().positive().optional(),
|
||||||
})
|
})
|
||||||
@@ -198,41 +155,6 @@ function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttac
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAdditionalSystemPrompt(value: string | null | undefined) {
|
|
||||||
const trimmed = value?.trim();
|
|
||||||
return trimmed || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function prependAdditionalSystemPrompt<T extends { messages: IncomingChatMessage[]; additionalSystemPrompt?: string | null }>(body: T): T {
|
|
||||||
const additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
|
|
||||||
if (!additionalSystemPrompt) return { ...body, additionalSystemPrompt: undefined };
|
|
||||||
return {
|
|
||||||
...body,
|
|
||||||
additionalSystemPrompt,
|
|
||||||
messages: [{ role: "system", content: additionalSystemPrompt }, ...body.messages],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyStoredChatSettings<T extends { chatId?: string; messages: IncomingChatMessage[]; additionalSystemPrompt?: string; enabledTools?: string[] }>(
|
|
||||||
body: T
|
|
||||||
) {
|
|
||||||
if (!body.chatId || (body.additionalSystemPrompt !== undefined && body.enabledTools !== undefined)) {
|
|
||||||
return prependAdditionalSystemPrompt(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
const chat = await prisma.chat.findUnique({
|
|
||||||
where: { id: body.chatId },
|
|
||||||
select: { additionalSystemPrompt: true, enabledTools: true },
|
|
||||||
});
|
|
||||||
if (!chat) return prependAdditionalSystemPrompt(body);
|
|
||||||
|
|
||||||
return prependAdditionalSystemPrompt({
|
|
||||||
...body,
|
|
||||||
additionalSystemPrompt: body.additionalSystemPrompt ?? chat.additionalSystemPrompt ?? undefined,
|
|
||||||
enabledTools: body.enabledTools ?? normalizeEnabledChatTools(chat.enabledTools),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const SearchRunBody = z.object({
|
const SearchRunBody = z.object({
|
||||||
query: z.string().trim().min(1).optional(),
|
query: z.string().trim().min(1).optional(),
|
||||||
title: z.string().trim().min(1).optional(),
|
title: z.string().trim().min(1).optional(),
|
||||||
@@ -399,6 +321,34 @@ type SearchRunRequest = z.infer<typeof SearchRunBody>;
|
|||||||
|
|
||||||
const activeChatStreams = new Map<string, ActiveSseStream>();
|
const activeChatStreams = new Map<string, ActiveSseStream>();
|
||||||
const activeSearchStreams = 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) {
|
function getErrorMessage(err: unknown) {
|
||||||
return err instanceof Error ? err.message : String(err);
|
return err instanceof Error ? err.message : String(err);
|
||||||
@@ -408,34 +358,111 @@ function compareUpdatedAtDesc(a: { updatedAt: Date | string }, b: { updatedAt: D
|
|||||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
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() {
|
async function listWorkspaceItems() {
|
||||||
const [chats, searches] = await Promise.all([
|
const [chats, searches] = await Promise.all([
|
||||||
prisma.chat.findMany({
|
prisma.chat.findMany({
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
take: 100,
|
take: 100,
|
||||||
select: {
|
select: chatSummarySelect,
|
||||||
id: true,
|
|
||||||
title: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
initiatedProvider: true,
|
|
||||||
initiatedModel: true,
|
|
||||||
lastUsedProvider: true,
|
|
||||||
lastUsedModel: true,
|
|
||||||
additionalSystemPrompt: true,
|
|
||||||
enabledTools: true,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
prisma.search.findMany({
|
prisma.search.findMany({
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
take: 100,
|
take: 100,
|
||||||
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
|
select: searchSummarySelect,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...chats.map((chat) => ({ type: "chat" as const, ...serializeProviderFields(chat) })),
|
...chats.map((chat) => ({ type: "chat" as const, ...serializeChatLike(chat) })),
|
||||||
...searches.map((search) => ({ type: "search" as const, ...search })),
|
...searches.map((search) => ({ type: "search" as const, ...serializeSearchLike(search) })),
|
||||||
].sort(compareUpdatedAtDesc);
|
].sort(compareUpdatedAtDesc);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -642,12 +669,15 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
|
|||||||
|
|
||||||
const search = await prisma.search.findUnique({
|
const search = await prisma.search.findUnique({
|
||||||
where: { id: searchId },
|
where: { id: searchId },
|
||||||
include: { results: { orderBy: { rank: "asc" } } },
|
include: {
|
||||||
|
results: { orderBy: { rank: "asc" } },
|
||||||
|
projectItems: starredProjectItemsSelect,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!search) {
|
if (!search) {
|
||||||
stream.complete({ event: "error", data: { message: "search not found" } });
|
stream.complete({ event: "error", data: { message: "search not found" } });
|
||||||
} else {
|
} else {
|
||||||
stream.complete({ event: "done", data: { search } });
|
stream.complete({ event: "done", data: { search: serializeSearchLike(search) } });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = getErrorMessage(err);
|
const message = getErrorMessage(err);
|
||||||
@@ -683,11 +713,6 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
return { providers: getModelCatalogSnapshot() };
|
return { providers: getModelCatalogSnapshot() };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/v1/chat-tools", async (req) => {
|
|
||||||
requireAdmin(req);
|
|
||||||
return { tools: getAvailableChatTools() };
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/v1/active-runs", async (req) => {
|
app.get("/v1/active-runs", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
return {
|
return {
|
||||||
@@ -706,20 +731,9 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const chats = await prisma.chat.findMany({
|
const chats = await prisma.chat.findMany({
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
take: 100,
|
take: 100,
|
||||||
select: {
|
select: chatSummarySelect,
|
||||||
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) => serializeProviderFields(chat)) };
|
return { chats: chats.map((chat) => serializeChatLike(chat)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/chats", async (req) => {
|
app.post("/v1/chats", async (req) => {
|
||||||
@@ -729,8 +743,6 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
provider: ProviderSchema.optional(),
|
provider: ProviderSchema.optional(),
|
||||||
model: z.string().trim().min(1).optional(),
|
model: z.string().trim().min(1).optional(),
|
||||||
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
|
|
||||||
enabledTools: EnabledToolsSchema.optional(),
|
|
||||||
messages: z.array(CompletionMessageSchema).optional(),
|
messages: z.array(CompletionMessageSchema).optional(),
|
||||||
})
|
})
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
@@ -759,8 +771,6 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
initiatedModel: body.model,
|
initiatedModel: body.model,
|
||||||
lastUsedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
|
lastUsedProvider: body.provider ? (toPrismaProvider(body.provider) as any) : undefined,
|
||||||
lastUsedModel: body.model,
|
lastUsedModel: body.model,
|
||||||
additionalSystemPrompt: normalizeAdditionalSystemPrompt(body.additionalSystemPrompt),
|
|
||||||
enabledTools: body.enabledTools as any,
|
|
||||||
messages: body.messages?.length
|
messages: body.messages?.length
|
||||||
? {
|
? {
|
||||||
create: body.messages.map((message) => ({
|
create: body.messages.map((message) => ({
|
||||||
@@ -772,62 +782,40 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
select: {
|
select: chatSummarySelect,
|
||||||
id: true,
|
|
||||||
title: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
initiatedProvider: true,
|
|
||||||
initiatedModel: true,
|
|
||||||
lastUsedProvider: true,
|
|
||||||
lastUsedModel: true,
|
|
||||||
additionalSystemPrompt: true,
|
|
||||||
enabledTools: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return { chat: serializeProviderFields(chat) };
|
return { chat: serializeChatLike(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/v1/chats/:chatId", async (req) => {
|
app.patch("/v1/chats/:chatId", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const Params = z.object({ chatId: z.string() });
|
const Params = z.object({ chatId: z.string() });
|
||||||
const Body = z.object({
|
const Body = z.object({ title: z.string().trim().min(1) });
|
||||||
title: z.string().trim().min(1).optional(),
|
|
||||||
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).nullable().optional(),
|
|
||||||
enabledTools: EnabledToolsSchema.optional(),
|
|
||||||
});
|
|
||||||
const { chatId } = Params.parse(req.params);
|
const { chatId } = Params.parse(req.params);
|
||||||
const body = Body.parse(req.body ?? {});
|
const body = Body.parse(req.body ?? {});
|
||||||
|
|
||||||
const data: Record<string, unknown> = {};
|
|
||||||
if (body.title !== undefined) data.title = body.title;
|
|
||||||
if (body.additionalSystemPrompt !== undefined) data.additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
|
|
||||||
if (body.enabledTools !== undefined) data.enabledTools = body.enabledTools;
|
|
||||||
|
|
||||||
const updated = await prisma.chat.updateMany({
|
const updated = await prisma.chat.updateMany({
|
||||||
where: { id: chatId },
|
where: { id: chatId },
|
||||||
data: data as any,
|
data: { title: body.title },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (updated.count === 0) return app.httpErrors.notFound("chat not found");
|
if (updated.count === 0) return app.httpErrors.notFound("chat not found");
|
||||||
|
|
||||||
const chat = await prisma.chat.findUnique({
|
const chat = await getChatSummary(chatId);
|
||||||
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");
|
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||||
return { chat: serializeProviderFields(chat) };
|
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 };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/chats/title/suggest", async (req) => {
|
app.post("/v1/chats/title/suggest", async (req) => {
|
||||||
@@ -840,44 +828,24 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const existing = await prisma.chat.findUnique({
|
const existing = await prisma.chat.findUnique({
|
||||||
where: { id: body.chatId },
|
where: { id: body.chatId },
|
||||||
select: {
|
select: chatSummarySelect,
|
||||||
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) return app.httpErrors.notFound("chat not found");
|
||||||
if (existing.title?.trim()) return { chat: serializeProviderFields(existing) };
|
if (existing.title?.trim()) return { chat: serializeChatLike(existing) };
|
||||||
|
|
||||||
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
||||||
const suggestedRaw = await generateChatTitle(body.content);
|
const suggestedRaw = await generateChatTitle(body.content);
|
||||||
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
||||||
|
|
||||||
const chat = await prisma.chat.update({
|
await prisma.chat.updateMany({
|
||||||
where: { id: body.chatId },
|
where: { id: body.chatId, title: existing.title },
|
||||||
data: { title },
|
data: { title },
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
title: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
initiatedProvider: true,
|
|
||||||
initiatedModel: true,
|
|
||||||
lastUsedProvider: true,
|
|
||||||
lastUsedModel: true,
|
|
||||||
additionalSystemPrompt: true,
|
|
||||||
enabledTools: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { chat: serializeProviderFields(chat) };
|
const chat = await getChatSummary(body.chatId);
|
||||||
|
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||||
|
|
||||||
|
return { chat };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete("/v1/chats/:chatId", async (req) => {
|
app.delete("/v1/chats/:chatId", async (req) => {
|
||||||
@@ -902,9 +870,9 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const searches = await prisma.search.findMany({
|
const searches = await prisma.search.findMany({
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
take: 100,
|
take: 100,
|
||||||
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
|
select: searchSummarySelect,
|
||||||
});
|
});
|
||||||
return { searches };
|
return { searches: searches.map((search) => serializeSearchLike(search)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/searches", async (req) => {
|
app.post("/v1/searches", async (req) => {
|
||||||
@@ -918,8 +886,20 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
title: title || null,
|
title: title || null,
|
||||||
query,
|
query,
|
||||||
},
|
},
|
||||||
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
|
select: searchSummarySelect,
|
||||||
});
|
});
|
||||||
|
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 };
|
return { search };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -946,10 +926,13 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const search = await prisma.search.findUnique({
|
const search = await prisma.search.findUnique({
|
||||||
where: { id: searchId },
|
where: { id: searchId },
|
||||||
include: { results: { orderBy: { rank: "asc" } } },
|
include: {
|
||||||
|
results: { orderBy: { rank: "asc" } },
|
||||||
|
projectItems: starredProjectItemsSelect,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!search) return app.httpErrors.notFound("search not found");
|
if (!search) return app.httpErrors.notFound("search not found");
|
||||||
return { search };
|
return { search: serializeSearchLike(search) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/searches/:searchId/chat", async (req) => {
|
app.post("/v1/searches/:searchId/chat", async (req) => {
|
||||||
@@ -985,21 +968,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: chatSummarySelect,
|
||||||
id: true,
|
|
||||||
title: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
initiatedProvider: true,
|
|
||||||
initiatedModel: true,
|
|
||||||
lastUsedProvider: true,
|
|
||||||
lastUsedModel: true,
|
|
||||||
additionalSystemPrompt: true,
|
|
||||||
enabledTools: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { chat: serializeProviderFields(chat) };
|
return { chat: serializeChatLike(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/searches/:searchId/run", async (req) => {
|
app.post("/v1/searches/:searchId/run", async (req) => {
|
||||||
@@ -1084,10 +1056,13 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const search = await prisma.search.findUnique({
|
const search = await prisma.search.findUnique({
|
||||||
where: { id: searchId },
|
where: { id: searchId },
|
||||||
include: { results: { orderBy: { rank: "asc" } } },
|
include: {
|
||||||
|
results: { orderBy: { rank: "asc" } },
|
||||||
|
projectItems: starredProjectItemsSelect,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!search) return app.httpErrors.notFound("search not found");
|
if (!search) return app.httpErrors.notFound("search not found");
|
||||||
return { search };
|
return { search: serializeSearchLike(search) };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
await prisma.search.update({
|
await prisma.search.update({
|
||||||
where: { id: searchId },
|
where: { id: searchId },
|
||||||
@@ -1142,10 +1117,14 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const chat = await prisma.chat.findUnique({
|
const chat = await prisma.chat.findUnique({
|
||||||
where: { id: chatId },
|
where: { id: chatId },
|
||||||
include: { messages: { orderBy: { createdAt: "asc" } }, calls: { orderBy: { createdAt: "desc" } } },
|
include: {
|
||||||
|
messages: { orderBy: { createdAt: "asc" } },
|
||||||
|
calls: { orderBy: { createdAt: "desc" } },
|
||||||
|
projectItems: starredProjectItemsSelect,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||||
return { chat: serializeProviderFields(chat) };
|
return { chat: serializeChatLike(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/chats/:chatId/messages", async (req) => {
|
app.post("/v1/chats/:chatId/messages", async (req) => {
|
||||||
@@ -1195,16 +1174,13 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
provider: ProviderSchema,
|
provider: ProviderSchema,
|
||||||
model: z.string().min(1),
|
model: z.string().min(1),
|
||||||
messages: z.array(CompletionMessageSchema),
|
messages: z.array(CompletionMessageSchema),
|
||||||
additionalSystemPrompt: z.string().max(MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS).optional(),
|
|
||||||
enabledTools: EnabledToolsSchema.optional(),
|
|
||||||
userLocation: z.string().trim().min(1).max(200).optional(),
|
|
||||||
temperature: z.number().min(0).max(2).optional(),
|
temperature: z.number().min(0).max(2).optional(),
|
||||||
maxTokens: z.number().int().positive().optional(),
|
maxTokens: z.number().int().positive().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = Body.safeParse(req.body);
|
const parsed = Body.safeParse(req.body);
|
||||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||||
const body = withRequestUserLocation(parsed.data, req);
|
const body = parsed.data;
|
||||||
|
|
||||||
// ensure chat exists if provided
|
// ensure chat exists if provided
|
||||||
if (body.chatId) {
|
if (body.chatId) {
|
||||||
@@ -1217,7 +1193,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
await storeNonAssistantMessages(body.chatId, body.messages);
|
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await runMultiplex(await applyStoredChatSettings(body));
|
const result = await runMultiplex(body);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
chatId: body.chatId ?? null,
|
chatId: body.chatId ?? null,
|
||||||
@@ -1231,7 +1207,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const parsed = CompletionStreamBody.safeParse(req.body);
|
const parsed = CompletionStreamBody.safeParse(req.body);
|
||||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||||
const body = withRequestUserLocation(parsed.data, req);
|
const body = parsed.data;
|
||||||
|
|
||||||
// ensure chat exists if provided
|
// ensure chat exists if provided
|
||||||
if (body.chatId) {
|
if (body.chatId) {
|
||||||
@@ -1248,14 +1224,14 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
if (activeChatStreams.has(body.chatId)) {
|
if (activeChatStreams.has(body.chatId)) {
|
||||||
return app.httpErrors.conflict("chat completion already running");
|
return app.httpErrors.conflict("chat completion already running");
|
||||||
}
|
}
|
||||||
const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body));
|
const stream = startActiveChatStream(body.chatId, body);
|
||||||
return streamActiveRun(req, reply, stream);
|
return streamActiveRun(req, reply, stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||||
reply.raw.flushHeaders();
|
reply.raw.flushHeaders();
|
||||||
|
|
||||||
for await (const ev of runMultiplexStream(await applyStoredChatSettings(body))) {
|
for await (const ev of runMultiplexStream(body)) {
|
||||||
writeSseEvent(reply, mapChatStreamEvent(ev));
|
writeSseEvent(reply, mapChatStreamEvent(ev));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
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\./);
|
|
||||||
});
|
|
||||||
@@ -60,6 +60,22 @@ export class SybilApiClient {
|
|||||||
return data.chat;
|
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 }) {
|
async suggestChatTitle(body: { chatId: string; content: string }) {
|
||||||
const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
|
const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -90,6 +106,14 @@ export class SybilApiClient {
|
|||||||
return data.search;
|
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) {
|
async deleteSearch(searchId: string) {
|
||||||
await this.request<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
|
await this.request<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
@@ -100,7 +124,6 @@ export class SybilApiClient {
|
|||||||
provider: Provider;
|
provider: Provider;
|
||||||
model: string;
|
model: string;
|
||||||
messages: CompletionRequestMessage[];
|
messages: CompletionRequestMessage[];
|
||||||
userLocation?: string;
|
|
||||||
},
|
},
|
||||||
handlers: CompletionStreamHandlers,
|
handlers: CompletionStreamHandlers,
|
||||||
options?: { signal?: AbortSignal }
|
options?: { signal?: AbortSignal }
|
||||||
|
|||||||
185
tui/src/index.ts
185
tui/src/index.ts
@@ -20,6 +20,8 @@ type SidebarItem = SidebarSelection & {
|
|||||||
title: string;
|
title: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
starred: boolean;
|
||||||
|
starredAt: string | null;
|
||||||
initiatedProvider: Provider | null;
|
initiatedProvider: Provider | null;
|
||||||
initiatedModel: string | null;
|
initiatedModel: string | null;
|
||||||
lastUsedProvider: Provider | null;
|
lastUsedProvider: Provider | null;
|
||||||
@@ -131,6 +133,8 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
|||||||
title: getChatTitle(chat),
|
title: getChatTitle(chat),
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
|
starred: chat.starred,
|
||||||
|
starredAt: chat.starredAt,
|
||||||
initiatedProvider: chat.initiatedProvider,
|
initiatedProvider: chat.initiatedProvider,
|
||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
@@ -145,6 +149,8 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
|||||||
title: getSearchTitle(search),
|
title: getSearchTitle(search),
|
||||||
updatedAt: search.updatedAt,
|
updatedAt: search.updatedAt,
|
||||||
createdAt: search.createdAt,
|
createdAt: search.createdAt,
|
||||||
|
starred: search.starred,
|
||||||
|
starredAt: search.starredAt,
|
||||||
initiatedProvider: null,
|
initiatedProvider: null,
|
||||||
initiatedModel: null,
|
initiatedModel: null,
|
||||||
lastUsedProvider: null,
|
lastUsedProvider: null,
|
||||||
@@ -254,6 +260,7 @@ async function main() {
|
|||||||
let renderedSidebarItems: SidebarItem[] = [];
|
let renderedSidebarItems: SidebarItem[] = [];
|
||||||
let renderedSidebarLines: string[] = [];
|
let renderedSidebarLines: string[] = [];
|
||||||
let suppressedSidebarSelectEvents = 0;
|
let suppressedSidebarSelectEvents = 0;
|
||||||
|
let isRenamePromptOpen = false;
|
||||||
|
|
||||||
const screen = blessed.screen({
|
const screen = blessed.screen({
|
||||||
smartCSR: true,
|
smartCSR: true,
|
||||||
@@ -361,6 +368,26 @@ 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;
|
const focusables = [sidebar, transcript, composer] as const;
|
||||||
|
|
||||||
function getTranscriptViewportHeight() {
|
function getTranscriptViewportHeight() {
|
||||||
@@ -500,12 +527,13 @@ async function main() {
|
|||||||
? ["No chats/searches yet. Press n or /. "]
|
? ["No chats/searches yet. Press n or /. "]
|
||||||
: items.map((item) => {
|
: items.map((item) => {
|
||||||
const kind = item.kind === "chat" ? "C" : "S";
|
const kind = item.kind === "chat" ? "C" : "S";
|
||||||
|
const star = item.starred ? "{yellow-fg}★{/yellow-fg} " : " ";
|
||||||
const title = truncate(item.title, 36);
|
const title = truncate(item.title, 36);
|
||||||
const initiatedLabel =
|
const initiatedLabel =
|
||||||
item.kind === "chat" && item.initiatedModel
|
item.kind === "chat" && item.initiatedModel
|
||||||
? ` | ${getProviderLabel(item.initiatedProvider)} ${truncate(item.initiatedModel, 16)}`
|
? ` | ${getProviderLabel(item.initiatedProvider)} ${truncate(item.initiatedModel, 16)}`
|
||||||
: "";
|
: "";
|
||||||
return `${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`;
|
return `${star}${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const linesChanged =
|
const linesChanged =
|
||||||
@@ -680,7 +708,7 @@ async function main() {
|
|||||||
const top = `{bold}${escapeTags(getSelectedTitle())}{/bold} {gray-fg}- Sybil TUI${modeLabel}${isSearchMode ? " • Exa Search" : ""}{/gray-fg}`;
|
const top = `{bold}${escapeTags(getSelectedTitle())}{/bold} {gray-fg}- Sybil TUI${modeLabel}${isSearchMode ? " • Exa Search" : ""}{/gray-fg}`;
|
||||||
|
|
||||||
let controls =
|
let controls =
|
||||||
"{gray-fg}Controls:{/gray-fg} [tab] focus [esc] command mode [↑/↓] highlight [enter] send/select [n] new chat [/] new search [d] delete [q] quit";
|
"{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";
|
||||||
if (!isSearchMode) {
|
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 += `\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}" : "";
|
controls += providerModelOptions.length === 0 ? " {red-fg}(no models){/red-fg}" : "";
|
||||||
@@ -842,6 +870,27 @@ async function main() {
|
|||||||
composer.readInput();
|
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) {
|
function cycleFocus(step: 1 | -1) {
|
||||||
const focused = screen.focused;
|
const focused = screen.focused;
|
||||||
const currentIndex = focusables.findIndex((node) => node === focused);
|
const currentIndex = focusables.findIndex((node) => node === focused);
|
||||||
@@ -910,10 +959,20 @@ async function main() {
|
|||||||
pendingTitleGeneration.add(chatId);
|
pendingTitleGeneration.add(chatId);
|
||||||
try {
|
try {
|
||||||
const updated = await api.suggestChatTitle({ chatId, content });
|
const updated = await api.suggestChatTitle({ chatId, content });
|
||||||
chats = chats.map((chat) => (chat.id === updated.id ? { ...chat, title: updated.title, updatedAt: updated.updatedAt } : chat));
|
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
|
||||||
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
||||||
if (selectedChat?.id === updated.id) {
|
if (selectedChat?.id === updated.id) {
|
||||||
selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt };
|
selectedChat = {
|
||||||
|
...selectedChat,
|
||||||
|
title: updated.title,
|
||||||
|
updatedAt: updated.updatedAt,
|
||||||
|
starred: updated.starred,
|
||||||
|
starredAt: updated.starredAt,
|
||||||
|
initiatedProvider: updated.initiatedProvider,
|
||||||
|
initiatedModel: updated.initiatedModel,
|
||||||
|
lastUsedProvider: updated.lastUsedProvider,
|
||||||
|
lastUsedModel: updated.lastUsedModel,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
updateUI();
|
updateUI();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -964,6 +1023,8 @@ async function main() {
|
|||||||
title: chat.title,
|
title: chat.title,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
|
starred: chat.starred,
|
||||||
|
starredAt: chat.starredAt,
|
||||||
initiatedProvider: chat.initiatedProvider,
|
initiatedProvider: chat.initiatedProvider,
|
||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
@@ -1140,6 +1201,8 @@ async function main() {
|
|||||||
query,
|
query,
|
||||||
createdAt: nowIso,
|
createdAt: nowIso,
|
||||||
updatedAt: nowIso,
|
updatedAt: nowIso,
|
||||||
|
starred: false,
|
||||||
|
starredAt: null,
|
||||||
requestId: null,
|
requestId: null,
|
||||||
latencyMs: null,
|
latencyMs: null,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -1302,6 +1365,88 @@ async function main() {
|
|||||||
await refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true });
|
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() {
|
function cycleProvider() {
|
||||||
const visibleProviders = getVisibleProviders(modelCatalog);
|
const visibleProviders = getVisibleProviders(modelCatalog);
|
||||||
const cycleProviders = visibleProviders.length ? visibleProviders : BASE_PROVIDERS;
|
const cycleProviders = visibleProviders.length ? visibleProviders : BASE_PROVIDERS;
|
||||||
@@ -1387,18 +1532,18 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["q"], () => {
|
screen.key(["q"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
screen.destroy();
|
screen.destroy();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["tab"], () => {
|
screen.key(["tab"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
cycleFocus(1);
|
cycleFocus(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["S-tab", "backtab"], () => {
|
screen.key(["S-tab", "backtab"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
cycleFocus(-1);
|
cycleFocus(-1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1415,36 +1560,50 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["n"], () => {
|
screen.key(["n"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
handleCreateChat();
|
handleCreateChat();
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["/"], () => {
|
screen.key(["/"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
handleCreateSearch();
|
handleCreateSearch();
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["d"], () => {
|
screen.key(["d"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
void runAction(async () => {
|
void runAction(async () => {
|
||||||
await handleDeleteSelection();
|
await handleDeleteSelection();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
screen.key(["s"], () => {
|
||||||
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
|
void runAction(async () => {
|
||||||
|
await handleToggleStarSelection();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
screen.key(["p"], () => {
|
screen.key(["p"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
if (getIsSearchMode() || isSending) return;
|
if (getIsSearchMode() || isSending) return;
|
||||||
cycleProvider();
|
cycleProvider();
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["m"], () => {
|
screen.key(["m"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
if (getIsSearchMode() || isSending) return;
|
if (getIsSearchMode() || isSending) return;
|
||||||
cycleModel();
|
cycleModel();
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["r"], () => {
|
screen.key(["r"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
|
void runAction(async () => {
|
||||||
|
await handleRenameSelection();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
screen.key(["C-r"], () => {
|
||||||
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
void runAction(async () => {
|
void runAction(async () => {
|
||||||
await refreshCollections({ loadSelection: true });
|
await refreshCollections({ loadSelection: true });
|
||||||
await refreshModels();
|
await refreshModels();
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export type ChatSummary = {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
starred: boolean;
|
||||||
|
starredAt: string | null;
|
||||||
initiatedProvider: Provider | null;
|
initiatedProvider: Provider | null;
|
||||||
initiatedModel: string | null;
|
initiatedModel: string | null;
|
||||||
lastUsedProvider: Provider | null;
|
lastUsedProvider: Provider | null;
|
||||||
@@ -27,6 +29,8 @@ export type SearchSummary = {
|
|||||||
query: string | null;
|
query: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
starred: boolean;
|
||||||
|
starredAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChatWorkspaceItem = ChatSummary & {
|
export type ChatWorkspaceItem = ChatSummary & {
|
||||||
@@ -66,6 +70,8 @@ export type ChatDetail = {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
starred: boolean;
|
||||||
|
starredAt: string | null;
|
||||||
initiatedProvider: Provider | null;
|
initiatedProvider: Provider | null;
|
||||||
initiatedModel: string | null;
|
initiatedModel: string | null;
|
||||||
lastUsedProvider: Provider | null;
|
lastUsedProvider: Provider | null;
|
||||||
@@ -95,6 +101,8 @@ export type SearchDetail = {
|
|||||||
query: string | null;
|
query: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
starred: boolean;
|
||||||
|
starredAt: string | null;
|
||||||
requestId: string | null;
|
requestId: string | null;
|
||||||
latencyMs: number | null;
|
latencyMs: number | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|||||||
375
web/src/App.tsx
375
web/src/App.tsx
@@ -1,20 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
import {
|
import { Check, ChevronDown, Globe2, LoaderCircle, Menu, MessageSquare, Paperclip, Pencil, Plus, Rabbit, Search, SendHorizontal, Star, Trash2, X } from "lucide-preact";
|
||||||
Check,
|
|
||||||
ChevronDown,
|
|
||||||
Globe2,
|
|
||||||
LoaderCircle,
|
|
||||||
Menu,
|
|
||||||
MessageSquare,
|
|
||||||
Paperclip,
|
|
||||||
Plus,
|
|
||||||
Rabbit,
|
|
||||||
Search,
|
|
||||||
SendHorizontal,
|
|
||||||
Settings2,
|
|
||||||
Trash2,
|
|
||||||
X,
|
|
||||||
} from "lucide-preact";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -33,14 +18,15 @@ import {
|
|||||||
attachSearchStream,
|
attachSearchStream,
|
||||||
getActiveRuns,
|
getActiveRuns,
|
||||||
getChat,
|
getChat,
|
||||||
listChatTools,
|
|
||||||
listModels,
|
listModels,
|
||||||
getSearch,
|
getSearch,
|
||||||
listWorkspaceItems,
|
listWorkspaceItems,
|
||||||
runCompletionStream,
|
runCompletionStream,
|
||||||
runSearchStream,
|
runSearchStream,
|
||||||
suggestChatTitle,
|
suggestChatTitle,
|
||||||
updateChatSettings,
|
updateChatTitle,
|
||||||
|
updateChatStar,
|
||||||
|
updateSearchStar,
|
||||||
getMessageAttachments,
|
getMessageAttachments,
|
||||||
type ChatAttachment,
|
type ChatAttachment,
|
||||||
type ActiveRunsResponse,
|
type ActiveRunsResponse,
|
||||||
@@ -48,7 +34,6 @@ import {
|
|||||||
type Provider,
|
type Provider,
|
||||||
type ChatDetail,
|
type ChatDetail,
|
||||||
type ChatSummary,
|
type ChatSummary,
|
||||||
type ChatToolInfo,
|
|
||||||
type CompletionRequestMessage,
|
type CompletionRequestMessage,
|
||||||
type Message,
|
type Message,
|
||||||
type SearchDetail,
|
type SearchDetail,
|
||||||
@@ -65,6 +50,8 @@ type SidebarItem = SidebarSelection & {
|
|||||||
title: string;
|
title: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
starred: boolean;
|
||||||
|
starredAt: string | null;
|
||||||
initiatedProvider: Provider | null;
|
initiatedProvider: Provider | null;
|
||||||
initiatedModel: string | null;
|
initiatedModel: string | null;
|
||||||
lastUsedProvider: Provider | null;
|
lastUsedProvider: Provider | null;
|
||||||
@@ -75,6 +62,9 @@ type ContextMenuState = {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
};
|
};
|
||||||
|
type RenameChatDialogState = {
|
||||||
|
chatId: string;
|
||||||
|
};
|
||||||
type PendingChatState = {
|
type PendingChatState = {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
};
|
};
|
||||||
@@ -389,30 +379,6 @@ function getProviderLabel(provider: Provider | null | undefined) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToolLabel(name: string) {
|
|
||||||
if (name === "web_search") return "Web search";
|
|
||||||
if (name === "fetch_url") return "Fetch URL";
|
|
||||||
if (name === "codex_exec") return "Codex";
|
|
||||||
if (name === "shell_exec") return "Shell";
|
|
||||||
return name
|
|
||||||
.split("_")
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultEnabledTools(availableTools: ChatToolInfo[]) {
|
|
||||||
return availableTools.map((tool) => tool.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeEnabledTools(value: unknown, availableTools: ChatToolInfo[]) {
|
|
||||||
const available = new Set(availableTools.map((tool) => tool.name));
|
|
||||||
if (!Array.isArray(value)) return getDefaultEnabledTools(availableTools);
|
|
||||||
return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))].filter((name) =>
|
|
||||||
available.has(name)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "lastUsedModel"> | Pick<ChatDetail, "lastUsedProvider" | "lastUsedModel"> | null) {
|
function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "lastUsedModel"> | Pick<ChatDetail, "lastUsedProvider" | "lastUsedModel"> | null) {
|
||||||
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
|
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
|
||||||
return {
|
return {
|
||||||
@@ -663,6 +629,8 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
|||||||
title: getChatTitle(chat),
|
title: getChatTitle(chat),
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
|
starred: chat.starred,
|
||||||
|
starredAt: chat.starredAt,
|
||||||
initiatedProvider: chat.initiatedProvider,
|
initiatedProvider: chat.initiatedProvider,
|
||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
@@ -677,6 +645,8 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
|||||||
title: getSearchTitle(search),
|
title: getSearchTitle(search),
|
||||||
updatedAt: search.updatedAt,
|
updatedAt: search.updatedAt,
|
||||||
createdAt: search.createdAt,
|
createdAt: search.createdAt,
|
||||||
|
starred: search.starred,
|
||||||
|
starredAt: search.starredAt,
|
||||||
initiatedProvider: null,
|
initiatedProvider: null,
|
||||||
initiatedModel: null,
|
initiatedModel: null,
|
||||||
lastUsedProvider: null,
|
lastUsedProvider: null,
|
||||||
@@ -728,7 +698,13 @@ function getSidebarSectionLabel(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSidebarSections(items: SidebarItem[]) {
|
function buildSidebarSections(items: SidebarItem[]) {
|
||||||
return items.reduce<Array<{ label: string; items: SidebarItem[] }>>((sections, item) => {
|
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) => {
|
||||||
const label = getSidebarSectionLabel(item.updatedAt);
|
const label = getSidebarSectionLabel(item.updatedAt);
|
||||||
const section = sections.find((candidate) => candidate.label === label);
|
const section = sections.find((candidate) => candidate.label === label);
|
||||||
if (section) {
|
if (section) {
|
||||||
@@ -737,7 +713,7 @@ function buildSidebarSections(items: SidebarItem[]) {
|
|||||||
sections.push({ label, items: [item] });
|
sections.push({ label, items: [item] });
|
||||||
}
|
}
|
||||||
return sections;
|
return sections;
|
||||||
}, []);
|
}, sections);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -772,7 +748,6 @@ export default function App() {
|
|||||||
const [isComposerDropActive, setIsComposerDropActive] = useState(false);
|
const [isComposerDropActive, setIsComposerDropActive] = useState(false);
|
||||||
const [provider, setProvider] = useState<Provider>("openai");
|
const [provider, setProvider] = useState<Provider>("openai");
|
||||||
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG);
|
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG);
|
||||||
const [availableChatTools, setAvailableChatTools] = useState<ChatToolInfo[]>([]);
|
|
||||||
const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences());
|
const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences());
|
||||||
const [model, setModel] = useState(() => {
|
const [model, setModel] = useState(() => {
|
||||||
const stored = loadStoredModelPreferences();
|
const stored = loadStoredModelPreferences();
|
||||||
@@ -795,13 +770,15 @@ export default function App() {
|
|||||||
const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false);
|
const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false);
|
||||||
const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null);
|
const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isChatSettingsOpen, setIsChatSettingsOpen] = useState(false);
|
const [renameChatDialog, setRenameChatDialog] = useState<RenameChatDialogState | null>(null);
|
||||||
const [additionalSystemPrompt, setAdditionalSystemPrompt] = useState("");
|
const [renameChatDraft, setRenameChatDraft] = useState("");
|
||||||
const [enabledTools, setEnabledTools] = useState<string[]>([]);
|
const [renameChatError, setRenameChatError] = useState<string | null>(null);
|
||||||
|
const [isRenamingChat, setIsRenamingChat] = useState(false);
|
||||||
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
|
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
|
||||||
const transcriptContainerRef = useRef<HTMLDivElement>(null);
|
const transcriptContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const renameChatInputRef = useRef<HTMLInputElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const dragDepthRef = useRef(0);
|
const dragDepthRef = useRef(0);
|
||||||
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
|
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
|
||||||
@@ -922,15 +899,17 @@ export default function App() {
|
|||||||
searchRunCountersRef.current.clear();
|
searchRunCountersRef.current.clear();
|
||||||
setComposer("");
|
setComposer("");
|
||||||
setPendingAttachments([]);
|
setPendingAttachments([]);
|
||||||
setIsChatSettingsOpen(false);
|
|
||||||
setAdditionalSystemPrompt("");
|
|
||||||
setEnabledTools([]);
|
|
||||||
setIsQuickQuestionOpen(false);
|
setIsQuickQuestionOpen(false);
|
||||||
setQuickPrompt("");
|
setQuickPrompt("");
|
||||||
setQuickSubmittedPrompt(null);
|
setQuickSubmittedPrompt(null);
|
||||||
setQuickSubmittedModelSelection(null);
|
setQuickSubmittedModelSelection(null);
|
||||||
setQuickQuestionMessages([]);
|
setQuickQuestionMessages([]);
|
||||||
setQuickQuestionError(null);
|
setQuickQuestionError(null);
|
||||||
|
setContextMenu(null);
|
||||||
|
setRenameChatDialog(null);
|
||||||
|
setRenameChatDraft("");
|
||||||
|
setRenameChatError(null);
|
||||||
|
setIsRenamingChat(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -989,21 +968,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshChatTools = async () => {
|
|
||||||
try {
|
|
||||||
const tools = await listChatTools();
|
|
||||||
setAvailableChatTools(tools);
|
|
||||||
setEnabledTools((current) => normalizeEnabledTools(current.length ? current : null, tools));
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
if (message.includes("bearer token")) {
|
|
||||||
handleAuthFailure(message);
|
|
||||||
} else {
|
|
||||||
setError(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshActiveRuns = async () => {
|
const refreshActiveRuns = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getActiveRuns();
|
const data = await getActiveRuns();
|
||||||
@@ -1056,7 +1020,7 @@ export default function App() {
|
|||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
const preferredSelection = initialRouteSelectionRef.current;
|
const preferredSelection = initialRouteSelectionRef.current;
|
||||||
initialRouteSelectionRef.current = null;
|
initialRouteSelectionRef.current = null;
|
||||||
void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshChatTools(), refreshActiveRuns()]);
|
void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels(), refreshActiveRuns()]);
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1303,6 +1267,11 @@ export default function App() {
|
|||||||
return chats.find((chat) => chat.id === selectedItem.id) ?? null;
|
return chats.find((chat) => chat.id === selectedItem.id) ?? null;
|
||||||
}, [chats, selectedItem]);
|
}, [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(() => {
|
const selectedSearchSummary = useMemo(() => {
|
||||||
if (!selectedItem || selectedItem.kind !== "search") return null;
|
if (!selectedItem || selectedItem.kind !== "search") return null;
|
||||||
return searches.find((search) => search.id === selectedItem.id) ?? null;
|
return searches.find((search) => search.id === selectedItem.id) ?? null;
|
||||||
@@ -1318,19 +1287,6 @@ export default function App() {
|
|||||||
setModel(nextSelection.model);
|
setModel(nextSelection.model);
|
||||||
}, [draftKind, selectedChat, selectedChatSummary, selectedItem]);
|
}, [draftKind, selectedChat, selectedChatSummary, selectedItem]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (draftKind === "chat") {
|
|
||||||
setAdditionalSystemPrompt("");
|
|
||||||
setEnabledTools(getDefaultEnabledTools(availableChatTools));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selectedItem?.kind !== "chat") return;
|
|
||||||
const chat = selectedChat?.id === selectedItem.id ? selectedChat : selectedChatSummary;
|
|
||||||
if (!chat) return;
|
|
||||||
setAdditionalSystemPrompt(chat.additionalSystemPrompt ?? "");
|
|
||||||
setEnabledTools(normalizeEnabledTools(chat.enabledTools, availableChatTools));
|
|
||||||
}, [availableChatTools, draftKind, selectedChat, selectedChatSummary, selectedItem]);
|
|
||||||
|
|
||||||
const selectedTitle = useMemo(() => {
|
const selectedTitle = useMemo(() => {
|
||||||
if (draftKind === "chat") return "New chat";
|
if (draftKind === "chat") return "New chat";
|
||||||
if (draftKind === "search") return "New search";
|
if (draftKind === "search") return "New search";
|
||||||
@@ -1454,16 +1410,136 @@ export default function App() {
|
|||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [filteredSidebarItems, isAuthenticated, isQuickQuestionOpen]);
|
}, [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) => {
|
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const menuWidth = 160;
|
const menuWidth = 176;
|
||||||
const menuHeight = 40;
|
const menuHeight = item.kind === "chat" ? 120 : 80;
|
||||||
const padding = 8;
|
const padding = 8;
|
||||||
const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding);
|
const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding);
|
||||||
const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding);
|
const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding);
|
||||||
setContextMenu({ item, x: Math.max(padding, x), y: Math.max(padding, y) });
|
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 () => {
|
const handleDeleteFromContextMenu = async () => {
|
||||||
if (!contextMenu || isItemRunning(contextMenu.item)) return;
|
if (!contextMenu || isItemRunning(contextMenu.item)) return;
|
||||||
const target = contextMenu.item;
|
const target = contextMenu.item;
|
||||||
@@ -1503,6 +1579,15 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [contextMenu]);
|
}, [contextMenu]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!renameChatDialog) return;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
renameChatInputRef.current?.focus();
|
||||||
|
renameChatInputRef.current?.select();
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [renameChatDialog]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isQuickQuestionOpen) return;
|
if (!isQuickQuestionOpen) return;
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -1677,6 +1762,8 @@ export default function App() {
|
|||||||
title: chat.title,
|
title: chat.title,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
|
starred: chat.starred,
|
||||||
|
starredAt: chat.starredAt,
|
||||||
initiatedProvider: chat.initiatedProvider,
|
initiatedProvider: chat.initiatedProvider,
|
||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
@@ -1897,6 +1984,8 @@ export default function App() {
|
|||||||
query,
|
query,
|
||||||
createdAt: currentSearch?.createdAt ?? nowIso,
|
createdAt: currentSearch?.createdAt ?? nowIso,
|
||||||
updatedAt: nowIso,
|
updatedAt: nowIso,
|
||||||
|
starred: currentSearch?.starred ?? false,
|
||||||
|
starredAt: currentSearch?.starredAt ?? null,
|
||||||
requestId: null,
|
requestId: null,
|
||||||
latencyMs: null,
|
latencyMs: null,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -2254,6 +2343,8 @@ export default function App() {
|
|||||||
title: chat.title,
|
title: chat.title,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
|
starred: chat.starred,
|
||||||
|
starredAt: chat.starredAt,
|
||||||
initiatedProvider: chat.initiatedProvider,
|
initiatedProvider: chat.initiatedProvider,
|
||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
@@ -2430,6 +2521,8 @@ export default function App() {
|
|||||||
title: chat.title,
|
title: chat.title,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
|
starred: chat.starred,
|
||||||
|
starredAt: chat.starredAt,
|
||||||
initiatedProvider: chat.initiatedProvider,
|
initiatedProvider: chat.initiatedProvider,
|
||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
@@ -2649,6 +2742,12 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||||
<span className="truncate text-sm font-semibold">{item.title}</span>
|
<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 ? (
|
{itemIsRunning ? (
|
||||||
<LoaderCircle
|
<LoaderCircle
|
||||||
className={cn("h-3.5 w-3.5 shrink-0 animate-spin", active ? "text-cyan-100" : "text-cyan-300/90")}
|
className={cn("h-3.5 w-3.5 shrink-0 animate-spin", active ? "text-cyan-100" : "text-cyan-300/90")}
|
||||||
@@ -2687,8 +2786,34 @@ export default function App() {
|
|||||||
<Menu className="h-4 w-4" />
|
<Menu className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div>
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
<h1 className="text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
|
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
|
||||||
@@ -2860,6 +2985,31 @@ export default function App() {
|
|||||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
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
|
<button
|
||||||
type="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"
|
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"
|
||||||
@@ -2871,6 +3021,61 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 ? (
|
{isQuickQuestionOpen ? (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ export type ChatSummary = {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
starred: boolean;
|
||||||
|
starredAt: string | null;
|
||||||
initiatedProvider: Provider | null;
|
initiatedProvider: Provider | null;
|
||||||
initiatedModel: string | null;
|
initiatedModel: string | null;
|
||||||
lastUsedProvider: Provider | null;
|
lastUsedProvider: Provider | null;
|
||||||
lastUsedModel: string | null;
|
lastUsedModel: string | null;
|
||||||
additionalSystemPrompt: string | null;
|
|
||||||
enabledTools: string[] | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SearchSummary = {
|
export type SearchSummary = {
|
||||||
@@ -17,6 +17,8 @@ export type SearchSummary = {
|
|||||||
query: string | null;
|
query: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
starred: boolean;
|
||||||
|
starredAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChatWorkspaceItem = ChatSummary & {
|
export type ChatWorkspaceItem = ChatSummary & {
|
||||||
@@ -56,12 +58,12 @@ export type ChatDetail = {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
starred: boolean;
|
||||||
|
starredAt: string | null;
|
||||||
initiatedProvider: Provider | null;
|
initiatedProvider: Provider | null;
|
||||||
initiatedModel: string | null;
|
initiatedModel: string | null;
|
||||||
lastUsedProvider: Provider | null;
|
lastUsedProvider: Provider | null;
|
||||||
lastUsedModel: string | null;
|
lastUsedModel: string | null;
|
||||||
additionalSystemPrompt: string | null;
|
|
||||||
enabledTools: string[] | null;
|
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,6 +89,8 @@ export type SearchDetail = {
|
|||||||
query: string | null;
|
query: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
starred: boolean;
|
||||||
|
starredAt: string | null;
|
||||||
requestId: string | null;
|
requestId: string | null;
|
||||||
latencyMs: number | null;
|
latencyMs: number | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -153,11 +157,6 @@ export type ModelCatalogResponse = {
|
|||||||
providers: Partial<Record<Provider, ProviderModelInfo>>;
|
providers: Partial<Record<Provider, ProviderModelInfo>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChatToolInfo = {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActiveRunsResponse = {
|
export type ActiveRunsResponse = {
|
||||||
chats: string[];
|
chats: string[];
|
||||||
searches: string[];
|
searches: string[];
|
||||||
@@ -183,8 +182,6 @@ type CreateChatRequest = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
provider?: Provider;
|
provider?: Provider;
|
||||||
model?: string;
|
model?: string;
|
||||||
additionalSystemPrompt?: string;
|
|
||||||
enabledTools?: string[];
|
|
||||||
messages?: CompletionRequestMessage[];
|
messages?: CompletionRequestMessage[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -248,11 +245,6 @@ export async function listModels() {
|
|||||||
return api<ModelCatalogResponse>("/v1/models");
|
return api<ModelCatalogResponse>("/v1/models");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listChatTools() {
|
|
||||||
const data = await api<{ tools: ChatToolInfo[] }>("/v1/chat-tools");
|
|
||||||
return data.tools;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getActiveRuns() {
|
export async function getActiveRuns() {
|
||||||
return api<ActiveRunsResponse>("/v1/active-runs");
|
return api<ActiveRunsResponse>("/v1/active-runs");
|
||||||
}
|
}
|
||||||
@@ -279,10 +271,10 @@ export async function updateChatTitle(chatId: string, title: string) {
|
|||||||
return data.chat;
|
return data.chat;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateChatSettings(chatId: string, body: { additionalSystemPrompt?: string | null; enabledTools?: string[] }) {
|
export async function updateChatStar(chatId: string, starred: boolean) {
|
||||||
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
|
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}/star`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify({ starred }),
|
||||||
});
|
});
|
||||||
return data.chat;
|
return data.chat;
|
||||||
}
|
}
|
||||||
@@ -317,6 +309,14 @@ export async function getSearch(searchId: string) {
|
|||||||
return data.search;
|
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 }) {
|
export async function createChatFromSearch(searchId: string, body?: { title?: string }) {
|
||||||
const data = await api<{ chat: ChatSummary }>(`/v1/searches/${searchId}/chat`, {
|
const data = await api<{ chat: ChatSummary }>(`/v1/searches/${searchId}/chat`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -593,9 +593,6 @@ export async function runCompletion(body: {
|
|||||||
provider: Provider;
|
provider: Provider;
|
||||||
model: string;
|
model: string;
|
||||||
messages: CompletionRequestMessage[];
|
messages: CompletionRequestMessage[];
|
||||||
additionalSystemPrompt?: string;
|
|
||||||
enabledTools?: string[];
|
|
||||||
userLocation?: string;
|
|
||||||
}) {
|
}) {
|
||||||
return api<CompletionResponse>("/v1/chat-completions", {
|
return api<CompletionResponse>("/v1/chat-completions", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -610,9 +607,6 @@ export async function runCompletionStream(
|
|||||||
provider: Provider;
|
provider: Provider;
|
||||||
model: string;
|
model: string;
|
||||||
messages: CompletionRequestMessage[];
|
messages: CompletionRequestMessage[];
|
||||||
additionalSystemPrompt?: string;
|
|
||||||
enabledTools?: string[];
|
|
||||||
userLocation?: string;
|
|
||||||
},
|
},
|
||||||
handlers: CompletionStreamHandlers,
|
handlers: CompletionStreamHandlers,
|
||||||
options?: { signal?: AbortSignal }
|
options?: { signal?: AbortSignal }
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ export default function SearchRoutePage() {
|
|||||||
query: trimmed,
|
query: trimmed,
|
||||||
createdAt: nowIso,
|
createdAt: nowIso,
|
||||||
updatedAt: nowIso,
|
updatedAt: nowIso,
|
||||||
|
starred: false,
|
||||||
|
starredAt: null,
|
||||||
requestId: null,
|
requestId: null,
|
||||||
latencyMs: null,
|
latencyMs: null,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -132,6 +134,8 @@ export default function SearchRoutePage() {
|
|||||||
query: created.query,
|
query: created.query,
|
||||||
createdAt: created.createdAt,
|
createdAt: created.createdAt,
|
||||||
updatedAt: created.updatedAt,
|
updatedAt: created.updatedAt,
|
||||||
|
starred: created.starred,
|
||||||
|
starredAt: created.starredAt,
|
||||||
}
|
}
|
||||||
: current
|
: current
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user