ios: initial commit

This commit is contained in:
2026-02-20 00:09:02 -08:00
parent b91b03b74f
commit c47646a48c
24 changed files with 3406 additions and 19 deletions

View File

@@ -14,11 +14,17 @@ let package = Package(
name: "Sybil",
targets: ["Sybil"]),
],
dependencies: [
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui.git", from: "2.4.1")
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "Sybil"),
name: "Sybil",
dependencies: [
.product(name: "MarkdownUI", package: "swift-markdown-ui")
]),
.testTarget(
name: "SybilTests",
dependencies: ["Sybil"]

View File

@@ -1,19 +1,38 @@
//
// SplitView.swift
// Sybil
//
// Created by James Magahern on 2/19/26.
//
import SwiftUI
public struct SplitView: View
{
public init() {
}
public struct SplitView: View {
@State private var viewModel = SybilViewModel()
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
public init() {}
public var body: some View {
Text("Not Yet Implemented: replace me.")
ZStack {
SybilTheme.backgroundGradient
.ignoresSafeArea()
if viewModel.isCheckingSession {
ProgressView("Checking session…")
.tint(SybilTheme.primary)
.foregroundStyle(SybilTheme.textMuted)
} else if !viewModel.isAuthenticated {
SybilConnectionView(viewModel: viewModel)
.padding()
} else if horizontalSizeClass == .compact {
SybilPhoneShellView(viewModel: viewModel)
} else {
NavigationSplitView {
SybilSidebarView(viewModel: viewModel)
.navigationTitle("Sybil")
} detail: {
SybilWorkspaceView(viewModel: viewModel)
}
.navigationSplitViewStyle(.balanced)
.tint(SybilTheme.primary)
}
}
.task {
await viewModel.bootstrap()
}
}
}

View File

@@ -0,0 +1,551 @@
import Foundation
struct APIConfiguration: Sendable {
var baseURL: URL
var authToken: String?
}
struct AnyEncodable: Encodable {
private let encodeClosure: (Encoder) throws -> Void
init<T: Encodable>(_ value: T) {
encodeClosure = value.encode(to:)
}
func encode(to encoder: Encoder) throws {
try encodeClosure(encoder)
}
}
actor SybilAPIClient {
private let configuration: APIConfiguration
private let session: URLSession
private static let iso8601FormatterWithFractional: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private static let iso8601Formatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()
init(configuration: APIConfiguration, session: URLSession = .shared) {
self.configuration = configuration
self.session = session
}
func verifySession() async throws -> AuthSession {
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self)
}
func listChats() async throws -> [ChatSummary] {
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
return response.chats
}
func createChat(title: String? = nil) async throws -> ChatSummary {
let response = try await request(
"/v1/chats",
method: "POST",
body: AnyEncodable(ChatCreateBody(title: title)),
responseType: ChatCreateResponse.self
)
return response.chat
}
func getChat(chatID: String) async throws -> ChatDetail {
let response = try await request("/v1/chats/\(chatID)", method: "GET", responseType: ChatDetailResponse.self)
return response.chat
}
func deleteChat(chatID: String) async throws {
_ = try await request("/v1/chats/\(chatID)", method: "DELETE", responseType: DeleteResponse.self)
}
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary {
let response = try await request(
"/v1/chats/title/suggest",
method: "POST",
body: AnyEncodable(SuggestTitleBody(chatId: chatID, content: content)),
responseType: ChatCreateResponse.self
)
return response.chat
}
func listSearches() async throws -> [SearchSummary] {
let response = try await request("/v1/searches", method: "GET", responseType: SearchListResponse.self)
return response.searches
}
func createSearch(title: String? = nil, query: String? = nil) async throws -> SearchSummary {
let response = try await request(
"/v1/searches",
method: "POST",
body: AnyEncodable(SearchCreateBody(title: title, query: query)),
responseType: SearchCreateResponse.self
)
return response.search
}
func getSearch(searchID: String) async throws -> SearchDetail {
let response = try await request("/v1/searches/\(searchID)", method: "GET", responseType: SearchDetailResponse.self)
return response.search
}
func deleteSearch(searchID: String) async throws {
_ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self)
}
func listModels() async throws -> ModelCatalogResponse {
try await request("/v1/models", method: "GET", responseType: ModelCatalogResponse.self)
}
func runCompletionStream(
body: CompletionStreamRequest,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
) async throws {
let request = try makeRequest(
path: "/v1/chat-completions/stream",
method: "POST",
body: AnyEncodable(body),
acceptsSSE: true
)
SybilLog.info(
SybilLog.network,
"Starting chat stream POST \(request.url?.absoluteString ?? "<unknown>")"
)
try await stream(request: request) { eventName, dataText in
switch eventName {
case "meta":
let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName)
await onEvent(.meta(payload))
case "delta":
let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName)
await onEvent(.delta(payload))
case "done":
do {
let payload: CompletionStreamDone = try Self.decodeEvent(dataText, as: CompletionStreamDone.self, eventName: eventName)
await onEvent(.done(payload))
} catch {
if let recovered = Self.decodeLastJSONLine(dataText, as: CompletionStreamDone.self) {
SybilLog.warning(
SybilLog.network,
"Recovered chat stream done payload from concatenated SSE data"
)
await onEvent(.done(recovered))
} else {
throw error
}
}
case "error":
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
await onEvent(.error(payload))
default:
SybilLog.warning(SybilLog.network, "Ignoring unknown chat stream event '\(eventName)'")
await onEvent(.ignored)
}
}
SybilLog.info(SybilLog.network, "Chat stream completed")
}
func runSearchStream(
searchID: String,
body: SearchRunRequest,
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
) async throws {
let request = try makeRequest(
path: "/v1/searches/\(searchID)/run/stream",
method: "POST",
body: AnyEncodable(body),
acceptsSSE: true
)
SybilLog.info(
SybilLog.network,
"Starting search stream POST \(request.url?.absoluteString ?? "<unknown>")"
)
try await stream(request: request) { eventName, dataText in
switch eventName {
case "search_results":
let payload: SearchResultsPayload = try Self.decodeEvent(dataText, as: SearchResultsPayload.self, eventName: eventName)
await onEvent(.searchResults(payload))
case "search_error":
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
await onEvent(.searchError(payload))
case "answer":
let payload: SearchAnswerPayload = try Self.decodeEvent(dataText, as: SearchAnswerPayload.self, eventName: eventName)
await onEvent(.answer(payload))
case "answer_error":
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
await onEvent(.answerError(payload))
case "done":
let payload: SearchDonePayload = try Self.decodeEvent(dataText, as: SearchDonePayload.self, eventName: eventName)
await onEvent(.done(payload))
case "error":
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
await onEvent(.error(payload))
default:
SybilLog.warning(SybilLog.network, "Ignoring unknown search stream event '\(eventName)'")
await onEvent(.ignored)
}
}
SybilLog.info(SybilLog.network, "Search stream completed")
}
private func request<Response: Decodable>(
_ path: String,
method: String,
body: AnyEncodable? = nil,
responseType: Response.Type
) async throws -> Response {
let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false)
SybilLog.debug(
SybilLog.network,
"HTTP request \(method) \(request.url?.absoluteString ?? "<unknown>")"
)
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch {
if let urlError = error as? URLError, urlError.code == .cancelled {
SybilLog.debug(
SybilLog.network,
"HTTP request cancelled \(method) \(request.url?.absoluteString ?? "<unknown>")"
)
throw CancellationError()
}
let wrapped = Self.wrapTransportError(error, method: method, url: request.url)
SybilLog.error(SybilLog.network, "HTTP transport failure", error: wrapped)
throw wrapped
}
try validate(response: response, data: data)
do {
let decoded = try Self.decodeJSON(Response.self, from: data)
if let httpResponse = response as? HTTPURLResponse {
SybilLog.debug(
SybilLog.network,
"HTTP response \(httpResponse.statusCode) for \(method) \(request.url?.path(percentEncoded: false) ?? path)"
)
}
return decoded
} catch let decodingError as DecodingError {
let details = SybilLog.describe(decodingError)
let snippet = Self.responseSnippet(data)
let message = "Failed to decode response for \(method) \(request.url?.absoluteString ?? path): \(details). Body: \(snippet)"
SybilLog.error(SybilLog.network, message)
throw APIError.decodingError(message: message)
} catch {
SybilLog.error(SybilLog.network, "Unexpected decoding failure", error: error)
throw error
}
}
private func makeRequest(
path: String,
method: String,
body: AnyEncodable?,
acceptsSSE: Bool
) throws -> URLRequest {
let url = try buildURL(path: path)
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = 120
if acceptsSSE {
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
} else {
request.setValue("application/json", forHTTPHeaderField: "Accept")
}
if let token = configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try Self.encodeJSON(body)
}
return request
}
private func buildURL(path: String) throws -> URL {
guard var components = URLComponents(url: configuration.baseURL, resolvingAgainstBaseURL: false) else {
throw APIError.invalidBaseURL
}
let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
var basePath = components.path
if basePath.hasSuffix("/") {
basePath.removeLast()
}
components.path = "\(basePath)/\(trimmedPath)"
guard let url = components.url else {
throw APIError.invalidBaseURL
}
return url
}
private func validate(response: URLResponse, data: Data) throws {
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard (200 ... 299).contains(httpResponse.statusCode) else {
let message = Self.parseMessage(from: data) ?? "\(httpResponse.statusCode) \(HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode))"
SybilLog.warning(
SybilLog.network,
"HTTP non-success status \(httpResponse.statusCode): \(message)"
)
throw APIError.httpError(statusCode: httpResponse.statusCode, message: message)
}
}
private static func parseMessage(from data: Data) -> String? {
guard !data.isEmpty else { return nil }
if let decoded = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let message = decoded["message"] as? String,
!message.isEmpty {
return message
}
return nil
}
private func stream(
request: URLRequest,
onEvent: @escaping @Sendable (_ eventName: String, _ dataText: String) async throws -> Void
) async throws {
let bytes: URLSession.AsyncBytes
let response: URLResponse
do {
(bytes, response) = try await session.bytes(for: request)
} catch {
if let urlError = error as? URLError, urlError.code == .cancelled {
SybilLog.debug(
SybilLog.network,
"SSE request cancelled \(request.httpMethod ?? "GET") \(request.url?.absoluteString ?? "<unknown>")"
)
throw CancellationError()
}
let wrapped = Self.wrapTransportError(error, method: request.httpMethod ?? "GET", url: request.url)
SybilLog.error(SybilLog.network, "SSE transport failure", error: wrapped)
throw wrapped
}
try validate(response: response, data: Data())
var eventName = "message"
var dataLines: [String] = []
var lineBytes: [UInt8] = []
for try await byte in bytes {
if Task.isCancelled {
SybilLog.warning(SybilLog.network, "SSE task cancelled")
throw CancellationError()
}
if byte == 0x0A { // \n
var bytesForLine = lineBytes
if bytesForLine.last == 0x0D { // \r
bytesForLine.removeLast()
}
let line = String(decoding: bytesForLine, as: UTF8.self)
lineBytes.removeAll(keepingCapacity: true)
if line.isEmpty {
if let emitted = Self.flushSSEEvent(eventName: &eventName, dataLines: &dataLines) {
SybilLog.debug(SybilLog.network, "SSE event \(emitted.name) payload chars=\(emitted.payload.count)")
try await onEvent(emitted.name, emitted.payload)
}
continue
}
if line.hasPrefix(":") {
continue
}
if line.hasPrefix("event:") {
if let emitted = Self.flushSSEEvent(eventName: &eventName, dataLines: &dataLines) {
SybilLog.debug(SybilLog.network, "SSE event \(emitted.name) payload chars=\(emitted.payload.count)")
try await onEvent(emitted.name, emitted.payload)
}
eventName = line.dropFirst("event:".count).trimmingCharacters(in: .whitespaces)
continue
}
if line.hasPrefix("data:") {
let payload = line.dropFirst("data:".count)
dataLines.append(Self.trimLeadingWhitespace(payload))
continue
}
SybilLog.debug(SybilLog.network, "Ignoring SSE line '\(String(line.prefix(120)))'")
} else {
lineBytes.append(byte)
}
}
if !lineBytes.isEmpty {
var bytesForLine = lineBytes
if bytesForLine.last == 0x0D { // \r
bytesForLine.removeLast()
}
let line = String(decoding: bytesForLine, as: UTF8.self)
if line.isEmpty {
if let emitted = Self.flushSSEEvent(eventName: &eventName, dataLines: &dataLines) {
SybilLog.debug(SybilLog.network, "SSE event \(emitted.name) payload chars=\(emitted.payload.count)")
try await onEvent(emitted.name, emitted.payload)
}
} else if line.hasPrefix("event:") {
if let emitted = Self.flushSSEEvent(eventName: &eventName, dataLines: &dataLines) {
SybilLog.debug(SybilLog.network, "SSE event \(emitted.name) payload chars=\(emitted.payload.count)")
try await onEvent(emitted.name, emitted.payload)
}
eventName = line.dropFirst("event:".count).trimmingCharacters(in: .whitespaces)
} else if line.hasPrefix("data:") {
let payload = line.dropFirst("data:".count)
dataLines.append(Self.trimLeadingWhitespace(payload))
} else if !line.hasPrefix(":") {
SybilLog.debug(SybilLog.network, "Ignoring SSE line '\(String(line.prefix(120)))'")
}
}
if let emitted = Self.flushSSEEvent(eventName: &eventName, dataLines: &dataLines) {
SybilLog.debug(SybilLog.network, "SSE event \(emitted.name) payload chars=\(emitted.payload.count)")
try await onEvent(emitted.name, emitted.payload)
}
}
private static func decodeJSON<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)
if let value = Self.iso8601FormatterWithFractional.date(from: string) {
return value
}
if let value = Self.iso8601Formatter.date(from: string) {
return value
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Expected ISO-8601 date")
}
return try decoder.decode(T.self, from: data)
}
private static func decodeEvent<T: Decodable>(_ dataText: String, as type: T.Type, eventName: String) throws -> T {
guard let data = dataText.data(using: .utf8) else {
let message = "Failed to decode SSE event '\(eventName)': payload is not UTF-8"
SybilLog.error(SybilLog.network, message)
throw APIError.decodingError(message: message)
}
do {
return try Self.decodeJSON(type, from: data)
} catch let decodingError as DecodingError {
let details = SybilLog.describe(decodingError)
let snippet = dataText.replacingOccurrences(of: "\n", with: " ").prefix(400)
let message = "Failed to decode SSE event '\(eventName)': \(details). Payload: \(snippet)"
SybilLog.error(SybilLog.network, message)
throw APIError.decodingError(message: message)
}
}
private static func decodeLastJSONLine<T: Decodable>(_ dataText: String, as type: T.Type) -> T? {
let lines = dataText
.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard let last = lines.last, let data = last.data(using: .utf8) else {
return nil
}
return try? Self.decodeJSON(type, from: data)
}
private static func flushSSEEvent(
eventName: inout String,
dataLines: inout [String]
) -> (name: String, payload: String)? {
guard !dataLines.isEmpty else {
eventName = "message"
return nil
}
let name = eventName
let payload = dataLines.joined(separator: "\n")
dataLines.removeAll(keepingCapacity: true)
eventName = "message"
return (name, payload)
}
private static func trimLeadingWhitespace(_ text: Substring) -> String {
var index = text.startIndex
while index < text.endIndex, text[index].isWhitespace {
index = text.index(after: index)
}
return String(text[index...])
}
private static func encodeJSON<T: Encodable>(_ value: T) throws -> Data {
let encoder = JSONEncoder()
return try encoder.encode(value)
}
private static func wrapTransportError(_ error: Error, method: String, url: URL?) -> APIError {
if let urlError = error as? URLError {
return APIError.networkError(
message: "Network error \(urlError.code.rawValue) while requesting \(method) \(url?.absoluteString ?? "<unknown>"): \(urlError.localizedDescription)"
)
}
return APIError.networkError(
message: "Network request failed for \(method) \(url?.absoluteString ?? "<unknown>"): \(error.localizedDescription)"
)
}
private static func responseSnippet(_ data: Data) -> String {
guard !data.isEmpty else { return "<empty>" }
if let string = String(data: data, encoding: .utf8) {
let normalized = string.replacingOccurrences(of: "\n", with: " ")
return String(normalized.prefix(500))
}
return "<non-utf8 body, \(data.count) bytes>"
}
}
struct CompletionStreamRequest: Codable, Sendable {
var chatId: String?
var provider: Provider
var model: String
var messages: [CompletionRequestMessage]
}
private struct ChatCreateBody: Encodable {
var title: String?
}
private struct SearchCreateBody: Encodable {
var title: String?
var query: String?
}

View File

@@ -0,0 +1,126 @@
import MarkdownUI
import SwiftUI
struct SybilChatTranscriptView: View {
var messages: [Message]
var isLoading: Bool
var isSending: Bool
private var hasPendingAssistant: Bool {
messages.contains { message in
message.id.hasPrefix("temp-assistant-") && message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 24) {
if isLoading && messages.isEmpty {
Text("Loading messages…")
.font(.footnote)
.foregroundStyle(SybilTheme.textMuted)
.padding(.top, 24)
}
ForEach(messages) { message in
MessageBubble(message: message, isSending: isSending)
.id(message.id)
}
if isSending && !hasPendingAssistant {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.textMuted)
Text("Assistant is typing…")
.font(.footnote)
.foregroundStyle(SybilTheme.textMuted)
}
.id("typing-indicator")
}
Color.clear
.frame(height: 2)
.id("chat-bottom-anchor")
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.vertical, 18)
}
.frame(maxWidth: .infinity, alignment: .leading)
.scrollDismissesKeyboard(.interactively)
.onAppear {
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
}
.onChange(of: messages.map(\.id)) { _, _ in
withAnimation(.easeOut(duration: 0.22)) {
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
}
}
.onChange(of: isSending) { _, _ in
withAnimation(.easeOut(duration: 0.22)) {
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
}
}
}
}
}
private struct MessageBubble: View {
var message: Message
var isSending: Bool
private var isUser: Bool {
message.role == .user
}
private var isPendingAssistant: Bool {
message.id.hasPrefix("temp-assistant-") &&
isSending &&
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 8) {
if isPendingAssistant {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.textMuted)
Text("Thinking…")
.font(.footnote)
.foregroundStyle(SybilTheme.textMuted)
}
.padding(.vertical, 2)
} else {
Markdown(message.content)
.tint(SybilTheme.primary)
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
.markdownTextStyle {
FontSize(15)
}
}
}
.padding(.horizontal, isUser ? 14 : 2)
.padding(.vertical, isUser ? 11 : 2)
.background(
Group {
if isUser {
RoundedRectangle(cornerRadius: 16)
.fill(SybilTheme.userBubble.opacity(0.86))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1)
)
} else {
RoundedRectangle(cornerRadius: 0)
.fill(Color.clear)
}
}
)
.frame(maxWidth: isUser ? 420 : nil, alignment: isUser ? .trailing : .leading)
}
}
}

View File

@@ -0,0 +1,113 @@
import Observation
import SwiftUI
struct SybilConnectionView: View {
@Bindable var viewModel: SybilViewModel
var body: some View {
@Bindable var settings = viewModel.settings
VStack(spacing: 20) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "shield.lefthalf.filled")
.font(.title3.weight(.semibold))
.foregroundStyle(SybilTheme.primary)
.frame(width: 34, height: 34)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(SybilTheme.primary.opacity(0.18))
)
VStack(alignment: .leading, spacing: 4) {
Text("Connect to Sybil")
.font(.title3.weight(.semibold))
.foregroundStyle(SybilTheme.text)
Text("Point the app at your backend and sign in with ADMIN_TOKEN if token mode is enabled.")
.font(.callout)
.foregroundStyle(SybilTheme.textMuted)
.fixedSize(horizontal: false, vertical: true)
}
}
VStack(alignment: .leading, spacing: 10) {
Text("API URL")
.font(.caption.weight(.semibold))
.foregroundStyle(SybilTheme.textMuted)
TextField("http://127.0.0.1:8787/api", text: $settings.apiBaseURL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.surface)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(SybilTheme.border, lineWidth: 1)
)
Text("Admin Token")
.font(.caption.weight(.semibold))
.foregroundStyle(SybilTheme.textMuted)
SecureField("ADMIN_TOKEN (optional in open mode)", text: $settings.adminToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.surface)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(SybilTheme.border, lineWidth: 1)
)
}
VStack(spacing: 10) {
Button {
Task {
await viewModel.refreshAfterSettingsChange()
}
} label: {
Text("Connect")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(SybilTheme.primarySoft)
Button {
settings.adminToken = ""
Task {
await viewModel.refreshAfterSettingsChange()
}
} label: {
Text("Continue Without Token")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(SybilTheme.textMuted)
}
if let authError = viewModel.authError {
Text(authError)
.font(.footnote)
.foregroundStyle(SybilTheme.danger)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(20)
.frame(maxWidth: 520)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(SybilTheme.card)
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(SybilTheme.border, lineWidth: 1)
)
)
}
}

View File

@@ -0,0 +1,63 @@
import Foundation
import OSLog
enum SybilLog {
private static let subsystem = "com.sybil.ios"
static let app = Logger(subsystem: subsystem, category: "app")
static let network = Logger(subsystem: subsystem, category: "network")
static let ui = Logger(subsystem: subsystem, category: "ui")
static func info(_ logger: Logger, _ message: String) {
logger.info("\(message, privacy: .public)")
}
static func debug(_ logger: Logger, _ message: String) {
logger.debug("\(message, privacy: .public)")
}
static func warning(_ logger: Logger, _ message: String) {
logger.warning("\(message, privacy: .public)")
}
static func error(_ logger: Logger, _ message: String) {
logger.error("\(message, privacy: .public)")
}
static func error(_ logger: Logger, _ message: String, error: Error) {
logger.error("\(message, privacy: .public) | \(describe(error), privacy: .public)")
}
static func describe(_ error: Error) -> String {
if let apiError = error as? APIError {
return apiError.localizedDescription
}
if let decodingError = error as? DecodingError {
return describe(decodingError)
}
let nsError = error as NSError
return "\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)"
}
static func describe(_ decodingError: DecodingError) -> String {
switch decodingError {
case let .typeMismatch(type, context):
return "Type mismatch for \(type): \(context.debugDescription) at \(codingPath(context.codingPath))"
case let .valueNotFound(type, context):
return "Value not found for \(type): \(context.debugDescription) at \(codingPath(context.codingPath))"
case let .keyNotFound(key, context):
return "Key \(key.stringValue) not found: \(context.debugDescription) at \(codingPath(context.codingPath))"
case let .dataCorrupted(context):
return "Data corrupted: \(context.debugDescription) at \(codingPath(context.codingPath))"
@unknown default:
return "Unknown decoding error"
}
}
private static func codingPath(_ path: [CodingKey]) -> String {
if path.isEmpty {
return "<root>"
}
return path.map(\.stringValue).joined(separator: ".")
}
}

View File

@@ -0,0 +1,314 @@
import Foundation
public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
case openai
case anthropic
case xai
public var displayName: String {
switch self {
case .openai: return "OpenAI"
case .anthropic: return "Anthropic"
case .xai: return "xAI"
}
}
}
public enum MessageRole: String, Codable, Hashable, Sendable {
case system
case user
case assistant
case tool
}
public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var title: String?
public var createdAt: Date
public var updatedAt: Date
public var initiatedProvider: Provider?
public var initiatedModel: String?
public var lastUsedProvider: Provider?
public var lastUsedModel: String?
}
public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var title: String?
public var query: String?
public var createdAt: Date
public var updatedAt: Date
}
public struct Message: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var createdAt: Date
public var role: MessageRole
public var content: String
public var name: String?
}
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var title: String?
public var createdAt: Date
public var updatedAt: Date
public var initiatedProvider: Provider?
public var initiatedModel: String?
public var lastUsedProvider: Provider?
public var lastUsedModel: String?
public var messages: [Message]
}
public struct SearchResultItem: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var createdAt: Date
public var rank: Int
public var title: String?
public var url: String
public var publishedDate: String?
public var author: String?
public var text: String?
public var highlights: [String]?
public var highlightScores: [Double]?
public var score: Double?
public var favicon: String?
public var image: String?
}
public struct SearchCitation: Codable, Hashable, Sendable {
public var id: String?
public var url: String?
public var title: String?
public var publishedDate: String?
public var author: String?
public var text: String?
}
public struct SearchDetail: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var title: String?
public var query: String?
public var createdAt: Date
public var updatedAt: Date
public var requestId: String?
public var latencyMs: Int?
public var error: String?
public var answerText: String?
public var answerRequestId: String?
public var answerCitations: [SearchCitation]?
public var answerError: String?
public var results: [SearchResultItem]
}
public struct SearchRunRequest: Codable, Sendable {
public var query: String?
public var title: String?
public var type: String?
public var numResults: Int?
public var includeDomains: [String]?
public var excludeDomains: [String]?
public init(
query: String? = nil,
title: String? = nil,
type: String? = nil,
numResults: Int? = nil,
includeDomains: [String]? = nil,
excludeDomains: [String]? = nil
) {
self.query = query
self.title = title
self.type = type
self.numResults = numResults
self.includeDomains = includeDomains
self.excludeDomains = excludeDomains
}
}
public struct CompletionRequestMessage: Codable, Sendable {
public var role: MessageRole
public var content: String
public var name: String?
public init(role: MessageRole, content: String, name: String? = nil) {
self.role = role
self.content = content
self.name = name
}
}
public struct CompletionStreamMeta: Codable, Sendable {
public var chatId: String
public var callId: String
public var provider: Provider
public var model: String
}
public struct CompletionStreamDelta: Codable, Sendable {
public var text: String
}
public struct CompletionStreamDone: Codable, Sendable {
public var text: String
}
public struct StreamErrorPayload: Codable, Sendable {
public var message: String
}
public enum CompletionStreamEvent: Sendable {
case meta(CompletionStreamMeta)
case delta(CompletionStreamDelta)
case done(CompletionStreamDone)
case error(StreamErrorPayload)
case ignored
}
public struct SearchResultsPayload: Codable, Sendable {
public var requestId: String?
public var results: [SearchResultItem]
}
public struct SearchErrorPayload: Codable, Sendable {
public var error: String
}
public struct SearchAnswerPayload: Codable, Sendable {
public var answerText: String?
public var answerRequestId: String?
public var answerCitations: [SearchCitation]?
}
public struct SearchDonePayload: Codable, Sendable {
public var search: SearchDetail
}
public enum SearchStreamEvent: Sendable {
case searchResults(SearchResultsPayload)
case searchError(SearchErrorPayload)
case answer(SearchAnswerPayload)
case answerError(SearchErrorPayload)
case done(SearchDonePayload)
case error(StreamErrorPayload)
case ignored
}
public struct ProviderModelInfo: Codable, Hashable, Sendable {
public var models: [String]
public var loadedAt: Date?
public var error: String?
}
public struct ModelCatalogResponse: Codable, Hashable, Sendable {
public var providers: [Provider: ProviderModelInfo]
enum CodingKeys: String, CodingKey {
case providers
}
public init(providers: [Provider: ProviderModelInfo]) {
self.providers = providers
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let rawProviders = try container.decode([String: ProviderModelInfo].self, forKey: .providers)
var mapped: [Provider: ProviderModelInfo] = [:]
for (key, value) in rawProviders {
if let provider = Provider(rawValue: key) {
mapped[provider] = value
}
}
self.providers = mapped
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
let raw = Dictionary(uniqueKeysWithValues: providers.map { ($0.key.rawValue, $0.value) })
try container.encode(raw, forKey: .providers)
}
}
struct AuthSession: Codable {
var authenticated: Bool
var mode: String
}
struct ChatListResponse: Codable {
var chats: [ChatSummary]
}
struct SearchListResponse: Codable {
var searches: [SearchSummary]
}
struct ChatDetailResponse: Codable {
var chat: ChatDetail
}
struct SearchDetailResponse: Codable {
var search: SearchDetail
}
struct ChatCreateResponse: Codable {
var chat: ChatSummary
}
struct SearchCreateResponse: Codable {
var search: SearchSummary
}
struct DeleteResponse: Codable {
var deleted: Bool
}
struct SuggestTitleBody: Codable {
var chatId: String
var content: String
}
enum APIError: LocalizedError {
case invalidBaseURL
case httpError(statusCode: Int, message: String)
case networkError(message: String)
case decodingError(message: String)
case invalidResponse
case noResponseStream
var errorDescription: String? {
switch self {
case .invalidBaseURL:
return "Invalid API URL"
case let .httpError(_, message):
return message
case let .networkError(message):
return message
case let .decodingError(message):
return message
case .invalidResponse:
return "Unexpected server response"
case .noResponseStream:
return "No response stream"
}
}
}
extension DateFormatter {
static let sybilDisplayDate: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = .autoupdatingCurrent
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter
}()
}
extension Date {
var sybilRelativeLabel: String {
let formatter = RelativeDateTimeFormatter()
formatter.locale = .autoupdatingCurrent
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: self, relativeTo: Date())
}
}

View File

@@ -0,0 +1,222 @@
import Observation
import SwiftUI
enum PhoneRoute: Hashable {
case chat(String)
case search(String)
case draftChat
case draftSearch
case settings
static func from(selection: SidebarSelection) -> PhoneRoute {
switch selection {
case let .chat(chatID):
return .chat(chatID)
case let .search(searchID):
return .search(searchID)
case .settings:
return .settings
}
}
}
struct SybilPhoneShellView: View {
@Bindable var viewModel: SybilViewModel
@State private var path: [PhoneRoute] = []
var body: some View {
NavigationStack(path: $path) {
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
.navigationTitle("Sybil")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(for: PhoneRoute.self) { route in
SybilPhoneDestinationView(viewModel: viewModel, route: route)
}
}
.tint(SybilTheme.primary)
}
}
private struct SybilPhoneSidebarRoot: View {
@Bindable var viewModel: SybilViewModel
@Binding var path: [PhoneRoute]
var body: some View {
VStack(spacing: 0) {
buttonRow
Divider()
.overlay(SybilTheme.border)
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(SybilTheme.danger)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 10)
Divider()
.overlay(SybilTheme.border)
}
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ProgressView()
.tint(SybilTheme.primary)
Text("Loading conversations…")
.font(.footnote)
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(16)
} else if viewModel.sidebarItems.isEmpty {
VStack(spacing: 10) {
Image(systemName: "message.badge")
.font(.title3)
.foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.")
.font(.footnote)
.multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(viewModel.sidebarItems) { item in
NavigationLink(value: PhoneRoute.from(selection: item.selection)) {
SybilPhoneSidebarRow(item: item)
}
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
Task {
await viewModel.deleteItem(item.selection)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.padding(10)
}
}
Divider()
.overlay(SybilTheme.border)
NavigationLink(value: PhoneRoute.settings) {
Label("Settings", systemImage: "gearshape")
.font(.subheadline.weight(.medium))
.foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.padding(10)
}
.background(SybilTheme.surfaceStrong.opacity(0.84))
}
private var buttonRow: some View {
HStack(spacing: 10) {
Button {
viewModel.startNewChat()
path.append(.draftChat)
} label: {
Label("New chat", systemImage: "plus")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(SybilTheme.primarySoft)
Button {
viewModel.startNewSearch()
path.append(.draftSearch)
} label: {
Label("New search", systemImage: "magnifyingglass")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(SybilTheme.textMuted)
}
.padding(.horizontal, 12)
.padding(.vertical, 12)
}
}
private struct SybilPhoneSidebarRow: View {
var item: SidebarItem
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: item.kind == .chat ? "message" : "globe")
.font(.caption.weight(.semibold))
Text(item.title)
.font(.subheadline.weight(.medium))
.lineLimit(1)
}
HStack(spacing: 8) {
Text(item.updatedAt.sybilRelativeLabel)
.font(.caption2)
.foregroundStyle(SybilTheme.textMuted)
if let initiated = item.initiatedLabel {
Spacer(minLength: 0)
Text(initiated)
.font(.caption2)
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
.lineLimit(1)
}
}
}
.foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.surface.opacity(0.55))
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
)
}
}
private struct SybilPhoneDestinationView: View {
@Bindable var viewModel: SybilViewModel
let route: PhoneRoute
var body: some View {
SybilWorkspaceView(viewModel: viewModel)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.navigationBarTitleDisplayMode(.inline)
.task(id: route) {
applyRoute()
}
}
private func applyRoute() {
switch route {
case let .chat(chatID):
viewModel.select(.chat(chatID))
case let .search(searchID):
viewModel.select(.search(searchID))
case .draftChat:
viewModel.startNewChat()
case .draftSearch:
viewModel.startNewSearch()
case .settings:
viewModel.openSettings()
}
}
}

View File

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

View File

@@ -0,0 +1,73 @@
import Foundation
import Observation
@MainActor
@Observable
final class SybilSettingsStore {
private enum Keys {
static let apiBaseURL = "sybil.ios.apiBaseURL"
static let adminToken = "sybil.ios.adminToken"
static let preferredProvider = "sybil.ios.preferredProvider"
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
static let preferredXAIModel = "sybil.ios.preferredXAIModel"
}
private let defaults: UserDefaults
var apiBaseURL: String
var adminToken: String
var preferredProvider: Provider
var preferredModelByProvider: [Provider: String]
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
let storedBaseURL = defaults.string(forKey: Keys.apiBaseURL)?.trimmingCharacters(in: .whitespacesAndNewlines)
let fallbackBaseURL = "http://127.0.0.1:8787/api"
self.apiBaseURL = storedBaseURL?.isEmpty == false ? storedBaseURL! : fallbackBaseURL
self.adminToken = defaults.string(forKey: Keys.adminToken) ?? ""
let provider = defaults.string(forKey: Keys.preferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
self.preferredProvider = provider
self.preferredModelByProvider = [
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini"
]
}
func persist() {
defaults.set(apiBaseURL.trimmingCharacters(in: .whitespacesAndNewlines), forKey: Keys.apiBaseURL)
let trimmedToken = adminToken.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedToken.isEmpty {
defaults.removeObject(forKey: Keys.adminToken)
} else {
defaults.set(trimmedToken, forKey: Keys.adminToken)
}
defaults.set(preferredProvider.rawValue, forKey: Keys.preferredProvider)
defaults.set(preferredModelByProvider[.openai], forKey: Keys.preferredOpenAIModel)
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
}
var trimmedTokenOrNil: String? {
let value = adminToken.trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
var normalizedAPIBaseURL: URL? {
var raw = apiBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard !raw.isEmpty else { return nil }
if raw.hasSuffix("/") {
raw.removeLast()
}
return URL(string: raw)
}
}

View File

@@ -0,0 +1,106 @@
import Observation
import SwiftUI
struct SybilSettingsView: View {
@Bindable var viewModel: SybilViewModel
var body: some View {
@Bindable var settings = viewModel.settings
ScrollView {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 6) {
Text("Connection")
.font(.title3.weight(.semibold))
.foregroundStyle(SybilTheme.text)
Text("Use the same API root as the web client. Example: `http://127.0.0.1:8787/api`")
.font(.footnote)
.foregroundStyle(SybilTheme.textMuted)
}
VStack(alignment: .leading, spacing: 8) {
Text("API URL")
.font(.caption.weight(.semibold))
.foregroundStyle(SybilTheme.textMuted)
TextField("http://127.0.0.1:8787/api", text: $settings.apiBaseURL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(SybilTheme.border, lineWidth: 1)
)
)
Text("Admin Token")
.font(.caption.weight(.semibold))
.foregroundStyle(SybilTheme.textMuted)
SecureField("ADMIN_TOKEN (optional in open mode)", text: $settings.adminToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(SybilTheme.border, lineWidth: 1)
)
)
}
HStack(spacing: 10) {
Button {
Task {
await viewModel.refreshAfterSettingsChange()
}
} label: {
Text("Save & Reconnect")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(SybilTheme.primarySoft)
Button {
settings.adminToken = ""
Task {
await viewModel.refreshAfterSettingsChange()
}
} label: {
Text("No Token")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(SybilTheme.textMuted)
}
if let mode = viewModel.authMode {
Label(mode == "open" ? "Server is in open mode" : "Server requires token", systemImage: "dot.radiowaves.left.and.right")
.font(.footnote)
.foregroundStyle(SybilTheme.textMuted)
}
if let authError = viewModel.authError {
Text(authError)
.font(.footnote)
.foregroundStyle(SybilTheme.danger)
}
if let runtimeError = viewModel.errorMessage {
Text(runtimeError)
.font(.footnote)
.foregroundStyle(SybilTheme.danger)
}
}
.padding(16)
.frame(maxWidth: 640, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .center)
}
}
}

View File

@@ -0,0 +1,162 @@
import Observation
import SwiftUI
struct SybilSidebarView: View {
@Bindable var viewModel: SybilViewModel
private func iconName(for item: SidebarItem) -> String {
switch item.kind {
case .chat: return "message"
case .search: return "globe"
}
}
private func isSelected(_ item: SidebarItem) -> Bool {
viewModel.draftKind == nil && viewModel.selectedItem == item.selection
}
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 10) {
Button {
viewModel.startNewChat()
} label: {
Label("New chat", systemImage: "plus")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(viewModel.draftKind == .chat ? SybilTheme.primary : SybilTheme.primarySoft)
Button {
viewModel.startNewSearch()
} label: {
Label("New search", systemImage: "magnifyingglass")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(viewModel.draftKind == .search ? SybilTheme.primary : SybilTheme.textMuted)
}
.padding(.horizontal, 12)
.padding(.top, 12)
.padding(.bottom, 10)
Divider()
.overlay(SybilTheme.border)
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(SybilTheme.danger)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 10)
Divider()
.overlay(SybilTheme.border)
}
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ProgressView()
.tint(SybilTheme.primary)
Text("Loading conversations…")
.font(.footnote)
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(16)
} else if viewModel.sidebarItems.isEmpty {
VStack(spacing: 10) {
Image(systemName: "message.badge")
.font(.title3)
.foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.")
.font(.footnote)
.multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(viewModel.sidebarItems) { item in
Button {
viewModel.select(item.selection)
} label: {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: iconName(for: item))
.font(.caption.weight(.semibold))
Text(item.title)
.font(.subheadline.weight(.medium))
.lineLimit(1)
}
HStack(spacing: 8) {
Text(item.updatedAt.sybilRelativeLabel)
.font(.caption2)
.foregroundStyle(SybilTheme.textMuted)
if let initiated = item.initiatedLabel {
Spacer(minLength: 0)
Text(initiated)
.font(.caption2)
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
.lineLimit(1)
}
}
}
.foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isSelected(item) ? SybilTheme.primary.opacity(0.28) : SybilTheme.surface.opacity(0.4))
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isSelected(item) ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1)
)
}
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
Task {
await viewModel.deleteItem(item.selection)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.padding(10)
}
}
Divider()
.overlay(SybilTheme.border)
Button {
viewModel.openSettings()
} label: {
Label("Settings", systemImage: "gearshape")
.font(.subheadline.weight(.medium))
.foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(viewModel.selectedItem == .settings ? SybilTheme.primary.opacity(0.28) : Color.clear)
)
}
.buttonStyle(.plain)
.padding(10)
}
.background(SybilTheme.surfaceStrong.opacity(0.84))
}
}

View File

@@ -0,0 +1,28 @@
import SwiftUI
enum SybilTheme {
static let background = Color(red: 0.07, green: 0.05, blue: 0.11)
static let surface = Color(red: 0.14, green: 0.09, blue: 0.19)
static let surfaceStrong = Color(red: 0.17, green: 0.10, blue: 0.23)
static let card = Color(red: 0.12, green: 0.08, blue: 0.17)
static let border = Color(red: 0.30, green: 0.22, blue: 0.39)
static let primary = Color(red: 0.66, green: 0.47, blue: 0.94)
static let primarySoft = Color(red: 0.53, green: 0.37, blue: 0.78)
static let text = Color(red: 0.95, green: 0.91, blue: 0.98)
static let textMuted = Color(red: 0.72, green: 0.65, blue: 0.80)
static let searchCard = Color(red: 0.16, green: 0.10, blue: 0.22)
static let userBubble = Color(red: 0.35, green: 0.20, blue: 0.62)
static let danger = Color(red: 0.93, green: 0.38, blue: 0.42)
static var backgroundGradient: LinearGradient {
LinearGradient(
colors: [
Color(red: 0.27, green: 0.12, blue: 0.36),
Color(red: 0.15, green: 0.09, blue: 0.23),
Color(red: 0.07, green: 0.05, blue: 0.11)
],
startPoint: .top,
endPoint: .bottom
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,193 @@
import Observation
import SwiftUI
struct SybilWorkspaceView: View {
@Bindable var viewModel: SybilViewModel
@FocusState private var composerFocused: Bool
private var isSettingsSelected: Bool {
if case .settings = viewModel.selectedItem {
return true
}
return false
}
var body: some View {
VStack(spacing: 0) {
header
Divider()
.overlay(SybilTheme.border)
Group {
if isSettingsSelected {
SybilSettingsView(viewModel: viewModel)
} else if viewModel.isSearchMode {
SybilSearchResultsView(
search: viewModel.selectedSearch,
isLoading: viewModel.isLoadingSelection,
isRunning: viewModel.isSending
)
} else {
SybilChatTranscriptView(
messages: viewModel.displayedMessages,
isLoading: viewModel.isLoadingSelection,
isSending: viewModel.isSending
)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
if viewModel.showsComposer {
Divider()
.overlay(SybilTheme.border)
composerBar
}
}
.navigationTitle(viewModel.selectedTitle)
.background(SybilTheme.background)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onChange(of: viewModel.isSending) { _, isSending in
if !isSending, viewModel.showsComposer {
composerFocused = true
}
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
Spacer()
if !viewModel.isSearchMode && !isSettingsSelected {
providerControls
} else if viewModel.isSearchMode {
Label("Search mode", systemImage: "globe")
.font(.caption.weight(.medium))
.foregroundStyle(SybilTheme.textMuted)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(
Capsule()
.fill(SybilTheme.surface)
.overlay(
Capsule()
.stroke(SybilTheme.border, lineWidth: 1)
)
)
}
}
if let error = viewModel.errorMessage {
Text(error)
.font(.footnote)
.foregroundStyle(SybilTheme.danger)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private var providerControls: some View {
HStack(spacing: 8) {
Menu {
ForEach(Provider.allCases, id: \.self) { candidate in
Button(candidate.displayName) {
viewModel.setProvider(candidate)
}
}
} label: {
Label(viewModel.provider.displayName, systemImage: "chevron.down")
.labelStyle(.titleAndIcon)
.font(.caption.weight(.medium))
.foregroundStyle(SybilTheme.text)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(SybilTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(SybilTheme.border, lineWidth: 1)
)
)
}
Menu {
ForEach(viewModel.providerModelOptions, id: \.self) { model in
Button(model) {
viewModel.setModel(model)
}
}
} label: {
Label(viewModel.model, systemImage: "chevron.down")
.labelStyle(.titleAndIcon)
.font(.caption.weight(.medium))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(SybilTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(SybilTheme.border, lineWidth: 1)
)
)
}
}
}
private var composerBar: some View {
HStack(alignment: .bottom, spacing: 10) {
TextField(
viewModel.isSearchMode ? "Search the web" : "Message Sybil",
text: $viewModel.composer,
axis: .vertical
)
.focused($composerFocused)
.textInputAutocapitalization(.sentences)
.autocorrectionDisabled(false)
.lineLimit(1 ... 6)
.submitLabel(.send)
.onSubmit {
Task {
await viewModel.sendComposer()
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(SybilTheme.border, lineWidth: 1)
)
)
.foregroundStyle(SybilTheme.text)
Button {
Task {
await viewModel.sendComposer()
}
} label: {
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
.font(.headline.weight(.semibold))
.frame(width: 40, height: 40)
.background(
Circle()
.fill(viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending ? SybilTheme.surface : SybilTheme.primary)
)
.foregroundStyle(viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending ? SybilTheme.textMuted : SybilTheme.text)
}
.buttonStyle(.plain)
.disabled(viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(SybilTheme.background)
}
}