316 lines
13 KiB
Swift
316 lines
13 KiB
Swift
import MarkdownUI
|
|
import SwiftUI
|
|
|
|
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: 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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
if isRunning || (search?.answerText?.isEmpty == false) || (search?.answerError?.isEmpty == false) {
|
|
answerCard
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
if (isLoading || isRunning) && (search?.results.isEmpty ?? true) {
|
|
HStack(spacing: 8) {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
.tint(SybilTheme.textMuted)
|
|
Text(isRunning ? "Searching Exa…" : "Loading search…")
|
|
.font(.sybil(.footnote))
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
}
|
|
}
|
|
|
|
if !isLoading, !isRunning, let search, !search.query.orEmpty.isEmpty, search.results.isEmpty {
|
|
Text("No results found.")
|
|
.font(.sybil(.footnote))
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
}
|
|
|
|
ForEach(search?.results ?? []) { result in
|
|
SearchResultCard(result: result)
|
|
.accessibilityLabel("SearchResultCard")
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
if let error = search?.error, !error.isEmpty {
|
|
Text(error)
|
|
.font(.sybil(.footnote))
|
|
.foregroundStyle(SybilTheme.danger)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 20)
|
|
}
|
|
.scrollDismissesKeyboard(.interactively)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private var resultCountLabel: String {
|
|
let count = search?.results.count ?? 0
|
|
let latency = search?.latencyMs
|
|
if let latency {
|
|
return "\(count) result\(count == 1 ? "" : "s") • \(latency) ms"
|
|
}
|
|
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) {
|
|
Text("Answer")
|
|
.font(.sybil(.caption, weight: .semibold))
|
|
.textCase(.uppercase)
|
|
.foregroundStyle(SybilTheme.primary.opacity(0.92))
|
|
|
|
if isRunning && (search?.answerText.orEmpty.isEmpty ?? true) {
|
|
HStack(spacing: 8) {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
.tint(SybilTheme.textMuted)
|
|
Text("Generating answer…")
|
|
.font(.sybil(.footnote))
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
}
|
|
} else if let answer = search?.answerText, !answer.isEmpty {
|
|
Markdown(answer)
|
|
.tint(SybilTheme.primary)
|
|
.foregroundStyle(SybilTheme.text)
|
|
.markdownTheme(.sybilReadable)
|
|
}
|
|
|
|
if let answerError = search?.answerError, !answerError.isEmpty {
|
|
Text(answerError)
|
|
.font(.sybil(.footnote))
|
|
.foregroundStyle(SybilTheme.danger)
|
|
}
|
|
|
|
if let citations = search?.answerCitations, !citations.isEmpty {
|
|
FlowLayout(spacing: 8) {
|
|
ForEach(Array(citations.prefix(8).enumerated()), id: \.offset) { index, citation in
|
|
if let link = citation.url ?? citation.id {
|
|
Link(destination: URL(string: link) ?? URL(string: "https://example.com")!) {
|
|
HStack(spacing: 6) {
|
|
Text("[\(index + 1)]")
|
|
.font(.sybil(.caption2, weight: .semibold))
|
|
.foregroundStyle(SybilTheme.primary)
|
|
Text(citation.title.orEmpty.isEmpty ? host(for: link) : citation.title.orEmpty)
|
|
.font(.sybil(.caption))
|
|
.lineLimit(1)
|
|
.foregroundStyle(SybilTheme.text)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(
|
|
Capsule()
|
|
.fill(SybilTheme.primary.opacity(0.10))
|
|
.overlay(
|
|
Capsule()
|
|
.stroke(SybilTheme.primary.opacity(0.28), lineWidth: 1)
|
|
)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(14)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.fill(LinearGradient(colors: [SybilTheme.searchCard.opacity(0.98), SybilTheme.surface.opacity(0.78)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.stroke(SybilTheme.border.opacity(0.88), lineWidth: 1)
|
|
)
|
|
)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private func host(for raw: String) -> String {
|
|
guard let url = URL(string: raw), let host = url.host else { return raw }
|
|
if host.hasPrefix("www.") {
|
|
return String(host.dropFirst(4))
|
|
}
|
|
return host
|
|
}
|
|
}
|
|
|
|
private struct SearchResultCard: View {
|
|
var result: SearchResultItem
|
|
|
|
private var resolvedURL: URL? {
|
|
URL(string: result.url)
|
|
}
|
|
|
|
var body: some View {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 7) {
|
|
Text(host)
|
|
.font(.sybil(.caption))
|
|
.foregroundStyle(SybilTheme.accent.opacity(0.9))
|
|
.lineLimit(1)
|
|
|
|
if let resolvedURL {
|
|
Link(destination: resolvedURL) {
|
|
Text(result.title.orEmpty.orFallback(result.url))
|
|
.font(.sybil(.headline))
|
|
.foregroundStyle(SybilTheme.primary.opacity(0.96))
|
|
.lineSpacing(2)
|
|
.multilineTextAlignment(.leading)
|
|
}
|
|
.buttonStyle(.plain)
|
|
} else {
|
|
Text(result.title.orEmpty.orFallback(result.url))
|
|
.font(.sybil(.headline))
|
|
.foregroundStyle(SybilTheme.primary.opacity(0.96))
|
|
.lineSpacing(2)
|
|
}
|
|
|
|
if let date = result.publishedDate, !date.isEmpty {
|
|
Text(date + (result.author.orEmpty.isEmpty ? "" : " • \(result.author.orEmpty)"))
|
|
.font(.sybil(.caption))
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
} else if let author = result.author, !author.isEmpty {
|
|
Text(author)
|
|
.font(.sybil(.caption))
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
}
|
|
|
|
Text(result.url)
|
|
.font(.sybil(.footnote))
|
|
.foregroundStyle(SybilTheme.text.opacity(0.92))
|
|
.lineSpacing(2)
|
|
.lineLimit(3)
|
|
.textSelection(.enabled)
|
|
|
|
if let highlights = result.highlights, !highlights.isEmpty {
|
|
ForEach(Array(highlights.prefix(2).enumerated()), id: \.offset) { _, highlight in
|
|
Text("• \(highlight)")
|
|
.font(.sybil(.caption))
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
.lineSpacing(2)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(14)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.fill(LinearGradient(colors: [SybilTheme.searchCard.opacity(0.96), SybilTheme.surface.opacity(0.72)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.stroke(SybilTheme.border.opacity(0.84), lineWidth: 1)
|
|
)
|
|
)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private var host: String {
|
|
guard let resolvedURL, let host = resolvedURL.host else {
|
|
return result.url
|
|
}
|
|
if host.hasPrefix("www.") {
|
|
return String(host.dropFirst(4))
|
|
}
|
|
return host
|
|
}
|
|
}
|
|
|
|
private struct FlowLayout<Content: View>: View {
|
|
var spacing: CGFloat
|
|
@ViewBuilder var content: Content
|
|
|
|
init(spacing: CGFloat = 8, @ViewBuilder content: () -> Content) {
|
|
self.spacing = spacing
|
|
self.content = content()
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: spacing) {
|
|
content
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension Optional where Wrapped == String {
|
|
var orEmpty: String {
|
|
self ?? ""
|
|
}
|
|
}
|
|
|
|
private extension String {
|
|
func orFallback(_ fallback: String) -> String {
|
|
isEmpty ? fallback : self
|
|
}
|
|
}
|