Merge pull request 'Add chat flow for search results' (#8) from codex/chat-with-search-results into master
Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
@@ -96,6 +96,16 @@ actor SybilAPIClient {
|
||||
return response.search
|
||||
}
|
||||
|
||||
func createChatFromSearch(searchID: String, title: String? = nil) async throws -> ChatSummary {
|
||||
let response = try await request(
|
||||
"/v1/searches/\(searchID)/chat",
|
||||
method: "POST",
|
||||
body: AnyEncodable(SearchChatCreateBody(title: title)),
|
||||
responseType: ChatCreateResponse.self
|
||||
)
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func deleteSearch(searchID: String) async throws {
|
||||
_ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self)
|
||||
}
|
||||
@@ -552,3 +562,7 @@ private struct SearchCreateBody: Encodable {
|
||||
var title: String?
|
||||
var query: String?
|
||||
}
|
||||
|
||||
private struct SearchChatCreateBody: Encodable {
|
||||
var title: String?
|
||||
}
|
||||
|
||||
@@ -5,23 +5,60 @@ struct SybilSearchResultsView: View {
|
||||
var search: SearchDetail?
|
||||
var isLoading: Bool
|
||||
var isRunning: Bool
|
||||
var isStartingChat: Bool = false
|
||||
var onStartChat: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if let query = search?.query, !query.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Results for")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
Text(query)
|
||||
.font(.sybil(.title3, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Results for")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
Text(query)
|
||||
.font(.sybil(.title3, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text(resultCountLabel)
|
||||
.font(.sybil(.caption))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
Text(resultCountLabel)
|
||||
.font(.sybil(.caption))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
|
||||
if let onStartChat {
|
||||
Button {
|
||||
onStartChat()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if isStartingChat {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.text)
|
||||
} else {
|
||||
Image(systemName: "bubble.left.and.text.bubble.right")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
Text(isStartingChat ? "Starting chat..." : "Chat with results")
|
||||
.font(.sybil(.caption, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(SybilTheme.primary.opacity(0.14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(SybilTheme.primary.opacity(0.30), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canStartChat)
|
||||
.opacity(canStartChat ? 1 : 0.55)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +113,13 @@ struct SybilSearchResultsView: View {
|
||||
return "\(count) result\(count == 1 ? "" : "s")"
|
||||
}
|
||||
|
||||
private var canStartChat: Bool {
|
||||
guard let search, !isLoading, !isRunning, !isStartingChat else {
|
||||
return false
|
||||
}
|
||||
return search.answerText?.isEmpty == false || !search.results.isEmpty
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var answerCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
|
||||
@@ -87,6 +87,7 @@ final class SybilViewModel {
|
||||
var isLoadingCollections = false
|
||||
var isLoadingSelection = false
|
||||
var isSending = false
|
||||
var isCreatingSearchChat = false
|
||||
var errorMessage: String?
|
||||
|
||||
var composer = ""
|
||||
@@ -202,20 +203,20 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
var displayedMessages: [Message] {
|
||||
let canonical = selectedChat?.messages ?? []
|
||||
let canonical = displayableMessages(selectedChat?.messages ?? [])
|
||||
guard let pending = pendingChatState else {
|
||||
return canonical
|
||||
}
|
||||
|
||||
if let pendingID = pending.chatID {
|
||||
if case let .chat(selectedID) = selectedItem, selectedID == pendingID {
|
||||
return pending.messages
|
||||
return displayableMessages(pending.messages)
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
|
||||
if draftKind == .chat {
|
||||
return pending.messages
|
||||
return displayableMessages(pending.messages)
|
||||
}
|
||||
|
||||
return canonical
|
||||
@@ -473,6 +474,36 @@ final class SybilViewModel {
|
||||
isSending = false
|
||||
}
|
||||
|
||||
func startChatFromSelectedSearch() async {
|
||||
guard let search = selectedSearch, !isCreatingSearchChat, !isSending else {
|
||||
return
|
||||
}
|
||||
|
||||
isCreatingSearchChat = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let client = try client()
|
||||
let chat = try await client.createChatFromSearch(searchID: search.id)
|
||||
draftKind = nil
|
||||
pendingChatState = nil
|
||||
composer = ""
|
||||
|
||||
chats.removeAll(where: { $0.id == chat.id })
|
||||
chats.insert(chat, at: 0)
|
||||
|
||||
selectedItem = .chat(chat.id)
|
||||
selectedSearch = nil
|
||||
|
||||
await refreshCollections(preferredSelection: .chat(chat.id))
|
||||
} catch {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
SybilLog.error(SybilLog.ui, "Create chat from search failed", error: error)
|
||||
}
|
||||
|
||||
isCreatingSearchChat = false
|
||||
}
|
||||
|
||||
private func loadInitialData(using client: SybilAPIClient) async {
|
||||
isLoadingCollections = true
|
||||
errorMessage = nil
|
||||
@@ -974,6 +1005,10 @@ final class SybilViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func displayableMessages(_ messages: [Message]) -> [Message] {
|
||||
messages.filter { $0.role != .system }
|
||||
}
|
||||
|
||||
private func chatTitle(title: String?, messages: [Message]?) -> String {
|
||||
if let title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||
return title
|
||||
|
||||
@@ -32,8 +32,13 @@ struct SybilWorkspaceView: View {
|
||||
SybilSearchResultsView(
|
||||
search: viewModel.selectedSearch,
|
||||
isLoading: viewModel.isLoadingSelection,
|
||||
isRunning: viewModel.isSending
|
||||
)
|
||||
isRunning: viewModel.isSending,
|
||||
isStartingChat: viewModel.isCreatingSearchChat
|
||||
) {
|
||||
Task {
|
||||
await viewModel.startChatFromSelectedSearch()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SybilChatTranscriptView(
|
||||
messages: viewModel.displayedMessages,
|
||||
|
||||
Reference in New Issue
Block a user