8 Commits

Author SHA1 Message Date
dc9336acf9 add chat flow for search results 2026-05-02 16:48:01 -07:00
fa429dcbb3 Tiltfile: read .env 2026-05-02 16:39:31 -07:00
05989e9fae ios: line spacing 2026-05-02 16:32:32 -07:00
70b831557f web: move indicator to left column 2026-05-02 16:23:08 -07:00
cb368a4005 ios: update style 2026-05-02 16:23:00 -07:00
adb9e15b6c new look 2026-05-02 15:44:31 -07:00
dacab2f6ee ios: prepare for testflight 2026-05-02 15:21:08 -07:00
a31c053298 Adds ability to manually enter model name 2026-04-01 18:13:04 -07:00
30 changed files with 1477 additions and 398 deletions

View File

@@ -1,5 +1,8 @@
update_settings(max_parallel_updates=4) update_settings(max_parallel_updates=4)
load('ext://dotenv', 'dotenv')
dotenv() # defaults to .env in the current directory
local_resource( local_resource(
"server", "server",
cmd="npm ci --no-audit --no-fund", cmd="npm ci --no-audit --no-fund",

View File

@@ -135,6 +135,16 @@ Behavior notes:
### `GET /v1/searches/:searchId` ### `GET /v1/searches/:searchId`
- Response: `{ "search": SearchDetail }` - Response: `{ "search": SearchDetail }`
### `POST /v1/searches/:searchId/chat`
- Body: `{ "title"?: string }`
- Response: `{ "chat": ChatSummary }`
- Not found: `404 { "message": "search not found" }`
Behavior notes:
- Creates a new chat seeded with a hidden `system` message containing the search query, answer text, answer citations, and top search results.
- Clients should include existing `system` messages when sending the chat history to `/v1/chat-completions` or `/v1/chat-completions/stream`; they may hide those messages in the transcript UI.
- The default chat title is `Search: <query-or-title>`, unless `title` is supplied.
### `POST /v1/searches/:searchId/run` ### `POST /v1/searches/:searchId/run`
- Body: - Body:
```json ```json

View File

@@ -34,7 +34,8 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
- If backend contract changes (request/response shapes, SSE events, auth semantics), update docs in the same change. - If backend contract changes (request/response shapes, SSE events, auth semantics), update docs in the same change.
## Practical Notes ## Practical Notes
- Default API URL is `http://127.0.0.1:8787/api` (configurable in-app). - Default API URL is `http://127.0.0.1:8787` (configurable in-app).
- Previously saved `/api` API roots are normalized to the server root by the iOS client.
- Provider fallback models: - Provider fallback models:
- OpenAI: `gpt-4.1-mini` - OpenAI: `gpt-4.1-mini`
- Anthropic: `claude-3-5-sonnet-latest` - Anthropic: `claude-3-5-sonnet-latest`

Binary file not shown.

Binary file not shown.

View File

@@ -11,9 +11,9 @@ targets:
product: Sybil product: Sybil
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.sybil.app PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
PRODUCT_MODULE_NAME: SybilApp PRODUCT_MODULE_NAME: SybilApp
DEVELOPMENT_TEAM: "" DEVELOPMENT_TEAM: DQQH5H6GBD
CODE_SIGN_STYLE: Automatic CODE_SIGN_STYLE: Automatic
SWIFT_VERSION: 6.0 SWIFT_VERSION: 6.0
TARGETED_DEVICE_FAMILY: "1,2" TARGETED_DEVICE_FAMILY: "1,2"

View File

@@ -4,7 +4,9 @@ public struct SplitView: View {
@State private var viewModel = SybilViewModel() @State private var viewModel = SybilViewModel()
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.horizontalSizeClass) private var horizontalSizeClass
public init() {} public init() {
SybilFontRegistry.registerIfNeeded()
}
public var body: some View { public var body: some View {
ZStack { ZStack {
@@ -31,6 +33,7 @@ public struct SplitView: View {
.tint(SybilTheme.primary) .tint(SybilTheme.primary)
} }
} }
.font(.sybil(.body))
.task { .task {
await viewModel.bootstrap() await viewModel.bootstrap()
} }

View File

@@ -96,6 +96,16 @@ actor SybilAPIClient {
return response.search 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 { 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)
} }
@@ -552,3 +562,7 @@ private struct SearchCreateBody: Encodable {
var title: String? var title: String?
var query: String? var query: String?
} }
private struct SearchChatCreateBody: Encodable {
var title: String?
}

View File

@@ -15,10 +15,10 @@ struct SybilChatTranscriptView: View {
var body: some View { var body: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 24) { LazyVStack(alignment: .leading, spacing: 26) {
if isLoading && messages.isEmpty { if isLoading && messages.isEmpty {
Text("Loading messages…") Text("Loading messages…")
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
.padding(.top, 24) .padding(.top, 24)
} }
@@ -34,7 +34,7 @@ struct SybilChatTranscriptView: View {
.controlSize(.small) .controlSize(.small)
.tint(SybilTheme.textMuted) .tint(SybilTheme.textMuted)
Text("Assistant is typing…") Text("Assistant is typing…")
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
.id("typing-indicator") .id("typing-indicator")
@@ -86,11 +86,16 @@ private struct MessageBubble: View {
} }
var body: some View { var body: some View {
HStack { HStack(alignment: .top) {
if isUser {
Spacer(minLength: 44)
}
if let toolCallMetadata { if let toolCallMetadata {
ToolCallActivityChip( ToolCallActivityChip(
metadata: toolCallMetadata, metadata: toolCallMetadata,
fallbackContent: message.content fallbackContent: message.content,
createdAt: message.createdAt
) )
} else { } else {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@@ -98,9 +103,9 @@ private struct MessageBubble: View {
HStack(spacing: 8) { HStack(spacing: 8) {
ProgressView() ProgressView()
.controlSize(.small) .controlSize(.small)
.tint(SybilTheme.textMuted) .tint(SybilTheme.primary)
Text("Thinking…") Text("Thinking…")
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
.padding(.vertical, 2) .padding(.vertical, 2)
@@ -108,18 +113,16 @@ private struct MessageBubble: View {
Markdown(message.content) Markdown(message.content)
.tint(SybilTheme.primary) .tint(SybilTheme.primary)
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95)) .foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
.markdownTextStyle { .markdownTheme(.sybilReadable)
FontSize(15)
}
} }
} }
.padding(.horizontal, isUser ? 14 : 2) .padding(.horizontal, isUser ? 14 : 2)
.padding(.vertical, isUser ? 11 : 2) .padding(.vertical, isUser ? 13 : 2)
.background( .background(
Group { Group {
if isUser { if isUser {
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: 16)
.fill(SybilTheme.userBubble.opacity(0.86)) .fill(SybilTheme.userBubbleGradient)
.overlay( .overlay(
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: 16)
.stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1) .stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1)
@@ -132,24 +135,50 @@ private struct MessageBubble: View {
) )
.frame(maxWidth: isUser ? 420 : nil, alignment: isUser ? .trailing : .leading) .frame(maxWidth: isUser ? 420 : nil, alignment: isUser ? .trailing : .leading)
} }
if !isUser {
Spacer(minLength: 0)
}
} }
.frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading)
} }
} }
private struct ToolCallActivityChip: View { private struct ToolCallActivityChip: View {
var metadata: ToolCallMetadata var metadata: ToolCallMetadata
var fallbackContent: String var fallbackContent: String
var createdAt: Date
private var summary: String { private var summary: String {
if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
return text return text
} }
if (metadata.status ?? "").lowercased() == "failed",
let error = metadata.error?.trimmingCharacters(in: .whitespacesAndNewlines),
!error.isEmpty {
return "Tool failed: \(error)"
}
if let preview = metadata.resultPreview?.trimmingCharacters(in: .whitespacesAndNewlines), !preview.isEmpty {
return preview
}
if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return fallbackContent return fallbackContent
} }
return "Ran tool '\(metadata.toolName ?? "unknown_tool")'." return "Ran tool '\(metadata.toolName ?? "unknown_tool")'."
} }
private var toolLabel: String {
let raw = metadata.toolName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !raw.isEmpty else { return "Tool call" }
return raw
.replacingOccurrences(of: "_", with: " ")
.split(separator: " ")
.map { word in
word.prefix(1).uppercased() + word.dropFirst()
}
.joined(separator: " ")
}
private var iconName: String { private var iconName: String {
let name = (metadata.toolName ?? "").lowercased() let name = (metadata.toolName ?? "").lowercased()
if name.contains("search") { if name.contains("search") {
@@ -165,26 +194,60 @@ private struct ToolCallActivityChip: View {
(metadata.status ?? "").lowercased() == "failed" (metadata.status ?? "").lowercased() == "failed"
} }
var body: some View { private var detailLabel: String {
HStack(spacing: 8) { var pieces: [String] = [isFailed ? "Failed" : "Completed"]
Image(systemName: iconName) if let durationMs = metadata.durationMs, durationMs > 0 {
.font(.system(size: 12, weight: .semibold)) pieces.append("\(durationMs) ms")
.foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.primary)
Text(summary)
.font(.caption)
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.95) : SybilTheme.text.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
} }
.padding(.horizontal, 10) pieces.append(createdAt.sybilShortTimeLabel)
.padding(.vertical, 7) return pieces.joined(separator: "")
}
var body: some View {
HStack(alignment: .top, spacing: 11) {
ZStack {
RoundedRectangle(cornerRadius: 9)
.fill((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.13))
.overlay(
RoundedRectangle(cornerRadius: 9)
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
)
Image(systemName: iconName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.accent)
}
.frame(width: 30, height: 30)
VStack(alignment: .leading, spacing: 4) {
Text(summary)
.font(.sybil(.subheadline))
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.96) : SybilTheme.text.opacity(0.94))
.lineSpacing(3)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 6) {
Text(toolLabel)
.font(.sybil(.caption2, weight: .semibold))
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.84) : SybilTheme.accent.opacity(0.90))
.lineLimit(1)
Text(detailLabel)
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted)
.lineLimit(1)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background( .background(
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 12)
.fill((isFailed ? SybilTheme.danger : SybilTheme.primary).opacity(0.12)) .fill(isFailed ? SybilTheme.failedToolCallGradient : SybilTheme.toolCallGradient)
.overlay( .overlay(
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 12)
.stroke((isFailed ? SybilTheme.danger : SybilTheme.primary).opacity(0.35), lineWidth: 1) .stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
) )
) )
.frame(maxWidth: 460, alignment: .leading) .frame(maxWidth: 520, alignment: .leading)
} }
} }

View File

@@ -8,23 +8,32 @@ struct SybilConnectionView: View {
@Bindable var settings = viewModel.settings @Bindable var settings = viewModel.settings
VStack(spacing: 20) { VStack(spacing: 20) {
HStack {
SybilWordmark(size: 34)
Spacer()
}
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
Image(systemName: "shield.lefthalf.filled") Image(systemName: "shield.lefthalf.filled")
.font(.title3.weight(.semibold)) .font(.system(size: 20, weight: .semibold))
.foregroundStyle(SybilTheme.primary) .foregroundStyle(SybilTheme.accent)
.frame(width: 34, height: 34) .frame(width: 34, height: 34)
.background( .background(
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
.fill(SybilTheme.primary.opacity(0.18)) .fill(SybilTheme.accent.opacity(0.12))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(SybilTheme.accent.opacity(0.28), lineWidth: 1)
)
) )
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Connect to Sybil") Text("Connect to Sybil")
.font(.title3.weight(.semibold)) .font(.sybil(.title3, weight: .semibold))
.foregroundStyle(SybilTheme.text) .foregroundStyle(SybilTheme.text)
Text("Point the app at your backend and sign in with ADMIN_TOKEN if token mode is enabled.") Text("Point the app at your backend and sign in with ADMIN_TOKEN if token mode is enabled.")
.font(.callout) .font(.sybil(.callout))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
@@ -32,25 +41,25 @@ struct SybilConnectionView: View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text("API URL") Text("API URL")
.font(.caption.weight(.semibold)) .font(.sybil(.caption, weight: .semibold))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
TextField("http://127.0.0.1:8787/api", text: $settings.apiBaseURL) TextField("http://127.0.0.1:8787", text: $settings.apiBaseURL)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.keyboardType(.URL) .keyboardType(.URL)
.padding(12) .padding(12)
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.surface) .fill(SybilTheme.surface.opacity(0.78))
) )
.overlay( .overlay(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.stroke(SybilTheme.border, lineWidth: 1) .stroke(SybilTheme.border.opacity(0.88), lineWidth: 1)
) )
Text("Admin Token") Text("Admin Token")
.font(.caption.weight(.semibold)) .font(.sybil(.caption, weight: .semibold))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
SecureField("ADMIN_TOKEN (optional in open mode)", text: $settings.adminToken) SecureField("ADMIN_TOKEN (optional in open mode)", text: $settings.adminToken)
@@ -59,11 +68,11 @@ struct SybilConnectionView: View {
.padding(12) .padding(12)
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.surface) .fill(SybilTheme.surface.opacity(0.78))
) )
.overlay( .overlay(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.stroke(SybilTheme.border, lineWidth: 1) .stroke(SybilTheme.border.opacity(0.88), lineWidth: 1)
) )
} }
@@ -94,7 +103,7 @@ struct SybilConnectionView: View {
if let authError = viewModel.authError { if let authError = viewModel.authError {
Text(authError) Text(authError)
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger) .foregroundStyle(SybilTheme.danger)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
@@ -103,10 +112,10 @@ struct SybilConnectionView: View {
.frame(maxWidth: 520) .frame(maxWidth: 520)
.background( .background(
RoundedRectangle(cornerRadius: 20) RoundedRectangle(cornerRadius: 20)
.fill(SybilTheme.card) .fill(SybilTheme.panelGradient)
.overlay( .overlay(
RoundedRectangle(cornerRadius: 20) RoundedRectangle(cornerRadius: 20)
.stroke(SybilTheme.border, lineWidth: 1) .stroke(SybilTheme.border.opacity(0.9), lineWidth: 1)
) )
) )
} }

View File

@@ -0,0 +1,164 @@
import MarkdownUI
import SwiftUI
@MainActor
extension Theme {
static let sybilReadable: Theme = {
SybilFontRegistry.registerIfNeeded()
return Theme()
.text {
FontFamily(.custom("Inter"))
FontSize(15)
}
.code {
FontFamilyVariant(.monospaced)
FontSize(.em(0.88))
BackgroundColor(SybilTheme.surfaceStrong.opacity(0.78))
}
.strong {
FontWeight(.semibold)
}
.link {
ForegroundColor(SybilTheme.accent)
}
.heading1 { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.18))
.markdownMargin(top: .em(1.1), bottom: .em(0.5))
.markdownTextStyle {
FontWeight(.semibold)
FontSize(.em(1.45))
}
}
.heading2 { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.18))
.markdownMargin(top: .em(1.0), bottom: .em(0.45))
.markdownTextStyle {
FontWeight(.semibold)
FontSize(.em(1.25))
}
}
.heading3 { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.18))
.markdownMargin(top: .em(0.9), bottom: .em(0.4))
.markdownTextStyle {
FontWeight(.semibold)
FontSize(.em(1.12))
}
}
.heading4 { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.18))
.markdownMargin(top: .em(0.85), bottom: .em(0.35))
.markdownTextStyle {
FontWeight(.semibold)
}
}
.heading5 { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.18))
.markdownMargin(top: .em(0.8), bottom: .em(0.35))
.markdownTextStyle {
FontWeight(.semibold)
FontSize(.em(0.92))
}
}
.heading6 { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.18))
.markdownMargin(top: .em(0.8), bottom: .em(0.35))
.markdownTextStyle {
FontWeight(.semibold)
FontSize(.em(0.86))
}
}
.paragraph { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.36))
.markdownMargin(top: .zero, bottom: .em(0.82))
}
.blockquote { configuration in
HStack(alignment: .top, spacing: 10) {
RoundedRectangle(cornerRadius: 2)
.fill(SybilTheme.primary.opacity(0.55))
.frame(width: 3)
configuration.label
.markdownTextStyle {
ForegroundColor(SybilTheme.textMuted)
}
}
.fixedSize(horizontal: false, vertical: true)
.markdownMargin(top: .em(0.25), bottom: .em(0.9))
}
.codeBlock { configuration in
ScrollView(.horizontal) {
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.28))
.markdownTextStyle {
FontFamilyVariant(.monospaced)
FontSize(.em(0.88))
}
.padding(12)
}
.background(
RoundedRectangle(cornerRadius: 10)
.fill(SybilTheme.surfaceStrong.opacity(0.82))
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
)
.markdownMargin(top: .em(0.2), bottom: .em(1))
}
.list { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.markdownMargin(top: .em(0.08), bottom: .em(0.78))
}
.listItem { configuration in
configuration.label
.markdownMargin(top: .em(0.28))
}
.table { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.markdownTableBorderStyle(.init(color: SybilTheme.border.opacity(0.85)))
.markdownTableBackgroundStyle(
.alternatingRows(
SybilTheme.surface.opacity(0.72),
SybilTheme.surfaceStrong.opacity(0.62)
)
)
.markdownMargin(top: .em(0.2), bottom: .em(1))
}
.tableCell { configuration in
configuration.label
.markdownTextStyle {
if configuration.row == 0 {
FontWeight(.semibold)
}
BackgroundColor(nil)
}
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.30))
.padding(.vertical, 7)
.padding(.horizontal, 11)
}
.thematicBreak {
Divider()
.overlay(SybilTheme.border.opacity(0.82))
.markdownMargin(top: .em(1.2), bottom: .em(1.2))
}
}()
}

View File

@@ -58,7 +58,10 @@ public struct Message: Codable, Identifiable, Hashable, Sendable {
toolCallId: object["toolCallId"]?.stringValue, toolCallId: object["toolCallId"]?.stringValue,
toolName: object["toolName"]?.stringValue ?? name, toolName: object["toolName"]?.stringValue ?? name,
status: object["status"]?.stringValue, status: object["status"]?.stringValue,
summary: object["summary"]?.stringValue summary: object["summary"]?.stringValue,
durationMs: object["durationMs"]?.numberValue.map(Int.init),
error: object["error"]?.stringValue,
resultPreview: object["resultPreview"]?.stringValue
) )
} }
@@ -72,6 +75,9 @@ public struct ToolCallMetadata: Hashable, Sendable {
public var toolName: String? public var toolName: String?
public var status: String? public var status: String?
public var summary: String? public var summary: String?
public var durationMs: Int?
public var error: String?
public var resultPreview: String?
} }
public enum JSONValue: Codable, Hashable, Sendable { public enum JSONValue: Codable, Hashable, Sendable {
@@ -136,6 +142,13 @@ public enum JSONValue: Codable, Hashable, Sendable {
return nil return nil
} }
public var numberValue: Double? {
if case let .number(value) = self {
return value
}
return nil
}
public var objectValue: [String: JSONValue]? { public var objectValue: [String: JSONValue]? {
if case let .object(value) = self { if case let .object(value) = self {
return value return value
@@ -412,6 +425,14 @@ extension DateFormatter {
formatter.timeStyle = .short formatter.timeStyle = .short
return formatter return formatter
}() }()
static let sybilShortTime: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = .autoupdatingCurrent
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter
}()
} }
extension Date { extension Date {
@@ -421,4 +442,8 @@ extension Date {
formatter.unitsStyle = .abbreviated formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: self, relativeTo: Date()) return formatter.localizedString(for: self, relativeTo: Date())
} }
var sybilShortTimeLabel: String {
DateFormatter.sybilShortTime.string(from: self)
}
} }

View File

@@ -27,8 +27,13 @@ struct SybilPhoneShellView: View {
var body: some View { var body: some View {
NavigationStack(path: $path) { NavigationStack(path: $path) {
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path) SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
.navigationTitle("Sybil") .navigationTitle("")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
SybilWordmark(size: 19)
}
}
.navigationDestination(for: PhoneRoute.self) { route in .navigationDestination(for: PhoneRoute.self) { route in
SybilPhoneDestinationView(viewModel: viewModel, route: route) SybilPhoneDestinationView(viewModel: viewModel, route: route)
} }
@@ -43,14 +48,9 @@ private struct SybilPhoneSidebarRoot: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
buttonRow
Divider()
.overlay(SybilTheme.border)
if let errorMessage = viewModel.errorMessage { if let errorMessage = viewModel.errorMessage {
Text(errorMessage) Text(errorMessage)
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger) .foregroundStyle(SybilTheme.danger)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -65,7 +65,7 @@ private struct SybilPhoneSidebarRoot: View {
ProgressView() ProgressView()
.tint(SybilTheme.primary) .tint(SybilTheme.primary)
Text("Loading conversations…") Text("Loading conversations…")
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
@@ -73,10 +73,10 @@ private struct SybilPhoneSidebarRoot: View {
} else if viewModel.sidebarItems.isEmpty { } else if viewModel.sidebarItems.isEmpty {
VStack(spacing: 10) { VStack(spacing: 10) {
Image(systemName: "message.badge") Image(systemName: "message.badge")
.font(.title3) .font(.system(size: 20, weight: .medium))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.") Text("Start a chat or run your first search.")
.font(.footnote) .font(.sybil(.footnote))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
@@ -104,48 +104,67 @@ private struct SybilPhoneSidebarRoot: View {
.padding(10) .padding(10)
} }
} }
}
.background(SybilTheme.panelGradient)
.safeAreaInset(edge: .bottom, spacing: 0) {
bottomToolbar
}
}
private var bottomToolbar: some View {
VStack(spacing: 0) {
Divider() Divider()
.overlay(SybilTheme.border) .overlay(SybilTheme.border)
NavigationLink(value: PhoneRoute.settings) { HStack(spacing: 12) {
Label("Settings", systemImage: "gearshape") toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
.font(.subheadline.weight(.medium)) path = [.settings]
.foregroundStyle(SybilTheme.text) }
.padding(.horizontal, 12)
.padding(.vertical, 10) Spacer()
.frame(maxWidth: .infinity, alignment: .leading)
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
viewModel.startNewSearch()
path = [.draftSearch]
}
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
viewModel.startNewChat()
path = [.draftChat]
}
} }
.buttonStyle(.plain) .padding(.horizontal, 18)
.padding(10) .padding(.vertical, 10)
.background(SybilTheme.panelGradient)
} }
.background(SybilTheme.surfaceStrong.opacity(0.84))
} }
private var buttonRow: some View { private func toolbarIconButton(
HStack(spacing: 10) { systemImage: String,
Button { accessibilityLabel: String,
viewModel.startNewChat() isPrimary: Bool = false,
path.append(.draftChat) action: @escaping () -> Void
} label: { ) -> some View {
Label("New chat", systemImage: "plus") Button(action: action) {
.frame(maxWidth: .infinity) Image(systemName: systemImage)
} .font(.system(size: 18, weight: .semibold))
.buttonStyle(.borderedProminent) .foregroundStyle(isPrimary ? SybilTheme.text : SybilTheme.textMuted)
.tint(SybilTheme.primarySoft) .frame(width: 42, height: 42)
.background(
Button { Circle()
viewModel.startNewSearch() .fill(
path.append(.draftSearch) isPrimary
} label: { ? AnyShapeStyle(SybilTheme.primaryGradient)
Label("New search", systemImage: "magnifyingglass") : AnyShapeStyle(SybilTheme.surface.opacity(0.78))
.frame(maxWidth: .infinity) )
} )
.buttonStyle(.bordered) .overlay(
.tint(SybilTheme.textMuted) Circle()
.stroke(isPrimary ? SybilTheme.primary.opacity(0.42) : SybilTheme.border.opacity(0.76), lineWidth: 1)
)
} }
.padding(.horizontal, 12) .buttonStyle(.plain)
.padding(.vertical, 12) .accessibilityLabel(accessibilityLabel)
} }
} }
@@ -156,24 +175,36 @@ private struct SybilPhoneSidebarRow: View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: item.kind == .chat ? "message" : "globe") Image(systemName: item.kind == .chat ? "message" : "globe")
.font(.caption.weight(.semibold)) .font(.system(size: 12, weight: .semibold))
.foregroundStyle(SybilTheme.textMuted)
.frame(width: 22, height: 22)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(SybilTheme.surface.opacity(0.72))
.overlay(
RoundedRectangle(cornerRadius: 7)
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
)
)
Text(item.title) Text(item.title)
.font(.subheadline.weight(.medium)) .font(.sybil(.subheadline, weight: .semibold))
.lineLimit(1) .lineLimit(1)
} }
HStack(spacing: 8) { HStack(spacing: 8) {
Text(item.updatedAt.sybilRelativeLabel) Text(item.updatedAt.sybilRelativeLabel)
.font(.caption2) .font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
if let initiated = item.initiatedLabel { if let initiated = item.initiatedLabel {
Spacer(minLength: 0) Spacer(minLength: 0)
Text(initiated) Text(initiated)
.font(.caption2) .font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted.opacity(0.88)) .foregroundStyle(SybilTheme.textMuted.opacity(0.88))
.lineLimit(1) .lineLimit(1)
.multilineTextAlignment(.trailing)
.frame(maxWidth: .infinity, alignment: .trailing)
} }
} }
} }
@@ -183,7 +214,7 @@ private struct SybilPhoneSidebarRow: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.surface.opacity(0.55)) .fill(LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
) )
.overlay( .overlay(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)

View File

@@ -5,23 +5,60 @@ struct SybilSearchResultsView: View {
var search: SearchDetail? var search: SearchDetail?
var isLoading: Bool var isLoading: Bool
var isRunning: Bool var isRunning: Bool
var isStartingChat: Bool = false
var onStartChat: (() -> Void)? = nil
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
if let query = search?.query, !query.isEmpty { if let query = search?.query, !query.isEmpty {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 12) {
Text("Results for") VStack(alignment: .leading, spacing: 4) {
.font(.footnote) Text("Results for")
.foregroundStyle(SybilTheme.textMuted) .font(.sybil(.footnote))
Text(query) .foregroundStyle(SybilTheme.textMuted)
.font(.title3.weight(.semibold)) Text(query)
.foregroundStyle(SybilTheme.text) .font(.sybil(.title3, weight: .semibold))
.fixedSize(horizontal: false, vertical: true) .foregroundStyle(SybilTheme.text)
.fixedSize(horizontal: false, vertical: true)
Text(resultCountLabel) Text(resultCountLabel)
.font(.caption) .font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted) .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)
}
} }
} }
@@ -36,14 +73,14 @@ struct SybilSearchResultsView: View {
.controlSize(.small) .controlSize(.small)
.tint(SybilTheme.textMuted) .tint(SybilTheme.textMuted)
Text(isRunning ? "Searching Exa…" : "Loading search…") Text(isRunning ? "Searching Exa…" : "Loading search…")
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
} }
if !isLoading, !isRunning, let search, !search.query.orEmpty.isEmpty, search.results.isEmpty { if !isLoading, !isRunning, let search, !search.query.orEmpty.isEmpty, search.results.isEmpty {
Text("No results found.") Text("No results found.")
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
@@ -55,7 +92,7 @@ struct SybilSearchResultsView: View {
if let error = search?.error, !error.isEmpty { if let error = search?.error, !error.isEmpty {
Text(error) Text(error)
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger) .foregroundStyle(SybilTheme.danger)
} }
} }
@@ -76,13 +113,20 @@ struct SybilSearchResultsView: View {
return "\(count) result\(count == 1 ? "" : "s")" 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 @ViewBuilder
private var answerCard: some View { private var answerCard: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text("Answer") Text("Answer")
.font(.caption.weight(.semibold)) .font(.sybil(.caption, weight: .semibold))
.textCase(.uppercase) .textCase(.uppercase)
.foregroundStyle(SybilTheme.primary) .foregroundStyle(SybilTheme.primary.opacity(0.92))
if isRunning && (search?.answerText.orEmpty.isEmpty ?? true) { if isRunning && (search?.answerText.orEmpty.isEmpty ?? true) {
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -90,21 +134,19 @@ struct SybilSearchResultsView: View {
.controlSize(.small) .controlSize(.small)
.tint(SybilTheme.textMuted) .tint(SybilTheme.textMuted)
Text("Generating answer…") Text("Generating answer…")
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
} else if let answer = search?.answerText, !answer.isEmpty { } else if let answer = search?.answerText, !answer.isEmpty {
Markdown(answer) Markdown(answer)
.tint(SybilTheme.primary) .tint(SybilTheme.primary)
.foregroundStyle(SybilTheme.text) .foregroundStyle(SybilTheme.text)
.markdownTextStyle { .markdownTheme(.sybilReadable)
FontSize(15)
}
} }
if let answerError = search?.answerError, !answerError.isEmpty { if let answerError = search?.answerError, !answerError.isEmpty {
Text(answerError) Text(answerError)
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger) .foregroundStyle(SybilTheme.danger)
} }
@@ -115,23 +157,23 @@ struct SybilSearchResultsView: View {
Link(destination: URL(string: link) ?? URL(string: "https://example.com")!) { Link(destination: URL(string: link) ?? URL(string: "https://example.com")!) {
HStack(spacing: 6) { HStack(spacing: 6) {
Text("[\(index + 1)]") Text("[\(index + 1)]")
.font(.caption2.weight(.semibold)) .font(.sybil(.caption2, weight: .semibold))
.foregroundStyle(SybilTheme.primary) .foregroundStyle(SybilTheme.primary)
Text(citation.title.orEmpty.isEmpty ? host(for: link) : citation.title.orEmpty) Text(citation.title.orEmpty.isEmpty ? host(for: link) : citation.title.orEmpty)
.font(.caption) .font(.sybil(.caption))
.lineLimit(1) .lineLimit(1)
.foregroundStyle(SybilTheme.text) .foregroundStyle(SybilTheme.text)
} }
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 6) .padding(.vertical, 6)
.background( .background(
Capsule() Capsule()
.fill(SybilTheme.surface) .fill(SybilTheme.primary.opacity(0.10))
.overlay( .overlay(
Capsule() Capsule()
.stroke(SybilTheme.border, lineWidth: 1) .stroke(SybilTheme.primary.opacity(0.28), lineWidth: 1)
) )
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@@ -142,10 +184,10 @@ struct SybilSearchResultsView: View {
.padding(14) .padding(14)
.background( .background(
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: 16)
.fill(SybilTheme.searchCard) .fill(LinearGradient(colors: [SybilTheme.searchCard.opacity(0.98), SybilTheme.surface.opacity(0.78)], startPoint: .topLeading, endPoint: .bottomTrailing))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: 16)
.stroke(SybilTheme.border, lineWidth: 1) .stroke(SybilTheme.border.opacity(0.88), lineWidth: 1)
) )
) )
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -171,45 +213,49 @@ private struct SearchResultCard: View {
HStack { HStack {
VStack(alignment: .leading, spacing: 7) { VStack(alignment: .leading, spacing: 7) {
Text(host) Text(host)
.font(.caption) .font(.sybil(.caption))
.foregroundStyle(SybilTheme.primary.opacity(0.9)) .foregroundStyle(SybilTheme.accent.opacity(0.9))
.lineLimit(1) .lineLimit(1)
if let resolvedURL { if let resolvedURL {
Link(destination: resolvedURL) { Link(destination: resolvedURL) {
Text(result.title.orEmpty.orFallback(result.url)) Text(result.title.orEmpty.orFallback(result.url))
.font(.headline) .font(.sybil(.headline))
.foregroundStyle(SybilTheme.primary) .foregroundStyle(SybilTheme.primary.opacity(0.96))
.lineSpacing(2)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} else { } else {
Text(result.title.orEmpty.orFallback(result.url)) Text(result.title.orEmpty.orFallback(result.url))
.font(.headline) .font(.sybil(.headline))
.foregroundStyle(SybilTheme.primary) .foregroundStyle(SybilTheme.primary.opacity(0.96))
.lineSpacing(2)
} }
if let date = result.publishedDate, !date.isEmpty { if let date = result.publishedDate, !date.isEmpty {
Text(date + (result.author.orEmpty.isEmpty ? "" : "\(result.author.orEmpty)")) Text(date + (result.author.orEmpty.isEmpty ? "" : "\(result.author.orEmpty)"))
.font(.caption) .font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} else if let author = result.author, !author.isEmpty { } else if let author = result.author, !author.isEmpty {
Text(author) Text(author)
.font(.caption) .font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
Text(result.url) Text(result.url)
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.text.opacity(0.92)) .foregroundStyle(SybilTheme.text.opacity(0.92))
.lineSpacing(2)
.lineLimit(3) .lineLimit(3)
.textSelection(.enabled) .textSelection(.enabled)
if let highlights = result.highlights, !highlights.isEmpty { if let highlights = result.highlights, !highlights.isEmpty {
ForEach(Array(highlights.prefix(2).enumerated()), id: \.offset) { _, highlight in ForEach(Array(highlights.prefix(2).enumerated()), id: \.offset) { _, highlight in
Text("\(highlight)") Text("\(highlight)")
.font(.caption) .font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
.lineSpacing(2)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
} }
@@ -220,10 +266,10 @@ private struct SearchResultCard: View {
.padding(14) .padding(14)
.background( .background(
RoundedRectangle(cornerRadius: 14) RoundedRectangle(cornerRadius: 14)
.fill(SybilTheme.searchCard.opacity(0.95)) .fill(LinearGradient(colors: [SybilTheme.searchCard.opacity(0.96), SybilTheme.surface.opacity(0.72)], startPoint: .topLeading, endPoint: .bottomTrailing))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 14) RoundedRectangle(cornerRadius: 14)
.stroke(SybilTheme.border, lineWidth: 1) .stroke(SybilTheme.border.opacity(0.84), lineWidth: 1)
) )
) )
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)

View File

@@ -24,7 +24,7 @@ final class SybilSettingsStore {
self.defaults = defaults self.defaults = defaults
let storedBaseURL = defaults.string(forKey: Keys.apiBaseURL)?.trimmingCharacters(in: .whitespacesAndNewlines) let storedBaseURL = defaults.string(forKey: Keys.apiBaseURL)?.trimmingCharacters(in: .whitespacesAndNewlines)
let fallbackBaseURL = "http://127.0.0.1:8787/api" let fallbackBaseURL = "http://127.0.0.1:8787"
self.apiBaseURL = storedBaseURL?.isEmpty == false ? storedBaseURL! : fallbackBaseURL self.apiBaseURL = storedBaseURL?.isEmpty == false ? storedBaseURL! : fallbackBaseURL
self.adminToken = defaults.string(forKey: Keys.adminToken) ?? "" self.adminToken = defaults.string(forKey: Keys.adminToken) ?? ""
@@ -64,10 +64,19 @@ final class SybilSettingsStore {
var raw = apiBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) var raw = apiBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard !raw.isEmpty else { return nil } guard !raw.isEmpty else { return nil }
if raw.hasSuffix("/") { while raw.hasSuffix("/") {
raw.removeLast() raw.removeLast()
} }
return URL(string: raw) guard var components = URLComponents(string: raw) else {
return nil
}
let path = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
if path.lowercased() == "api" {
components.path = ""
}
return components.url
} }
} }

View File

@@ -11,19 +11,19 @@ struct SybilSettingsView: View {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("Connection") Text("Connection")
.font(.title3.weight(.semibold)) .font(.sybil(.title3, weight: .semibold))
.foregroundStyle(SybilTheme.text) .foregroundStyle(SybilTheme.text)
Text("Use the same API root as the web client. Example: `http://127.0.0.1:8787/api`") Text("Use the API server root. Example: `http://127.0.0.1:8787`")
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("API URL") Text("API URL")
.font(.caption.weight(.semibold)) .font(.sybil(.caption, weight: .semibold))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
TextField("http://127.0.0.1:8787/api", text: $settings.apiBaseURL) TextField("http://127.0.0.1:8787", text: $settings.apiBaseURL)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.keyboardType(.URL) .keyboardType(.URL)
@@ -38,7 +38,7 @@ struct SybilSettingsView: View {
) )
Text("Admin Token") Text("Admin Token")
.font(.caption.weight(.semibold)) .font(.sybil(.caption, weight: .semibold))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
SecureField("ADMIN_TOKEN (optional in open mode)", text: $settings.adminToken) SecureField("ADMIN_TOKEN (optional in open mode)", text: $settings.adminToken)
@@ -82,19 +82,19 @@ struct SybilSettingsView: View {
if let mode = viewModel.authMode { if let mode = viewModel.authMode {
Label(mode == "open" ? "Server is in open mode" : "Server requires token", systemImage: "dot.radiowaves.left.and.right") Label(mode == "open" ? "Server is in open mode" : "Server requires token", systemImage: "dot.radiowaves.left.and.right")
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
if let authError = viewModel.authError { if let authError = viewModel.authError {
Text(authError) Text(authError)
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger) .foregroundStyle(SybilTheme.danger)
} }
if let runtimeError = viewModel.errorMessage { if let runtimeError = viewModel.errorMessage {
Text(runtimeError) Text(runtimeError)
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger) .foregroundStyle(SybilTheme.danger)
} }
} }

View File

@@ -17,27 +17,31 @@ struct SybilSidebarView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack(spacing: 10) { VStack(alignment: .leading, spacing: 14) {
Button { SybilWordmark(size: 31)
viewModel.startNewChat()
} label: {
Label("New chat", systemImage: "plus")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(viewModel.draftKind == .chat ? SybilTheme.primary : SybilTheme.primarySoft)
Button { VStack(spacing: 10) {
viewModel.startNewSearch() sidebarActionButton(
} label: { title: "New chat",
Label("New search", systemImage: "magnifyingglass") systemImage: "plus",
.frame(maxWidth: .infinity) isPrimary: true,
isActive: viewModel.draftKind == .chat
) {
viewModel.startNewChat()
}
sidebarActionButton(
title: "New search",
systemImage: "magnifyingglass",
isPrimary: false,
isActive: viewModel.draftKind == .search
) {
viewModel.startNewSearch()
}
} }
.buttonStyle(.bordered)
.tint(viewModel.draftKind == .search ? SybilTheme.primary : SybilTheme.textMuted)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.top, 12) .padding(.top, 18)
.padding(.bottom, 10) .padding(.bottom, 10)
Divider() Divider()
@@ -45,7 +49,7 @@ struct SybilSidebarView: View {
if let errorMessage = viewModel.errorMessage { if let errorMessage = viewModel.errorMessage {
Text(errorMessage) Text(errorMessage)
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger) .foregroundStyle(SybilTheme.danger)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -60,7 +64,7 @@ struct SybilSidebarView: View {
ProgressView() ProgressView()
.tint(SybilTheme.primary) .tint(SybilTheme.primary)
Text("Loading conversations…") Text("Loading conversations…")
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
@@ -68,10 +72,10 @@ struct SybilSidebarView: View {
} else if viewModel.sidebarItems.isEmpty { } else if viewModel.sidebarItems.isEmpty {
VStack(spacing: 10) { VStack(spacing: 10) {
Image(systemName: "message.badge") Image(systemName: "message.badge")
.font(.title3) .font(.system(size: 20, weight: .medium))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.") Text("Start a chat or run your first search.")
.font(.footnote) .font(.sybil(.footnote))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
@@ -87,24 +91,36 @@ struct SybilSidebarView: View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: iconName(for: item)) Image(systemName: iconName(for: item))
.font(.caption.weight(.semibold)) .font(.system(size: 12, weight: .semibold))
.foregroundStyle(isSelected(item) ? SybilTheme.accent : SybilTheme.textMuted)
.frame(width: 22, height: 22)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(isSelected(item) ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
.overlay(
RoundedRectangle(cornerRadius: 7)
.stroke(isSelected(item) ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1)
)
)
Text(item.title) Text(item.title)
.font(.subheadline.weight(.medium)) .font(.sybil(.subheadline, weight: .semibold))
.lineLimit(1) .lineLimit(1)
} }
HStack(spacing: 8) { HStack(spacing: 8) {
Text(item.updatedAt.sybilRelativeLabel) Text(item.updatedAt.sybilRelativeLabel)
.font(.caption2) .font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
if let initiated = item.initiatedLabel { if let initiated = item.initiatedLabel {
Spacer(minLength: 0) Spacer(minLength: 0)
Text(initiated) Text(initiated)
.font(.caption2) .font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted.opacity(0.88)) .foregroundStyle(SybilTheme.textMuted.opacity(0.88))
.lineLimit(1) .lineLimit(1)
.multilineTextAlignment(.trailing)
.frame(maxWidth: .infinity, alignment: .trailing)
} }
} }
} }
@@ -114,7 +130,7 @@ struct SybilSidebarView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(isSelected(item) ? SybilTheme.primary.opacity(0.28) : SybilTheme.surface.opacity(0.4)) .fill(isSelected(item) ? SybilTheme.selectedRowGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
) )
.overlay( .overlay(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
@@ -144,7 +160,7 @@ struct SybilSidebarView: View {
viewModel.openSettings() viewModel.openSettings()
} label: { } label: {
Label("Settings", systemImage: "gearshape") Label("Settings", systemImage: "gearshape")
.font(.subheadline.weight(.medium)) .font(.sybil(.subheadline, weight: .medium))
.foregroundStyle(SybilTheme.text) .foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -157,6 +173,32 @@ struct SybilSidebarView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(10) .padding(10)
} }
.background(SybilTheme.surfaceStrong.opacity(0.84)) .background(SybilTheme.panelGradient)
}
private func sidebarActionButton(
title: String,
systemImage: String,
isPrimary: Bool,
isActive: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Label(title, systemImage: systemImage)
.font(.sybil(.subheadline, weight: .semibold))
.foregroundStyle(isPrimary ? SybilTheme.text : SybilTheme.text.opacity(0.92))
.padding(.horizontal, 13)
.padding(.vertical, 11)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 11)
.fill(isPrimary ? SybilTheme.primaryGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.86), SybilTheme.surface.opacity(0.62)], startPoint: .topLeading, endPoint: .bottomTrailing))
)
.overlay(
RoundedRectangle(cornerRadius: 11)
.stroke(isActive ? SybilTheme.primary.opacity(0.70) : SybilTheme.border.opacity(isPrimary ? 0.28 : 0.72), lineWidth: 1)
)
}
.buttonStyle(.plain)
} }
} }

View File

@@ -1,28 +1,194 @@
import CoreText
import Foundation
import SwiftUI import SwiftUI
enum SybilFontRegistry {
static func registerIfNeeded() {
_ = registeredFonts
}
private static let registeredFonts: Void = {
for fontName in ["Inter", "Orbitron"] {
guard let url = Bundle.main.url(forResource: fontName, withExtension: "ttf", subdirectory: "Fonts") ??
Bundle.main.url(forResource: fontName, withExtension: "ttf")
else {
continue
}
CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil)
}
}()
}
extension Font {
static func sybil(_ textStyle: Font.TextStyle, weight: Font.Weight = .regular) -> Font {
SybilFontRegistry.registerIfNeeded()
return .custom("Inter", size: Self.sybilPointSize(for: textStyle), relativeTo: textStyle)
.weight(weight)
}
static func sybil(size: CGFloat, weight: Font.Weight = .regular) -> Font {
SybilFontRegistry.registerIfNeeded()
return .custom("Inter", size: size)
.weight(weight)
}
private static func sybilPointSize(for textStyle: Font.TextStyle) -> CGFloat {
switch textStyle {
case .largeTitle:
return 34
case .title:
return 28
case .title2:
return 22
case .title3:
return 20
case .headline:
return 17
case .subheadline:
return 15
case .callout:
return 16
case .caption:
return 12
case .caption2:
return 11
case .footnote:
return 13
case .body:
return 17
@unknown default:
return 17
}
}
}
enum SybilTheme { enum SybilTheme {
static let background = Color(red: 0.07, green: 0.05, blue: 0.11) static let background = Color(red: 0.02, green: 0.02, blue: 0.05)
static let surface = Color(red: 0.14, green: 0.09, blue: 0.19) static let surface = Color(red: 0.05, green: 0.04, blue: 0.10)
static let surfaceStrong = Color(red: 0.17, green: 0.10, blue: 0.23) static let surfaceStrong = Color(red: 0.07, green: 0.06, blue: 0.14)
static let card = Color(red: 0.12, green: 0.08, blue: 0.17) static let card = Color(red: 0.06, green: 0.05, blue: 0.12)
static let border = Color(red: 0.30, green: 0.22, blue: 0.39) static let border = Color(red: 0.24, green: 0.20, blue: 0.38)
static let primary = Color(red: 0.66, green: 0.47, blue: 0.94) static let primary = Color(red: 0.57, green: 0.38, blue: 0.96)
static let primarySoft = Color(red: 0.53, green: 0.37, blue: 0.78) static let primarySoft = Color(red: 0.43, green: 0.25, blue: 0.76)
static let text = Color(red: 0.95, green: 0.91, blue: 0.98) static let accent = Color(red: 0.31, green: 0.88, blue: 0.95)
static let textMuted = Color(red: 0.72, green: 0.65, blue: 0.80) static let text = Color(red: 0.96, green: 0.94, blue: 1.0)
static let searchCard = Color(red: 0.16, green: 0.10, blue: 0.22) static let textMuted = Color(red: 0.65, green: 0.62, blue: 0.76)
static let userBubble = Color(red: 0.35, green: 0.20, blue: 0.62) static let searchCard = Color(red: 0.07, green: 0.06, blue: 0.14)
static let danger = Color(red: 0.93, green: 0.38, blue: 0.42) static let userBubble = Color(red: 0.29, green: 0.13, blue: 0.65)
static let danger = Color(red: 0.96, green: 0.32, blue: 0.40)
static var backgroundGradient: LinearGradient { static var backgroundGradient: LinearGradient {
LinearGradient( LinearGradient(
colors: [ colors: [
Color(red: 0.27, green: 0.12, blue: 0.36), Color(red: 0.12, green: 0.08, blue: 0.28),
Color(red: 0.15, green: 0.09, blue: 0.23), Color(red: 0.04, green: 0.03, blue: 0.09),
Color(red: 0.07, green: 0.05, blue: 0.11) background
], ],
startPoint: .top, startPoint: .top,
endPoint: .bottom endPoint: .bottom
) )
} }
static var brandGradient: LinearGradient {
LinearGradient(
colors: [
Color(red: 1.0, green: 0.55, blue: 0.97),
Color(red: 0.60, green: 0.43, blue: 1.0),
Color(red: 0.40, green: 0.87, blue: 1.0)
],
startPoint: .leading,
endPoint: .trailing
)
}
static var panelGradient: LinearGradient {
LinearGradient(
colors: [
Color(red: 0.07, green: 0.06, blue: 0.15).opacity(0.94),
Color(red: 0.03, green: 0.03, blue: 0.08).opacity(0.96)
],
startPoint: .top,
endPoint: .bottom
)
}
static var primaryGradient: LinearGradient {
LinearGradient(
colors: [
Color(red: 0.43, green: 0.27, blue: 0.92),
Color(red: 0.45, green: 0.09, blue: 0.63)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
static var selectedRowGradient: LinearGradient {
LinearGradient(
colors: [
primary.opacity(0.56),
primarySoft.opacity(0.48)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
static var userBubbleGradient: LinearGradient {
LinearGradient(
colors: [
Color(red: 0.38, green: 0.16, blue: 0.80),
Color(red: 0.25, green: 0.08, blue: 0.55)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
static var composerGradient: LinearGradient {
LinearGradient(
colors: [
Color(red: 0.04, green: 0.04, blue: 0.10).opacity(0.98),
Color(red: 0.09, green: 0.06, blue: 0.16).opacity(0.94)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
static var toolCallGradient: LinearGradient {
LinearGradient(
colors: [
Color(red: 0.01, green: 0.15, blue: 0.17).opacity(0.70),
Color(red: 0.03, green: 0.09, blue: 0.15).opacity(0.78)
],
startPoint: .leading,
endPoint: .trailing
)
}
static var failedToolCallGradient: LinearGradient {
LinearGradient(
colors: [
danger.opacity(0.18),
Color(red: 0.15, green: 0.03, blue: 0.07).opacity(0.72)
],
startPoint: .leading,
endPoint: .trailing
)
}
}
struct SybilWordmark: View {
var size: CGFloat = 30
var body: some View {
Text("SYBIL")
.font(.custom("Orbitron", size: size))
.fontWeight(.black)
.tracking(0)
.foregroundStyle(SybilTheme.brandGradient)
.accessibilityLabel("Sybil")
}
} }

View File

@@ -87,6 +87,7 @@ final class SybilViewModel {
var isLoadingCollections = false var isLoadingCollections = false
var isLoadingSelection = false var isLoadingSelection = false
var isSending = false var isSending = false
var isCreatingSearchChat = false
var errorMessage: String? var errorMessage: String?
var composer = "" var composer = ""
@@ -111,11 +112,15 @@ final class SybilViewModel {
} }
var providerModelOptions: [String] { var providerModelOptions: [String] {
let serverModels = modelCatalog[provider]?.models ?? [] modelOptions(for: provider)
}
func modelOptions(for candidate: Provider) -> [String] {
let serverModels = modelCatalog[candidate]?.models ?? []
if !serverModels.isEmpty { if !serverModels.isEmpty {
return serverModels return serverModels
} }
return fallbackModels[provider] ?? [] return fallbackModels[candidate] ?? []
} }
var selectedTitle: String { var selectedTitle: String {
@@ -198,20 +203,20 @@ final class SybilViewModel {
} }
var displayedMessages: [Message] { var displayedMessages: [Message] {
let canonical = selectedChat?.messages ?? [] let canonical = displayableMessages(selectedChat?.messages ?? [])
guard let pending = pendingChatState else { guard let pending = pendingChatState else {
return canonical return canonical
} }
if let pendingID = pending.chatID { if let pendingID = pending.chatID {
if case let .chat(selectedID) = selectedItem, selectedID == pendingID { if case let .chat(selectedID) = selectedItem, selectedID == pendingID {
return pending.messages return displayableMessages(pending.messages)
} }
return canonical return canonical
} }
if draftKind == .chat { if draftKind == .chat {
return pending.messages return displayableMessages(pending.messages)
} }
return canonical return canonical
@@ -222,7 +227,7 @@ final class SybilViewModel {
let initiatedLabel: String? let initiatedLabel: String?
if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty { if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
if let provider = chat.initiatedProvider { if let provider = chat.initiatedProvider {
initiatedLabel = "\(provider.displayName) · \(model)" initiatedLabel = "\(provider.displayName) \(model)"
} else { } else {
initiatedLabel = model initiatedLabel = model
} }
@@ -336,6 +341,15 @@ final class SybilViewModel {
SybilLog.info(SybilLog.ui, "Model changed to \(nextModel)") SybilLog.info(SybilLog.ui, "Model changed to \(nextModel)")
} }
func setProvider(_ nextProvider: Provider, model nextModel: String) {
provider = nextProvider
model = nextModel
settings.preferredProvider = nextProvider
settings.preferredModelByProvider[nextProvider] = nextModel
settings.persist()
SybilLog.info(SybilLog.ui, "Provider changed to \(nextProvider.rawValue), model=\(nextModel)")
}
func startNewChat() { func startNewChat() {
SybilLog.debug(SybilLog.ui, "Starting draft chat") SybilLog.debug(SybilLog.ui, "Starting draft chat")
draftKind = .chat draftKind = .chat
@@ -460,6 +474,36 @@ final class SybilViewModel {
isSending = false 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 { private func loadInitialData(using client: SybilAPIClient) async {
isLoadingCollections = true isLoadingCollections = true
errorMessage = nil errorMessage = nil
@@ -961,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 { private func chatTitle(title: String?, messages: [Message]?) -> String {
if let title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { if let title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
return title return title

View File

@@ -3,8 +3,13 @@ import SwiftUI
struct SybilWorkspaceView: View { struct SybilWorkspaceView: View {
@Bindable var viewModel: SybilViewModel @Bindable var viewModel: SybilViewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@FocusState private var composerFocused: Bool @FocusState private var composerFocused: Bool
private var isCompact: Bool {
horizontalSizeClass == .compact
}
private var isSettingsSelected: Bool { private var isSettingsSelected: Bool {
if case .settings = viewModel.selectedItem { if case .settings = viewModel.selectedItem {
return true return true
@@ -12,12 +17,18 @@ struct SybilWorkspaceView: View {
return false return false
} }
private var showsHeader: Bool {
!isCompact || viewModel.errorMessage != nil
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
header if showsHeader {
header
Divider() Divider()
.overlay(SybilTheme.border) .overlay(SybilTheme.border)
}
Group { Group {
if isSettingsSelected { if isSettingsSelected {
@@ -26,8 +37,13 @@ struct SybilWorkspaceView: View {
SybilSearchResultsView( SybilSearchResultsView(
search: viewModel.selectedSearch, search: viewModel.selectedSearch,
isLoading: viewModel.isLoadingSelection, isLoading: viewModel.isLoadingSelection,
isRunning: viewModel.isSending isRunning: viewModel.isSending,
) isStartingChat: viewModel.isCreatingSearchChat
) {
Task {
await viewModel.startChatFromSelectedSearch()
}
}
} else { } else {
SybilChatTranscriptView( SybilChatTranscriptView(
messages: viewModel.displayedMessages, messages: viewModel.displayedMessages,
@@ -44,7 +60,23 @@ struct SybilWorkspaceView: View {
composerBar composerBar
} }
} }
.navigationTitle(viewModel.selectedTitle) .navigationTitle(isCompact ? "" : viewModel.selectedTitle)
.toolbar {
if isCompact {
ToolbarItem(placement: .principal) {
Text(viewModel.selectedTitle)
.font(.sybil(.headline, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
}
}
if isCompact && !viewModel.isSearchMode && !isSettingsSelected {
ToolbarItem(placement: .topBarTrailing) {
compactProviderModelMenu
}
}
}
.background(SybilTheme.background) .background(SybilTheme.background)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onChange(of: viewModel.isSending) { _, isSending in .onChange(of: viewModel.isSending) { _, isSending in
@@ -56,37 +88,84 @@ struct SybilWorkspaceView: View {
private var header: some View { private var header: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) { if !isCompact {
Spacer() HStack(alignment: .top, spacing: 12) {
Spacer()
if !viewModel.isSearchMode && !isSettingsSelected { if !viewModel.isSearchMode && !isSettingsSelected {
providerControls providerControls
} else if viewModel.isSearchMode { } else if viewModel.isSearchMode {
Label("Search mode", systemImage: "globe") Label("Search mode", systemImage: "globe")
.font(.caption.weight(.medium)) .font(.sybil(.caption, weight: .medium))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.accent)
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 7) .padding(.vertical, 7)
.background( .background(
Capsule() RoundedRectangle(cornerRadius: 10)
.fill(SybilTheme.surface) .fill(SybilTheme.accent.opacity(0.10))
.overlay( .overlay(
Capsule() RoundedRectangle(cornerRadius: 10)
.stroke(SybilTheme.border, lineWidth: 1) .stroke(SybilTheme.accent.opacity(0.24), lineWidth: 1)
) )
) )
}
} }
} }
if let error = viewModel.errorMessage { if let error = viewModel.errorMessage {
Text(error) Text(error)
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger) .foregroundStyle(SybilTheme.danger)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(SybilTheme.panelGradient.opacity(0.58))
}
private var compactProviderModelMenu: some View {
Menu {
Text("\(viewModel.provider.displayName)\(viewModel.model)")
.font(.sybil(.caption))
Divider()
ForEach(Provider.allCases, id: \.self) { candidate in
Menu(candidate.displayName) {
let models = viewModel.modelOptions(for: candidate)
if models.isEmpty {
Text("No models")
} else {
ForEach(models, id: \.self) { candidateModel in
Button {
viewModel.setProvider(candidate, model: candidateModel)
} label: {
if viewModel.provider == candidate && viewModel.model == candidateModel {
Label(candidateModel, systemImage: "checkmark")
} else {
Text(candidateModel)
}
}
}
}
}
}
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.frame(width: 34, height: 34)
.background(
Circle()
.fill(SybilTheme.surface.opacity(0.78))
)
.overlay(
Circle()
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
)
}
.accessibilityLabel("Provider and model")
} }
private var providerControls: some View { private var providerControls: some View {
@@ -100,16 +179,16 @@ struct SybilWorkspaceView: View {
} label: { } label: {
Label(viewModel.provider.displayName, systemImage: "chevron.down") Label(viewModel.provider.displayName, systemImage: "chevron.down")
.labelStyle(.titleAndIcon) .labelStyle(.titleAndIcon)
.font(.caption.weight(.medium)) .font(.sybil(.caption, weight: .medium))
.foregroundStyle(SybilTheme.text) .foregroundStyle(SybilTheme.text)
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 7) .padding(.vertical, 7)
.background( .background(
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
.fill(SybilTheme.surface) .fill(SybilTheme.surface.opacity(0.78))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
.stroke(SybilTheme.border, lineWidth: 1) .stroke(SybilTheme.border.opacity(0.88), lineWidth: 1)
) )
) )
} }
@@ -123,17 +202,17 @@ struct SybilWorkspaceView: View {
} label: { } label: {
Label(viewModel.model, systemImage: "chevron.down") Label(viewModel.model, systemImage: "chevron.down")
.labelStyle(.titleAndIcon) .labelStyle(.titleAndIcon)
.font(.caption.weight(.medium)) .font(.sybil(.caption, weight: .medium))
.foregroundStyle(SybilTheme.text) .foregroundStyle(SybilTheme.text)
.lineLimit(1) .lineLimit(1)
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 7) .padding(.vertical, 7)
.background( .background(
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
.fill(SybilTheme.surface) .fill(SybilTheme.surface.opacity(0.78))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
.stroke(SybilTheme.border, lineWidth: 1) .stroke(SybilTheme.border.opacity(0.88), lineWidth: 1)
) )
) )
} }
@@ -161,10 +240,10 @@ struct SybilWorkspaceView: View {
.padding(.vertical, 10) .padding(.vertical, 10)
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.surface) .fill(SybilTheme.composerGradient)
.overlay( .overlay(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.stroke(SybilTheme.border, lineWidth: 1) .stroke(SybilTheme.primary.opacity(0.34), lineWidth: 1)
) )
) )
.foregroundStyle(SybilTheme.text) .foregroundStyle(SybilTheme.text)
@@ -175,11 +254,15 @@ struct SybilWorkspaceView: View {
} }
} label: { } label: {
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up") Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
.font(.headline.weight(.semibold)) .font(.system(size: 17, weight: .semibold))
.frame(width: 40, height: 40) .frame(width: 40, height: 40)
.background( .background(
Circle() Circle()
.fill(viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending ? SybilTheme.surface : SybilTheme.primary) .fill(
viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending
? AnyShapeStyle(SybilTheme.surface)
: AnyShapeStyle(SybilTheme.primaryGradient)
)
) )
.foregroundStyle(viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending ? SybilTheme.textMuted : SybilTheme.text) .foregroundStyle(viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending ? SybilTheme.textMuted : SybilTheme.text)
} }
@@ -188,6 +271,15 @@ struct SybilWorkspaceView: View {
} }
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(SybilTheme.background) .background(
LinearGradient(
colors: [
SybilTheme.background.opacity(0.18),
SybilTheme.background.opacity(0.96)
],
startPoint: .top,
endPoint: .bottom
)
)
} }
} }

View File

@@ -108,6 +108,13 @@ function mapSearchResultPreview(result: any, index: number) {
}; };
} }
function truncateContextPart(value: string | null | undefined, maxLength: number) {
const trimmed = value?.trim();
if (!trimmed) return null;
if (trimmed.length <= maxLength) return trimmed;
return `${trimmed.slice(0, maxLength - 1).trimEnd()}...`;
}
function parseAnswerText(answerResponse: any) { function parseAnswerText(answerResponse: any) {
if (typeof answerResponse?.answer === "string") return answerResponse.answer; if (typeof answerResponse?.answer === "string") return answerResponse.answer;
if (answerResponse?.answer) return JSON.stringify(answerResponse.answer, null, 2); if (answerResponse?.answer) return JSON.stringify(answerResponse.answer, null, 2);
@@ -153,6 +160,57 @@ function normalizeUrlForMatch(input: string | null | undefined) {
} }
} }
function buildSearchChatContext(search: any) {
const query = truncateContextPart(search.query, 500) ?? truncateContextPart(search.title, 500) ?? "Untitled search";
const lines: string[] = [
"You are Sybil. The user started this chat from a saved web search. Use the search answer and result context below when answering follow-up questions. If the context is insufficient, say so and use available tools when appropriate.",
"",
`Search query: ${query}`,
];
const answer = truncateContextPart(search.answerText, 6000);
if (answer) {
lines.push("", "Search answer:", answer);
}
if (Array.isArray(search.answerCitations) && search.answerCitations.length) {
lines.push("", "Answer citations:");
for (const [index, citation] of search.answerCitations.slice(0, 8).entries()) {
const title = truncateContextPart(citation?.title, 160);
const url = truncateContextPart(citation?.url ?? citation?.id, 400);
if (title || url) {
lines.push(`${index + 1}. ${[title, url].filter(Boolean).join(" - ")}`);
}
}
}
if (Array.isArray(search.results) && search.results.length) {
lines.push("", "Search results:");
for (const result of search.results.slice(0, 10)) {
const title = truncateContextPart(result.title, 180) ?? result.url;
const url = truncateContextPart(result.url, 500);
const published = truncateContextPart(result.publishedDate, 80);
const author = truncateContextPart(result.author, 120);
const text = truncateContextPart(result.text, 1000);
const highlights = Array.isArray(result.highlights)
? result.highlights
.map((highlight: unknown) => truncateContextPart(typeof highlight === "string" ? highlight : null, 360))
.filter(Boolean)
: [];
lines.push(`${result.rank + 1}. ${title}`);
if (url) lines.push(` URL: ${url}`);
if (published || author) lines.push(` Source detail: ${[published, author].filter(Boolean).join(" - ")}`);
if (text) lines.push(` Text: ${text}`);
for (const highlight of highlights.slice(0, 2)) {
lines.push(` Highlight: ${highlight}`);
}
}
}
return lines.join("\n");
}
function buildSseHeaders(originHeader: string | undefined) { function buildSseHeaders(originHeader: string | undefined) {
const origin = originHeader && originHeader !== "null" ? originHeader : "*"; const origin = originHeader && originHeader !== "null" ? originHeader : "*";
const headers: Record<string, string> = { const headers: Record<string, string> = {
@@ -370,6 +428,54 @@ export async function registerRoutes(app: FastifyInstance) {
return { search }; return { search };
}); });
app.post("/v1/searches/:searchId/chat", async (req) => {
requireAdmin(req);
const Params = z.object({ searchId: z.string() });
const Body = z.object({ title: z.string().optional() });
const { searchId } = Params.parse(req.params);
const body = Body.parse(req.body ?? {});
const search = await prisma.search.findUnique({
where: { id: searchId },
include: { results: { orderBy: { rank: "asc" } } },
});
if (!search) return app.httpErrors.notFound("search not found");
const fallbackTitle = search.query?.trim() || search.title?.trim() || "Search results";
const title = body.title?.trim() || `Search: ${fallbackTitle.slice(0, 72)}`;
const context = buildSearchChatContext(search);
const chat = await prisma.chat.create({
data: {
title,
messages: {
create: {
role: "system" as any,
content: context,
metadata: {
kind: "search_context",
searchId: search.id,
query: search.query,
resultCount: search.results.length,
},
},
},
},
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
},
});
return { chat };
});
app.post("/v1/searches/:searchId/run", async (req) => { app.post("/v1/searches/:searchId/run", async (req) => {
requireAdmin(req); requireAdmin(req);
const Params = z.object({ searchId: z.string() }); const Params = z.object({ searchId: z.string() });

View File

@@ -8,6 +8,7 @@ import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
import { SearchResultsPanel } from "@/components/search/search-results-panel"; import { SearchResultsPanel } from "@/components/search/search-results-panel";
import { import {
createChat, createChat,
createChatFromSearch,
createSearch, createSearch,
deleteChat, deleteChat,
deleteSearch, deleteSearch,
@@ -119,9 +120,8 @@ function loadStoredModelPreferences() {
} }
} }
function pickProviderModel(options: string[], preferred: string | null, fallback: string | null = null) { function pickProviderModel(options: string[], preferred: string | null) {
if (fallback && options.includes(fallback)) return fallback; if (preferred?.trim()) return preferred.trim();
if (preferred && options.includes(preferred)) return preferred;
return options[0] ?? ""; return options[0] ?? "";
} }
@@ -165,6 +165,10 @@ function isToolCallLogMessage(message: Message) {
return asToolLogMetadata(message.metadata) !== null; return asToolLogMetadata(message.metadata) !== null;
} }
function isDisplayableMessage(message: Message) {
return message.role !== "system";
}
function buildOptimisticToolMessage(event: ToolCallEvent): Message { function buildOptimisticToolMessage(event: ToolCallEvent): Message {
return { return {
id: `temp-tool-${event.toolCallId}`, id: `temp-tool-${event.toolCallId}`,
@@ -197,34 +201,46 @@ type ModelComboboxProps = {
function ModelCombobox({ options, value, onChange, disabled = false }: ModelComboboxProps) { function ModelCombobox({ options, value, onChange, disabled = false }: ModelComboboxProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(""); const [draftValue, setDraftValue] = useState(value);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const normalizedDraftValue = draftValue.trim();
const filteredOptions = useMemo(() => { const filteredOptions = useMemo(() => {
const needle = query.trim().toLowerCase(); const needle = normalizedDraftValue.toLowerCase();
if (!needle) return options; if (!needle) return options;
return options.filter((option) => option.toLowerCase().includes(needle)); return options.filter((option) => option.toLowerCase().includes(needle));
}, [options, query]); }, [normalizedDraftValue, options]);
const hasExactOption = options.includes(normalizedDraftValue);
useEffect(() => {
if (open) return;
setDraftValue(value);
}, [open, value]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
inputRef.current?.focus(); inputRef.current?.focus();
}, [open]); }, [open]);
const commitDraftValue = () => {
onChange(normalizedDraftValue);
setDraftValue(normalizedDraftValue);
setOpen(false);
};
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const handlePointerDown = (event: PointerEvent) => { const handlePointerDown = (event: PointerEvent) => {
if (rootRef.current?.contains(event.target as Node)) return; if (rootRef.current?.contains(event.target as Node)) return;
setOpen(false); commitDraftValue();
setQuery("");
}; };
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return; if (event.key !== "Escape") return;
setOpen(false); setOpen(false);
setQuery(""); setDraftValue(value);
}; };
window.addEventListener("pointerdown", handlePointerDown); window.addEventListener("pointerdown", handlePointerDown);
@@ -233,51 +249,82 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
window.removeEventListener("pointerdown", handlePointerDown); window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keydown", handleKeyDown);
}; };
}, [open]); }, [commitDraftValue, open, value]);
return ( return (
<div className="relative" ref={rootRef}> <div className="relative" ref={rootRef}>
<button <div className="flex h-10 min-w-56 items-center rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)]">
type="button" <input
className="flex h-9 min-w-56 items-center justify-between rounded-md border border-input bg-background px-2 text-sm" ref={inputRef}
onClick={() => { value={draftValue}
if (disabled) return; onFocus={() => {
setOpen((current) => !current); if (disabled) return;
}} setDraftValue(value);
disabled={disabled} setOpen(true);
> }}
<span className="truncate text-left">{value || "Select model"}</span> onInput={(event) => {
<ChevronDown className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" /> setDraftValue(event.currentTarget.value);
</button> setOpen(true);
}}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
commitDraftValue();
}}
className="h-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
placeholder="Select or type model"
disabled={disabled}
/>
<button
type="button"
className="ml-2 shrink-0 text-muted-foreground disabled:opacity-50"
onClick={() => {
if (disabled) return;
if (open) {
commitDraftValue();
return;
}
setDraftValue(value);
setOpen(true);
}}
disabled={disabled}
aria-label="Toggle model options"
>
<ChevronDown className="h-4 w-4" />
</button>
</div>
{open ? ( {open ? (
<div className="absolute right-0 z-50 mt-1 w-full rounded-md border border-border bg-background p-1 shadow-md"> <div className="absolute right-0 z-50 mt-2 w-full rounded-lg border border-violet-300/20 bg-[hsl(238_48%_7%)] p-1 shadow-2xl shadow-black/45">
<input
ref={inputRef}
value={query}
onInput={(event) => setQuery(event.currentTarget.value)}
className="mb-1 h-8 w-full rounded-sm border border-input bg-background px-2 text-sm outline-none"
placeholder="Filter models"
/>
<div className="max-h-64 overflow-y-auto"> <div className="max-h-64 overflow-y-auto">
{normalizedDraftValue && !hasExactOption ? (
<button
type="button"
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-violet-400/12"
onClick={commitDraftValue}
>
<Check className={cn("h-4 w-4", normalizedDraftValue === value ? "opacity-100" : "opacity-0")} />
<span className="truncate">Use "{normalizedDraftValue}"</span>
</button>
) : null}
{filteredOptions.length ? ( {filteredOptions.length ? (
filteredOptions.map((option) => ( filteredOptions.map((option) => (
<button <button
key={option} key={option}
type="button" type="button"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted" className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-violet-400/12"
onClick={() => { onClick={() => {
onChange(option); onChange(option);
setOpen(false); setOpen(false);
setQuery(""); setDraftValue(option);
}} }}
> >
<Check className={cn("h-4 w-4", option === value ? "opacity-100" : "opacity-0")} /> <Check className={cn("h-4 w-4", option === value ? "opacity-100" : "opacity-0")} />
<span className="truncate">{option}</span> <span className="truncate">{option}</span>
</button> </button>
)) ))
) : ( ) : !normalizedDraftValue ? (
<p className="px-2 py-2 text-sm text-muted-foreground">No models found</p> <p className="px-2 py-2 text-sm text-muted-foreground">No models found</p>
)} ) : null}
</div> </div>
</div> </div>
) : null} ) : null}
@@ -336,6 +383,32 @@ function formatDate(value: string) {
}).format(new Date(value)); }).format(new Date(value));
} }
function getSidebarSectionLabel(value: string) {
const date = new Date(value);
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfItemDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
const dayMs = 24 * 60 * 60 * 1000;
const dayDelta = Math.floor((startOfToday - startOfItemDay) / dayMs);
if (dayDelta <= 0) return "TODAY";
if (dayDelta < 7) return "LAST 7 DAYS";
return "EARLIER";
}
function buildSidebarSections(items: SidebarItem[]) {
return items.reduce<Array<{ label: string; items: SidebarItem[] }>>((sections, item) => {
const label = getSidebarSectionLabel(item.updatedAt);
const section = sections.find((candidate) => candidate.label === label);
if (section) {
section.items.push(item);
} else {
sections.push({ label, items: [item] });
}
return sections;
}, []);
}
export default function App() { export default function App() {
const { const {
authTokenInput, authTokenInput,
@@ -359,6 +432,7 @@ export default function App() {
const [isLoadingCollections, setIsLoadingCollections] = useState(false); const [isLoadingCollections, setIsLoadingCollections] = useState(false);
const [isLoadingSelection, setIsLoadingSelection] = useState(false); const [isLoadingSelection, setIsLoadingSelection] = useState(false);
const [isSending, setIsSending] = useState(false); const [isSending, setIsSending] = useState(false);
const [isStartingSearchChat, setIsStartingSearchChat] = useState(false);
const [pendingChatState, setPendingChatState] = useState<{ chatId: string | null; messages: Message[] } | null>(null); const [pendingChatState, setPendingChatState] = useState<{ chatId: string | null; messages: Message[] } | null>(null);
const [composer, setComposer] = useState(""); const [composer, setComposer] = useState("");
const [provider, setProvider] = useState<Provider>("openai"); const [provider, setProvider] = useState<Provider>("openai");
@@ -380,6 +454,7 @@ export default function App() {
const wasSendingRef = useRef(false); const wasSendingRef = useRef(false);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null); const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const [sidebarQuery, setSidebarQuery] = useState("");
const initialRouteSelectionRef = useRef<SidebarSelection | null>(readSidebarSelectionFromUrl()); const initialRouteSelectionRef = useRef<SidebarSelection | null>(readSidebarSelectionFromUrl());
const hasSyncedSelectionHistoryRef = useRef(false); const hasSyncedSelectionHistoryRef = useRef(false);
@@ -400,6 +475,17 @@ export default function App() {
}, [composer]); }, [composer]);
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]); const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]);
const filteredSidebarItems = useMemo(() => {
const query = sidebarQuery.trim().toLowerCase();
if (!query) return sidebarItems;
return sidebarItems.filter((item) => {
const providerLabel = getProviderLabel(item.lastUsedProvider || item.initiatedProvider).toLowerCase();
return [item.title, item.initiatedModel, item.lastUsedModel, providerLabel]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query));
});
}, [sidebarItems, sidebarQuery]);
const sidebarSections = useMemo(() => buildSidebarSections(filteredSidebarItems), [filteredSidebarItems]);
const resetWorkspaceState = () => { const resetWorkspaceState = () => {
setChats([]); setChats([]);
@@ -545,10 +631,11 @@ export default function App() {
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]); const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
useEffect(() => { useEffect(() => {
if (model.trim()) return;
setModel((current) => { setModel((current) => {
return pickProviderModel(providerModelOptions, providerModelPreferences[provider], current); return current.trim() || pickProviderModel(providerModelOptions, providerModelPreferences[provider]);
}); });
}, [provider, providerModelOptions, providerModelPreferences]); }, [model, provider, providerModelOptions, providerModelPreferences]);
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@@ -618,14 +705,14 @@ export default function App() {
selectedItem?.kind === "chat" && selectedItem?.kind === "chat" &&
selectedItem.id === pendingChatState.chatId; selectedItem.id === pendingChatState.chatId;
const displayMessages = useMemo(() => { const displayMessages = useMemo(() => {
if (!pendingChatState) return messages; if (!pendingChatState) return messages.filter(isDisplayableMessage);
if (pendingChatState.chatId) { if (pendingChatState.chatId) {
if (selectedItem?.kind === "chat" && selectedItem.id === pendingChatState.chatId) { if (selectedItem?.kind === "chat" && selectedItem.id === pendingChatState.chatId) {
return pendingChatState.messages; return pendingChatState.messages.filter(isDisplayableMessage);
} }
return messages; return messages.filter(isDisplayableMessage);
} }
return isSearchMode ? messages : pendingChatState.messages; return (isSearchMode ? messages : pendingChatState.messages).filter(isDisplayableMessage);
}, [isSearchMode, messages, pendingChatState, selectedItem]); }, [isSearchMode, messages, pendingChatState, selectedItem]);
const selectedChatSummary = useMemo(() => { const selectedChatSummary = useMemo(() => {
@@ -1068,6 +1155,47 @@ export default function App() {
await refreshCollections({ kind: "search", id: searchId }); await refreshCollections({ kind: "search", id: searchId });
}; };
const handleStartChatFromSearch = async () => {
if (!selectedSearch || isStartingSearchChat || isSending) return;
setError(null);
setIsStartingSearchChat(true);
try {
const chat = await createChatFromSearch(selectedSearch.id);
setDraftKind(null);
setPendingChatState(null);
setComposer("");
setChats((current) => {
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
return [chat, ...withoutExisting];
});
setSelectedItem({ kind: "chat", id: chat.id });
setSelectedChat({
id: chat.id,
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel,
messages: [],
});
setSelectedSearch(null);
await refreshCollections({ kind: "chat", id: chat.id });
await refreshChat(chat.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setError(message);
}
} finally {
setIsStartingSearchChat(false);
}
};
const handleSend = async () => { const handleSend = async () => {
const content = composer.trim(); const content = composer.trim();
if (!content || isSending) return; if (!content || isSending) return;
@@ -1128,12 +1256,12 @@ export default function App() {
} }
return ( return (
<div className="h-full"> <div className="app-grid-surface h-full p-0 md:p-2">
<div className="flex h-full w-full overflow-hidden bg-background"> <div className="flex h-full w-full overflow-hidden bg-transparent md:gap-2">
{isMobileSidebarOpen ? ( {isMobileSidebarOpen ? (
<button <button
type="button" type="button"
className="fixed inset-0 z-30 bg-black/45 md:hidden" className="fixed inset-0 z-30 bg-black/70 backdrop-blur-sm md:hidden"
onClick={() => setIsMobileSidebarOpen(false)} onClick={() => setIsMobileSidebarOpen(false)}
aria-label="Close sidebar" aria-label="Close sidebar"
/> />
@@ -1141,22 +1269,41 @@ export default function App() {
<aside <aside
className={cn( className={cn(
"fixed inset-y-0 left-0 z-40 flex w-[85vw] max-w-80 shrink-0 flex-col border-r bg-[hsl(272_34%_14%)] transition-transform md:static md:z-auto md:w-80 md:max-w-none", "glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none md:rounded-2xl md:border",
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0" isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)} )}
> >
<div className="grid grid-cols-2 gap-2 p-3"> <div className="px-4 pb-4 pt-5">
<Button className="justify-start gap-2" onClick={handleCreateChat}> <div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
SYBIL
</div>
<p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
</p>
</div>
<div className="space-y-3 px-3 pb-3">
<Button className="h-11 w-full justify-start gap-3 text-[15px]" onClick={handleCreateChat}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
New chat New chat
</Button> </Button>
<Button className="justify-start gap-2" variant="secondary" onClick={handleCreateSearch}> <Button className="h-10 w-full justify-start gap-3" variant="secondary" onClick={handleCreateSearch}>
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
New search New search
</Button> </Button>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-200/58" />
<input
value={sidebarQuery}
onInput={(event) => setSidebarQuery(event.currentTarget.value)}
placeholder="Search chats"
className="h-10 w-full rounded-lg border border-violet-300/18 bg-background/66 pl-9 pr-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.05)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
/>
</div>
</div> </div>
<Separator /> <Separator className="bg-violet-300/10" />
<div className="flex-1 overflow-y-auto p-2"> <div className="flex-1 overflow-y-auto px-2 py-3">
{isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null} {isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null}
{!isLoadingCollections && sidebarItems.length === 0 ? ( {!isLoadingCollections && sidebarItems.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-sm text-muted-foreground"> <div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-sm text-muted-foreground">
@@ -1164,45 +1311,60 @@ export default function App() {
Start a chat or run your first search. Start a chat or run your first search.
</div> </div>
) : null} ) : null}
{sidebarItems.map((item) => { {!isLoadingCollections && sidebarItems.length > 0 && filteredSidebarItems.length === 0 ? (
const active = selectedItem?.kind === item.kind && selectedItem.id === item.id; <p className="px-2 py-3 text-sm text-muted-foreground">No chats found.</p>
const initiatedLabel = item.kind === "chat" && item.initiatedModel ) : null}
? `${getProviderLabel(item.initiatedProvider)}${item.initiatedProvider ? " · " : ""}${item.initiatedModel}` {sidebarSections.map((section) => (
: null; <div key={section.label} className="mb-4">
return ( <p className="px-3 pb-2 text-[11px] font-semibold text-violet-200/48">{section.label}</p>
<button {section.items.map((item) => {
key={`${item.kind}-${item.id}`} const active = selectedItem?.kind === item.kind && selectedItem.id === item.id;
className={cn( const initiatedLabel = item.kind === "chat" && item.initiatedModel
"mb-1 w-full rounded-lg px-3 py-2 text-left transition", ? `${getProviderLabel(item.initiatedProvider)}${item.initiatedProvider ? " · " : ""}${item.initiatedModel}`
active ? "bg-violet-500/30 text-violet-100" : "text-violet-200/85 hover:bg-violet-500/15" : null;
)} return (
onClick={() => { <button
setContextMenu(null); key={`${item.kind}-${item.id}`}
setDraftKind(null); className={cn(
setSelectedItem({ kind: item.kind, id: item.id }); "mb-1 w-full rounded-lg border px-3 py-2.5 text-left transition",
setIsMobileSidebarOpen(false); active
}} ? "border-violet-300/45 bg-[linear-gradient(135deg,hsl(258_86%_52%_/_0.58),hsl(277_78%_28%_/_0.55))] text-violet-50"
onContextMenu={(event) => openContextMenu(event, { kind: item.kind, id: item.id })} : "border-transparent text-violet-100/78 hover:border-violet-300/18 hover:bg-violet-400/10"
type="button" )}
> onClick={() => {
<div className="flex items-center gap-2"> setContextMenu(null);
{item.kind === "chat" ? <MessageSquare className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />} setDraftKind(null);
<p className="truncate text-sm font-medium">{item.title}</p> setSelectedItem({ kind: item.kind, id: item.id });
</div> setIsMobileSidebarOpen(false);
<div className="mt-1 flex items-center gap-2 text-xs"> }}
<p className={cn("shrink-0", active ? "text-violet-100/90" : "text-violet-300/60")}>{formatDate(item.updatedAt)}</p> onContextMenu={(event) => openContextMenu(event, { kind: item.kind, id: item.id })}
{initiatedLabel ? ( type="button"
<p className={cn("ml-auto truncate text-right", active ? "text-violet-200/65" : "text-violet-300/45")}>{initiatedLabel}</p> >
) : null} <div className="flex items-center gap-2">
</div> <span
</button> className={cn(
); "flex h-5 w-5 shrink-0 items-center justify-center rounded-md border",
})} active ? "border-cyan-200/35 bg-cyan-300/12 text-cyan-100" : "border-violet-300/18 text-violet-200/70"
)}
>
{item.kind === "chat" ? <MessageSquare className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />}
</span>
<p className="truncate text-sm font-semibold">{item.title}</p>
<p className={cn("ml-auto shrink-0 text-xs", active ? "text-violet-100/86" : "text-violet-200/50")}>{formatDate(item.updatedAt)}</p>
</div>
{initiatedLabel ? (
<p className={cn("mt-1 truncate text-right text-xs", active ? "text-violet-100/62" : "text-violet-200/42")}>{initiatedLabel}</p>
) : null}
</button>
);
})}
</div>
))}
</div> </div>
</aside> </aside>
<main className="flex min-w-0 flex-1 flex-col"> <main className="glass-panel relative flex min-w-0 flex-1 flex-col overflow-hidden border-violet-300/18 md:rounded-2xl md:border">
<header className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3"> <header className="flex flex-wrap items-center justify-between gap-3 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:px-7">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<Button <Button
type="button" type="button"
@@ -1216,24 +1378,20 @@ export default function App() {
</Button> </Button>
<div> <div>
<h1 className="text-sm font-semibold md:text-base">{selectedTitle}</h1> <h1 className="text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
<p className="text-xs text-muted-foreground">
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
{isSearchMode ? " • Exa Search" : ""}
</p>
</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">
{!isSearchMode ? ( {!isSearchMode ? (
<> <>
<select <select
className="h-9 rounded-md border border-input bg-background px-2 text-sm" className="h-10 min-w-32 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)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
value={provider} value={provider}
onChange={(event) => { onChange={(event) => {
const nextProvider = event.currentTarget.value as Provider; const nextProvider = event.currentTarget.value as Provider;
setProvider(nextProvider); setProvider(nextProvider);
const options = getModelOptions(modelCatalog, nextProvider); const options = getModelOptions(modelCatalog, nextProvider);
setModel((current) => pickProviderModel(options, providerModelPreferences[nextProvider], current)); setModel(pickProviderModel(options, providerModelPreferences[nextProvider]));
}} }}
disabled={isSending} disabled={isSending}
> >
@@ -1244,18 +1402,19 @@ export default function App() {
<ModelCombobox <ModelCombobox
options={providerModelOptions} options={providerModelOptions}
value={model} value={model}
disabled={isSending || providerModelOptions.length === 0} disabled={isSending}
onChange={(nextModel) => { onChange={(nextModel) => {
setModel(nextModel); const normalizedModel = nextModel.trim();
setModel(normalizedModel);
setProviderModelPreferences((current) => ({ setProviderModelPreferences((current) => ({
...current, ...current,
[provider]: nextModel, [provider]: normalizedModel || null,
})); }));
}} }}
/> />
</> </>
) : ( ) : (
<div className="flex h-9 items-center rounded-md border border-input px-3 text-sm text-muted-foreground"> <div className="flex h-10 items-center rounded-lg border border-cyan-300/22 bg-cyan-300/8 px-3 text-sm text-cyan-100">
<Globe2 className="mr-2 h-4 w-4" /> <Globe2 className="mr-2 h-4 w-4" />
Search mode Search mode
</div> </div>
@@ -1265,7 +1424,7 @@ export default function App() {
<div <div
ref={transcriptContainerRef} ref={transcriptContainerRef}
className={cn("flex-1 overflow-y-auto px-3 pt-6 md:px-10", isSearchMode ? "pb-6" : "pb-28 md:pb-40")} className="flex-1 overflow-y-auto px-4 pt-8 md:px-10 lg:px-14 pb-36 md:pb-44"
onScroll={() => { onScroll={() => {
const container = transcriptContainerRef.current; const container = transcriptContainerRef.current;
if (!container) return; if (!container) return;
@@ -1276,13 +1435,19 @@ export default function App() {
{!isSearchMode ? ( {!isSearchMode ? (
<ChatMessagesPanel messages={displayMessages} isLoading={isLoadingSelection} isSending={isSendingActiveChat} /> <ChatMessagesPanel messages={displayMessages} isLoading={isLoadingSelection} isSending={isSendingActiveChat} />
) : ( ) : (
<SearchResultsPanel search={selectedSearch} isLoading={isLoadingSelection} isRunning={isSearchRunning} /> <SearchResultsPanel
search={selectedSearch}
isLoading={isLoadingSelection}
isRunning={isSearchRunning}
isStartingChat={isStartingSearchChat}
onStartChat={selectedSearch ? handleStartChatFromSearch : undefined}
/>
)} )}
<div ref={transcriptEndRef} /> <div ref={transcriptEndRef} />
</div> </div>
<footer className="border-t p-3 md:p-4"> <footer className="pointer-events-none absolute inset-x-0 bottom-0 z-10 bg-[linear-gradient(to_top,hsl(235_50%_4%)_0%,hsl(235_50%_4%_/_0.92)_58%,transparent)] p-3 pt-14 md:p-6 md:pt-20">
<div className="mx-auto max-w-3xl rounded-xl border bg-background p-2 shadow-sm"> <div className="pointer-events-auto mx-auto max-w-4xl rounded-2xl border border-violet-300/30 bg-[linear-gradient(135deg,hsl(235_48%_7%_/_0.96),hsl(258_48%_11%_/_0.94))] p-2 shadow-lg shadow-black/20">
<Textarea <Textarea
id="composer-input" id="composer-input"
rows={1} rows={1}
@@ -1299,13 +1464,13 @@ export default function App() {
void handleSend(); void handleSend();
} }
}} }}
placeholder={isSearchMode ? "Search the web" : "Message Sybil"} placeholder={isSearchMode ? "Search the web" : "Message Sybil..."}
className="max-h-40 min-h-0 resize-none overflow-y-auto border-0 shadow-none focus-visible:ring-0" className="max-h-40 min-h-0 resize-none overflow-y-auto border-0 bg-transparent px-3 py-3 text-base text-violet-50 shadow-none placeholder:text-violet-200/45 focus-visible:ring-0"
disabled={isSending} disabled={isSending}
/> />
<div className={cn("flex items-center px-2 pb-1", error ? "justify-between" : "justify-end")}> <div className={cn("flex items-center gap-3 px-2 pb-1", error ? "justify-between" : "justify-end")}>
{error ? <p className="text-xs text-red-600">{error}</p> : null} {error ? <p className="min-w-0 truncate text-xs text-rose-300">{error}</p> : null}
<Button onClick={() => void handleSend()} size="icon" disabled={isSending || !composer.trim()}> <Button className="h-10 w-10 rounded-lg" onClick={() => void handleSend()} size="icon" disabled={isSending || !composer.trim()}>
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />} {isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
</Button> </Button>
</div> </div>
@@ -1316,13 +1481,13 @@ export default function App() {
{contextMenu ? ( {contextMenu ? (
<div <div
ref={contextMenuRef} ref={contextMenuRef}
className="fixed z-50 min-w-40 rounded-md border border-border bg-background p-1 shadow-md" className="fixed z-50 min-w-40 rounded-lg border border-violet-300/20 bg-[hsl(238_48%_7%)] p-1 shadow-2xl shadow-black/45"
style={{ left: contextMenu.x, top: contextMenu.y }} style={{ left: contextMenu.x, top: contextMenu.y }}
onContextMenu={(event) => event.preventDefault()} onContextMenu={(event) => event.preventDefault()}
> >
<button <button
type="button" type="button"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-red-600 transition hover:bg-muted 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"
onClick={() => void handleDeleteFromContextMenu()} onClick={() => void handleDeleteFromContextMenu()}
disabled={isSending} disabled={isSending}
> >

View File

@@ -12,14 +12,20 @@ type Props = {
export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, authError, onSignIn }: Props) { export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, authError, onSignIn }: Props) {
return ( return (
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top,#45215f_0%,#2a183d_45%,#191227_100%)] p-4"> <div className="app-grid-surface flex h-full items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl border bg-[hsl(276_32%_14%)] p-6 shadow-xl shadow-violet-950/45"> <div className="glass-panel w-full max-w-md rounded-2xl border border-violet-300/18 p-6">
<div className="mb-6">
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
SYBIL
</div>
</div>
<div className="mb-5 flex items-start gap-3"> <div className="mb-5 flex items-start gap-3">
<div className="rounded-lg bg-violet-200 p-2 text-violet-900"> <div className="rounded-lg border border-cyan-300/25 bg-cyan-400/12 p-2 text-cyan-200">
<ShieldCheck className="h-4 w-4" /> <ShieldCheck className="h-4 w-4" />
</div> </div>
<div> <div>
<h1 className="text-lg font-semibold">Sign in to Sybil</h1> <h1 className="text-lg font-semibold text-violet-50">Sign in to Sybil</h1>
<p className="mt-1 text-sm text-muted-foreground">Use your backend admin token.</p> <p className="mt-1 text-sm text-muted-foreground">Use your backend admin token.</p>
</div> </div>
</div> </div>
@@ -38,6 +44,7 @@ export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, aut
value={authTokenInput} value={authTokenInput}
onInput={(event) => setAuthTokenInput(event.currentTarget.value)} onInput={(event) => setAuthTokenInput(event.currentTarget.value)}
disabled={isSigningIn} disabled={isSigningIn}
className="bg-[hsl(235_48%_6%_/_0.84)] text-violet-50"
/> />
<Button className="w-full" type="submit" disabled={isSigningIn}> <Button className="w-full" type="submit" disabled={isSigningIn}>
{isSigningIn ? "Signing in..." : "Sign in"} {isSigningIn ? "Signing in..." : "Sign in"}

View File

@@ -42,7 +42,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
return ( return (
<> <>
{isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null} {isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
<div className="mx-auto max-w-3xl space-y-6"> <div className="mx-auto max-w-4xl space-y-6">
{messages.map((message) => { {messages.map((message) => {
const toolLogMetadata = asToolLogMetadata(message.metadata); const toolLogMetadata = asToolLogMetadata(message.metadata);
if (message.role === "tool" && toolLogMetadata) { if (message.role === "tool" && toolLogMetadata) {
@@ -53,13 +53,13 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
<div key={message.id} className="flex justify-start"> <div key={message.id} className="flex justify-start">
<div <div
className={cn( className={cn(
"inline-flex max-w-[85%] items-center gap-2 rounded-md border px-3 py-2 text-xs leading-5", "inline-flex max-w-[85%] items-center gap-3 rounded-lg border px-3.5 py-2 text-sm leading-5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
isFailed isFailed
? "border-rose-500/40 bg-rose-950/20 text-rose-200" ? "border-rose-500/40 bg-rose-950/18 text-rose-200"
: "border-cyan-500/35 bg-cyan-950/20 text-cyan-100" : "border-cyan-400/34 bg-cyan-950/18 text-cyan-100"
)} )}
> >
<Icon className="h-3.5 w-3.5 shrink-0" /> <Icon className="h-4 w-4 shrink-0 text-cyan-300" />
<span>{getToolSummary(message, toolLogMetadata)}</span> <span>{getToolSummary(message, toolLogMetadata)}</span>
</div> </div>
</div> </div>
@@ -73,7 +73,9 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
<div <div
className={cn( className={cn(
"max-w-[85%]", "max-w-[85%]",
isUser ? "rounded-2xl bg-violet-900/80 px-4 py-3 text-sm leading-6 text-fuchsia-50" : "text-base leading-7 text-fuchsia-100" isUser
? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
: "text-base leading-7 text-violet-50"
)} )}
> >
{isPendingAssistant ? ( {isPendingAssistant ? (
@@ -85,7 +87,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
) : ( ) : (
<MarkdownContent <MarkdownContent
markdown={message.content} markdown={message.content}
className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-fuchsia-100")} className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-violet-50")}
/> />
)} )}
</div> </div>
@@ -94,7 +96,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
})} })}
{isSending && !hasPendingAssistant ? ( {isSending && !hasPendingAssistant ? (
<div className="flex justify-start"> <div className="flex justify-start">
<div className="max-w-[85%] text-base leading-7 text-fuchsia-100"> <div className="max-w-[85%] text-base leading-7 text-violet-50">
<span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status"> <span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status">
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" /> <span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" /> <span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "preact/hooks";
import type { SearchDetail } from "@/lib/api"; import type { SearchDetail } from "@/lib/api";
import { MarkdownContent } from "@/components/markdown/markdown-content"; import { MarkdownContent } from "@/components/markdown/markdown-content";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MessageSquare } from "lucide-preact";
function formatHost(url: string) { function formatHost(url: string) {
try { try {
@@ -29,6 +30,8 @@ type Props = {
className?: string; className?: string;
enableKeyboardNavigation?: boolean; enableKeyboardNavigation?: boolean;
openLinksInNewTab?: boolean; openLinksInNewTab?: boolean;
isStartingChat?: boolean;
onStartChat?: () => void;
}; };
export function SearchResultsPanel({ export function SearchResultsPanel({
@@ -38,6 +41,8 @@ export function SearchResultsPanel({
className, className,
enableKeyboardNavigation = false, enableKeyboardNavigation = false,
openLinksInNewTab = true, openLinksInNewTab = true,
isStartingChat = false,
onStartChat,
}: Props) { }: Props) {
const ANSWER_COLLAPSED_HEIGHT_CLASS = "h-[3rem]"; const ANSWER_COLLAPSED_HEIGHT_CLASS = "h-[3rem]";
const [isAnswerExpanded, setIsAnswerExpanded] = useState(false); const [isAnswerExpanded, setIsAnswerExpanded] = useState(false);
@@ -133,23 +138,37 @@ export function SearchResultsPanel({
const isAnswerLoading = isRunning && !hasAnswerText; const isAnswerLoading = isRunning && !hasAnswerText;
const hasCitations = citationEntries.length > 0; const hasCitations = citationEntries.length > 0;
const isExpandable = hasAnswerText && (canExpandAnswer || hasCitations); const isExpandable = hasAnswerText && (canExpandAnswer || hasCitations);
const canStartChat = !!search && !isLoading && !isRunning && !isStartingChat && (!!search.answerText || search.results.length > 0);
return ( return (
<div className={className ?? "mx-auto w-full max-w-4xl"}> <div className={className ?? "mx-auto w-full max-w-4xl"}>
{search?.query ? ( {search?.query ? (
<div className="mb-5"> <div className="mb-5 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<p className="text-sm text-muted-foreground">Results for</p> <div className="min-w-0">
<h2 className="mt-1 break-words text-xl font-semibold">{search.query}</h2> <p className="text-sm text-muted-foreground">Results for</p>
<p className="mt-1 text-xs text-muted-foreground"> <h2 className="mt-1 break-words text-xl font-semibold text-violet-50">{search.query}</h2>
{search.results.length} result{search.results.length === 1 ? "" : "s"} <p className="mt-1 text-xs text-muted-foreground">
{search.latencyMs ? `${search.latencyMs} ms` : ""} {search.results.length} result{search.results.length === 1 ? "" : "s"}
</p> {search.latencyMs ? `${search.latencyMs} ms` : ""}
</p>
</div>
{onStartChat ? (
<button
type="button"
className="inline-flex h-10 shrink-0 items-center justify-center gap-2 rounded-lg border border-violet-300/24 bg-violet-300/10 px-3 text-sm font-medium text-violet-50 transition hover:bg-violet-300/16 disabled:cursor-not-allowed disabled:opacity-50"
onClick={onStartChat}
disabled={!canStartChat}
>
<MessageSquare className="h-4 w-4" />
{isStartingChat ? "Starting chat..." : "Chat with results"}
</button>
) : null}
</div> </div>
) : null} ) : null}
{(isRunning || !!search?.answerText || !!search?.answerError) && ( {(isRunning || !!search?.answerText || !!search?.answerError) && (
<section className="mb-6 rounded-xl border border-violet-400/35 bg-[hsl(276_31%_15%)] p-4"> <section className="mb-6 rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(240_46%_8%_/_0.94),hsl(260_40%_12%_/_0.88))] p-4 shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)]">
<p className="text-xs font-semibold uppercase tracking-wide text-violet-300/90">Answer</p> <p className="text-xs font-semibold uppercase text-violet-300/90">Answer</p>
{(isAnswerLoading || hasAnswerText) ? ( {(isAnswerLoading || hasAnswerText) ? (
<div className="mt-2"> <div className="mt-2">
<div className="relative"> <div className="relative">
@@ -172,7 +191,7 @@ export function SearchResultsPanel({
)} )}
</div> </div>
{!isAnswerExpanded && (isExpandable || isAnswerLoading) ? ( {!isAnswerExpanded && (isExpandable || isAnswerLoading) ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-7 bg-gradient-to-t from-[hsl(276_31%_15%)] to-transparent" /> <div className="pointer-events-none absolute inset-x-0 bottom-0 h-7 bg-gradient-to-t from-[hsl(252_42%_10%)] to-transparent" />
) : null} ) : null}
</div> </div>
<div className="mt-2 h-5"> <div className="mt-2 h-5">
@@ -199,7 +218,7 @@ export function SearchResultsPanel({
href={citation.href} href={citation.href}
target={openLinksInNewTab ? "_blank" : undefined} target={openLinksInNewTab ? "_blank" : undefined}
rel={openLinksInNewTab ? "noreferrer" : undefined} rel={openLinksInNewTab ? "noreferrer" : undefined}
className="max-w-full truncate rounded-md border border-violet-400/40 px-2 py-1 text-xs text-violet-200 hover:bg-violet-500/20" className="max-w-full truncate rounded-md border border-violet-300/28 bg-violet-300/8 px-2 py-1 text-xs text-violet-200 hover:bg-violet-500/20"
> >
<span className="mr-1 rounded bg-violet-900/70 px-1 py-0.5 text-[10px] text-violet-100">{citation.index}</span> <span className="mr-1 rounded bg-violet-900/70 px-1 py-0.5 text-[10px] text-violet-100">{citation.index}</span>
{citation.label} {citation.label}
@@ -225,23 +244,23 @@ export function SearchResultsPanel({
<article <article
key={result.id} key={result.id}
className={cn( className={cn(
"rounded-lg border border-border bg-[hsl(276_30%_13%)] px-4 py-4 shadow-sm transition-colors", "rounded-lg border border-violet-300/16 bg-[linear-gradient(135deg,hsl(238_44%_7%_/_0.92),hsl(250_34%_10%_/_0.86))] px-4 py-4 shadow-sm transition-colors",
index === activeResultIndex && "border-violet-300 ring-1 ring-violet-300/80" index === activeResultIndex && "border-violet-300/55 ring-1 ring-violet-300/70"
)} )}
> >
<p className="truncate text-xs text-violet-300/85">{formatHost(result.url)}</p> <p className="truncate text-xs text-cyan-200/85">{formatHost(result.url)}</p>
<a <a
href={result.url} href={result.url}
target={openLinksInNewTab ? "_blank" : undefined} target={openLinksInNewTab ? "_blank" : undefined}
rel={openLinksInNewTab ? "noreferrer" : undefined} rel={openLinksInNewTab ? "noreferrer" : undefined}
className="mt-1 block break-words text-lg font-medium text-violet-300 hover:underline" className="mt-1 block break-words text-lg font-medium text-violet-200 hover:underline"
> >
{result.title || result.url} {result.title || result.url}
</a> </a>
{(result.publishedDate || result.author) && ( {(result.publishedDate || result.author) && (
<p className="mt-1 text-xs text-muted-foreground">{[result.publishedDate, result.author].filter(Boolean).join(" • ")}</p> <p className="mt-1 text-xs text-muted-foreground">{[result.publishedDate, result.author].filter(Boolean).join(" • ")}</p>
)} )}
{result.url ? <p className="mt-2 break-all text-sm leading-6 text-violet-100/90">{result.url}</p> : null} {result.url ? <p className="mt-2 break-all text-sm leading-6 text-violet-100/82">{result.url}</p> : null}
</article> </article>
); );
})} })}

View File

@@ -3,14 +3,16 @@ import type { JSX } from "preact";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", default:
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", "border border-violet-300/35 bg-[linear-gradient(135deg,hsl(252_92%_64%_/_0.95),hsl(274_84%_35%_/_0.95))] text-primary-foreground shadow-sm hover:border-violet-200/55 hover:brightness-110",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", secondary:
ghost: "hover:bg-accent hover:text-accent-foreground", "border border-violet-300/18 bg-secondary/78 text-secondary-foreground shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] hover:border-violet-300/32 hover:bg-secondary",
outline: "border border-input bg-background/76 hover:border-violet-300/45 hover:bg-accent/65 hover:text-accent-foreground",
ghost: "text-muted-foreground hover:bg-accent/65 hover:text-accent-foreground",
}, },
size: { size: {
default: "h-10 px-4 py-2", default: "h-10 px-4 py-2",

View File

@@ -5,7 +5,7 @@ export function Input({ className, ...props }: JSX.InputHTMLAttributes<HTMLInput
return ( return (
<input <input
className={cn( className={cn(
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "flex h-9 w-full rounded-md border border-input bg-background/78 px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
className className
)} )}
{...props} {...props}

View File

@@ -5,7 +5,7 @@ export function Textarea({ className, ...props }: JSX.TextareaHTMLAttributes<HTM
return ( return (
<textarea <textarea
className={cn( className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "flex min-h-[80px] w-full rounded-md border border-input bg-background/78 px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
className className
)} )}
{...props} {...props}

View File

@@ -1,26 +1,31 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Orbitron:wght@700;800;900&display=swap");
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
:root { :root {
--background: 282 33% 8%; color-scheme: dark;
--foreground: 300 35% 95%; --background: 235 45% 4%;
--muted: 287 24% 16%; --foreground: 258 36% 96%;
--muted-foreground: 297 16% 72%; --muted: 246 30% 13%;
--border: 287 24% 24%; --muted-foreground: 247 18% 68%;
--input: 287 24% 24%; --border: 251 35% 20%;
--ring: 264 76% 70%; --input: 252 42% 24%;
--primary: 266 72% 67%; --ring: 258 92% 70%;
--primary-foreground: 296 45% 12%; --primary: 264 93% 66%;
--secondary: 277 24% 19%; --primary-foreground: 265 55% 98%;
--secondary-foreground: 302 42% 94%; --secondary: 244 32% 14%;
--accent: 279 24% 22%; --secondary-foreground: 254 38% 94%;
--accent-foreground: 305 45% 94%; --accent: 188 86% 50%;
--radius: 0.65rem; --accent-foreground: 230 45% 8%;
--radius: 0.5rem;
} }
* { * {
@apply border-border; @apply border-border;
scrollbar-color: hsl(263 92% 68% / 0.42) transparent;
scrollbar-width: thin;
} }
html, html,
@@ -31,8 +36,47 @@ body,
body { body {
@apply bg-background text-foreground antialiased; @apply bg-background text-foreground antialiased;
background-image: radial-gradient(circle at top, hsl(274 42% 18%) 0%, hsl(271 34% 12%) 42%, hsl(266 32% 7%) 100%); background-color: hsl(var(--background));
font-family: "Soehne", "Avenir Next", "Segoe UI", sans-serif; background-image:
linear-gradient(90deg, hsl(187 92% 49% / 0.08), transparent 24%, hsl(264 92% 59% / 0.12) 74%, transparent),
linear-gradient(180deg, hsl(250 60% 16% / 0.68), hsl(235 45% 4%) 48%, hsl(235 54% 3%));
font-family: "Inter", "Avenir Next", "Segoe UI", sans-serif;
}
button,
input,
select,
textarea {
font: inherit;
color-scheme: dark;
}
::selection {
background: hsl(264 92% 68% / 0.45);
color: hsl(258 36% 98%);
}
.sybil-wordmark {
font-family: "Orbitron", "Inter", sans-serif;
font-weight: 900;
letter-spacing: 0;
line-height: 1;
}
.app-grid-surface {
background-image:
linear-gradient(hsl(244 48% 20% / 0.18) 1px, transparent 1px),
linear-gradient(90deg, hsl(244 48% 20% / 0.14) 1px, transparent 1px);
background-size: 48px 48px;
}
.glass-panel {
background:
linear-gradient(180deg, hsl(243 42% 12% / 0.88), hsl(236 48% 5% / 0.92)),
hsl(236 48% 6%);
box-shadow:
inset 0 1px 0 hsl(252 90% 86% / 0.08),
0 14px 36px hsl(240 80% 2% / 0.28);
} }
.md-content { .md-content {

View File

@@ -239,6 +239,14 @@ export async function getSearch(searchId: string) {
return data.search; return data.search;
} }
export async function createChatFromSearch(searchId: string, body?: { title?: string }) {
const data = await api<{ chat: ChatSummary }>(`/v1/searches/${searchId}/chat`, {
method: "POST",
body: JSON.stringify(body ?? {}),
});
return data.chat;
}
export async function deleteSearch(searchId: string) { export async function deleteSearch(searchId: string) {
await api<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" }); await api<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
} }