ios: initial commit
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
551
ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift
Normal file
551
ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift
Normal 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?
|
||||
}
|
||||
126
ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift
Normal file
126
ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
113
ios/Packages/Sybil/Sources/Sybil/SybilConnectionView.swift
Normal file
113
ios/Packages/Sybil/Sources/Sybil/SybilConnectionView.swift
Normal 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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
63
ios/Packages/Sybil/Sources/Sybil/SybilLog.swift
Normal file
63
ios/Packages/Sybil/Sources/Sybil/SybilLog.swift
Normal 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: ".")
|
||||
}
|
||||
}
|
||||
314
ios/Packages/Sybil/Sources/Sybil/SybilModels.swift
Normal file
314
ios/Packages/Sybil/Sources/Sybil/SybilModels.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
222
ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift
Normal file
222
ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
269
ios/Packages/Sybil/Sources/Sybil/SybilSearchResultsView.swift
Normal file
269
ios/Packages/Sybil/Sources/Sybil/SybilSearchResultsView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
73
ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift
Normal file
73
ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
106
ios/Packages/Sybil/Sources/Sybil/SybilSettingsView.swift
Normal file
106
ios/Packages/Sybil/Sources/Sybil/SybilSettingsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
162
ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift
Normal file
162
ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
28
ios/Packages/Sybil/Sources/Sybil/SybilTheme.swift
Normal file
28
ios/Packages/Sybil/Sources/Sybil/SybilTheme.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
1044
ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift
Normal file
1044
ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift
Normal file
File diff suppressed because it is too large
Load Diff
193
ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift
Normal file
193
ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user