diff --git a/ios/AGENTS.md b/ios/AGENTS.md new file mode 100644 index 0000000..4632039 --- /dev/null +++ b/ios/AGENTS.md @@ -0,0 +1,41 @@ +# AGENTS.md (iOS) + +## Scope +Instructions for work under `/Users/buzzert/src/sybil-2/ios`. + +## Build + Run +- Preferred build command: `just build` +- `just build` will: + 1. generate `Sybil.xcodeproj` with `xcodegen` if missing, + 2. build scheme `Sybil` for `iPhone 16e` simulator. +- If `xcbeautify` is installed it is used automatically; otherwise raw `xcodebuild` output is used. + +## App Structure +- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift` +- Shared iOS app code lives in Swift package: + - `/Users/buzzert/src/sybil-2/ios/Packages/Sybil/Sources/Sybil` +- Main UI root: `SplitView.swift` +- Networking + SSE client: `SybilAPIClient.swift` +- State coordinator: `SybilViewModel.swift` + +## Product Expectations +- Keep the iOS design aligned to the web app dark aesthetic (no light mode support required). +- Preserve these core features: + - conversation/search list, + - streaming chat transcript, + - streaming search results + answer, + - settings screen for API URL and token. +- Markdown rendering currently uses `MarkdownUI` via SwiftPM. + +## API Contract +- iOS client must follow docs in: + - `/Users/buzzert/src/sybil-2/docs/api/rest.md` + - `/Users/buzzert/src/sybil-2/docs/api/streaming-chat.md` +- If backend contract changes (request/response shapes, SSE events, auth semantics), update docs in the same change. + +## Practical Notes +- Default API URL is `http://127.0.0.1:8787/api` (configurable in-app). +- Provider fallback models: + - OpenAI: `gpt-4.1-mini` + - Anthropic: `claude-3-5-sonnet-latest` + - xAI: `grok-3-mini` diff --git a/ios/Apps/Sybil/Resources/.gitkeep b/ios/Apps/Sybil/Resources/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ios/Apps/Sybil/Resources/Artwork.xcassets/AppIcon.appiconset/Contents.json b/ios/Apps/Sybil/Resources/Artwork.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..374a543 --- /dev/null +++ b/ios/Apps/Sybil/Resources/Artwork.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "buzzert_iOS_app_icon_prophet_Sibyl_simple_minimal_mystic_blue_b_08983f8f-941b-4ff3-abb4-9df3f3f7612d.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Apps/Sybil/Resources/Artwork.xcassets/AppIcon.appiconset/buzzert_iOS_app_icon_prophet_Sibyl_simple_minimal_mystic_blue_b_08983f8f-941b-4ff3-abb4-9df3f3f7612d.png b/ios/Apps/Sybil/Resources/Artwork.xcassets/AppIcon.appiconset/buzzert_iOS_app_icon_prophet_Sibyl_simple_minimal_mystic_blue_b_08983f8f-941b-4ff3-abb4-9df3f3f7612d.png new file mode 100644 index 0000000..cbfafa3 Binary files /dev/null and b/ios/Apps/Sybil/Resources/Artwork.xcassets/AppIcon.appiconset/buzzert_iOS_app_icon_prophet_Sibyl_simple_minimal_mystic_blue_b_08983f8f-941b-4ff3-abb4-9df3f3f7612d.png differ diff --git a/ios/Apps/Sybil/Resources/Artwork.xcassets/Contents.json b/ios/Apps/Sybil/Resources/Artwork.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/Apps/Sybil/Resources/Artwork.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Apps/Sybil/project.yml b/ios/Apps/Sybil/project.yml index ad74bf1..426e40d 100644 --- a/ios/Apps/Sybil/project.yml +++ b/ios/Apps/Sybil/project.yml @@ -1,7 +1,3 @@ -packages: - Sybil: - path: Packages/Sybil - targets: SybilApp: type: application @@ -9,6 +5,7 @@ targets: deploymentTarget: "18.0" sources: - Sources + - Resources dependencies: - package: Sybil product: Sybil @@ -21,6 +18,7 @@ targets: SWIFT_VERSION: 6.0 TARGETED_DEVICE_FAMILY: "1,2" GENERATE_INFOPLIST_FILE: YES + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon MARKETING_VERSION: 1.0 CURRENT_PROJECT_VERSION: 1 INFOPLIST_KEY_CFBundleDisplayName: Sybil diff --git a/ios/Packages/Sybil/Package.swift b/ios/Packages/Sybil/Package.swift index d972b92..50947c7 100644 --- a/ios/Packages/Sybil/Package.swift +++ b/ios/Packages/Sybil/Package.swift @@ -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"] diff --git a/ios/Packages/Sybil/Sources/Sybil/SplitView.swift b/ios/Packages/Sybil/Sources/Sybil/SplitView.swift index d16b8e6..4471ce7 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SplitView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SplitView.swift @@ -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() + } } } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift new file mode 100644 index 0000000..5fff944 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift @@ -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(_ 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 ?? "")" + ) + + 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 ?? "")" + ) + + 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( + _ 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 ?? "")" + ) + + 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 ?? "")" + ) + 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 ?? "")" + ) + 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(_ 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(_ 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(_ 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(_ 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 ?? ""): \(urlError.localizedDescription)" + ) + } + + return APIError.networkError( + message: "Network request failed for \(method) \(url?.absoluteString ?? ""): \(error.localizedDescription)" + ) + } + + private static func responseSnippet(_ data: Data) -> String { + guard !data.isEmpty else { return "" } + if let string = String(data: data, encoding: .utf8) { + let normalized = string.replacingOccurrences(of: "\n", with: " ") + return String(normalized.prefix(500)) + } + return "" + } +} + +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? +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift new file mode 100644 index 0000000..73d0ef3 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift @@ -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) + } + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilConnectionView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilConnectionView.swift new file mode 100644 index 0000000..28495a8 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilConnectionView.swift @@ -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) + ) + ) + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilLog.swift b/ios/Packages/Sybil/Sources/Sybil/SybilLog.swift new file mode 100644 index 0000000..82a52b9 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilLog.swift @@ -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 "" + } + return path.map(\.stringValue).joined(separator: ".") + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift new file mode 100644 index 0000000..c382931 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift @@ -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()) + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift new file mode 100644 index 0000000..e3b55bf --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift @@ -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() + } + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSearchResultsView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSearchResultsView.swift new file mode 100644 index 0000000..54d902a --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilSearchResultsView.swift @@ -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: 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 + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift new file mode 100644 index 0000000..cdcc357 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift @@ -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) + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSettingsView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSettingsView.swift new file mode 100644 index 0000000..c90799f --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilSettingsView.swift @@ -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) + } + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift new file mode 100644 index 0000000..daaebac --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift @@ -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)) + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilTheme.swift b/ios/Packages/Sybil/Sources/Sybil/SybilTheme.swift new file mode 100644 index 0000000..0bae438 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilTheme.swift @@ -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 + ) + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift new file mode 100644 index 0000000..4024268 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -0,0 +1,1044 @@ +import Foundation +import Observation + +enum DraftKind { + case chat + case search +} + +enum SidebarSelection: Hashable { + case chat(String) + case search(String) + case settings + + var id: String { + switch self { + case let .chat(chatID): + return "chat:\(chatID)" + case let .search(searchID): + return "search:\(searchID)" + case .settings: + return "settings" + } + } +} + +struct SidebarItem: Identifiable, Hashable { + enum Kind: Hashable { + case chat + case search + } + + var id: String { selection.id } + var selection: SidebarSelection + var kind: Kind + var title: String + var updatedAt: Date + var initiatedLabel: String? +} + +private struct PendingChatState { + var chatID: String? + var messages: [Message] +} + +private actor CompletionStreamStatus { + private var streamError: String? + + func setError(_ value: String) { + streamError = value + } + + func error() -> String? { + streamError + } +} + +private actor SearchStreamStatus { + private var streamError: String? + + func setError(_ value: String) { + streamError = value + } + + func error() -> String? { + streamError + } +} + +@MainActor +@Observable +final class SybilViewModel { + let settings: SybilSettingsStore + + var isCheckingSession = true + var isAuthenticated = false + var authMode: String? + var authError: String? + + var chats: [ChatSummary] = [] + var searches: [SearchSummary] = [] + + var selectedItem: SidebarSelection? + var selectedChat: ChatDetail? + var selectedSearch: SearchDetail? + var draftKind: DraftKind? + + var isLoadingCollections = false + var isLoadingSelection = false + var isSending = false + var errorMessage: String? + + var composer = "" + var provider: Provider + var modelCatalog: [Provider: ProviderModelInfo] = [:] + var model: String + + private var hasBootstrapped = false + private var pendingChatState: PendingChatState? + private var selectionTask: Task? + + private let fallbackModels: [Provider: [String]] = [ + .openai: ["gpt-4.1-mini"], + .anthropic: ["claude-3-5-sonnet-latest"], + .xai: ["grok-3-mini"] + ] + + init(settings: SybilSettingsStore = SybilSettingsStore()) { + self.settings = settings + self.provider = settings.preferredProvider + self.model = settings.preferredModelByProvider[settings.preferredProvider] ?? "gpt-4.1-mini" + } + + var providerModelOptions: [String] { + let serverModels = modelCatalog[provider]?.models ?? [] + if !serverModels.isEmpty { + return serverModels + } + return fallbackModels[provider] ?? [] + } + + var selectedTitle: String { + if case .settings = selectedItem { + return "Settings" + } + if draftKind == .chat { + return "New chat" + } + if draftKind == .search { + return "New search" + } + + guard let selectedItem else { + return "Sybil" + } + + switch selectedItem { + case .chat: + if let selectedChat { + return chatTitle(title: selectedChat.title, messages: selectedChat.messages) + } + if let summary = selectedChatSummary { + return chatTitle(title: summary.title, messages: nil) + } + return "Chat" + + case .search: + if let selectedSearch { + return searchTitle(title: selectedSearch.title, query: selectedSearch.query) + } + if let summary = selectedSearchSummary { + return searchTitle(title: summary.title, query: summary.query) + } + return "Search" + + case .settings: + return "Settings" + } + } + + var subtitle: String { + if case .settings = selectedItem { + return "Configure API connectivity" + } + + let modeLabel: String + if authMode == "open" { + modeLabel = "open mode" + } else if authMode == "token" { + modeLabel = "token mode" + } else { + modeLabel = "offline" + } + + if isSearchMode { + return "Sybil iOS (\(modeLabel)) • Exa Search" + } + return "Sybil iOS (\(modeLabel))" + } + + var isSearchMode: Bool { + if draftKind == .search { + return true + } + if draftKind == .chat { + return false + } + if case .search = selectedItem { + return true + } + return false + } + + var showsComposer: Bool { + if case .settings = selectedItem { + return false + } + return draftKind != nil || selectedItem != nil + } + + var displayedMessages: [Message] { + let canonical = selectedChat?.messages ?? [] + guard let pending = pendingChatState else { + return canonical + } + + if let pendingID = pending.chatID { + if case let .chat(selectedID) = selectedItem, selectedID == pendingID { + return pending.messages + } + return canonical + } + + if draftKind == .chat { + return pending.messages + } + + return canonical + } + + var sidebarItems: [SidebarItem] { + let chatItems: [SidebarItem] = chats.map { chat in + let initiatedLabel: String? + if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty { + if let provider = chat.initiatedProvider { + initiatedLabel = "\(provider.displayName) · \(model)" + } else { + initiatedLabel = model + } + } else { + initiatedLabel = nil + } + + return SidebarItem( + selection: .chat(chat.id), + kind: .chat, + title: chatTitle(title: chat.title, messages: nil), + updatedAt: chat.updatedAt, + initiatedLabel: initiatedLabel + ) + } + + let searchItems: [SidebarItem] = searches.map { search in + SidebarItem( + selection: .search(search.id), + kind: .search, + title: searchTitle(title: search.title, query: search.query), + updatedAt: search.updatedAt, + initiatedLabel: nil + ) + } + + return (chatItems + searchItems).sorted { $0.updatedAt > $1.updatedAt } + } + + var selectedChatSummary: ChatSummary? { + guard case let .chat(chatID) = selectedItem else { return nil } + return chats.first(where: { $0.id == chatID }) + } + + var selectedSearchSummary: SearchSummary? { + guard case let .search(searchID) = selectedItem else { return nil } + return searches.first(where: { $0.id == searchID }) + } + + func bootstrap() async { + guard !hasBootstrapped else { + return + } + + SybilLog.info(SybilLog.app, "Bootstrapping Sybil iOS session") + hasBootstrapped = true + await reconnect() + } + + func reconnect() async { + isCheckingSession = true + authError = nil + errorMessage = nil + pendingChatState = nil + settings.persist() + + SybilLog.info( + SybilLog.app, + "Reconnecting with API base URL \(settings.normalizedAPIBaseURL?.absoluteString ?? "")" + ) + + do { + let client = try client() + let session = try await client.verifySession() + isAuthenticated = session.authenticated + authMode = session.mode + authError = nil + + SybilLog.info( + SybilLog.app, + "Session verified (authenticated=\(session.authenticated), mode=\(session.mode))" + ) + + await loadInitialData(using: client) + } catch { + isAuthenticated = false + authMode = nil + chats = [] + searches = [] + selectedItem = .settings + selectedChat = nil + selectedSearch = nil + draftKind = nil + authError = normalizeAuthError(error) + SybilLog.error(SybilLog.app, "Reconnect failed", error: error) + } + + isCheckingSession = false + } + + func setProvider(_ nextProvider: Provider) { + provider = nextProvider + + let preferred = settings.preferredModelByProvider[nextProvider] + let options = providerModelOptions + if let preferred, options.contains(preferred) { + model = preferred + } else if let first = options.first { + model = first + } + + settings.preferredProvider = nextProvider + settings.persist() + SybilLog.info(SybilLog.ui, "Provider changed to \(nextProvider.rawValue), model=\(model)") + } + + func setModel(_ nextModel: String) { + model = nextModel + settings.preferredModelByProvider[provider] = nextModel + settings.persist() + SybilLog.info(SybilLog.ui, "Model changed to \(nextModel)") + } + + func startNewChat() { + SybilLog.debug(SybilLog.ui, "Starting draft chat") + draftKind = .chat + selectedItem = nil + selectedChat = nil + selectedSearch = nil + errorMessage = nil + composer = "" + } + + func startNewSearch() { + SybilLog.debug(SybilLog.ui, "Starting draft search") + draftKind = .search + selectedItem = nil + selectedChat = nil + selectedSearch = nil + errorMessage = nil + composer = "" + } + + func openSettings() { + SybilLog.debug(SybilLog.ui, "Opening settings") + draftKind = nil + selectedItem = .settings + selectedChat = nil + selectedSearch = nil + errorMessage = nil + } + + func select(_ selection: SidebarSelection) { + SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)") + draftKind = nil + selectedItem = selection + errorMessage = nil + + if case .settings = selection { + selectedChat = nil + selectedSearch = nil + return + } + + selectionTask?.cancel() + selectionTask = Task { [weak self] in + await self?.refreshSelectionIfNeeded() + } + } + + func deleteItem(_ selection: SidebarSelection) async { + guard isAuthenticated else { + return + } + guard case .settings = selection else { + SybilLog.info(SybilLog.ui, "Deleting item \(selection.id)") + do { + let client = try client() + switch selection { + case let .chat(chatID): + try await client.deleteChat(chatID: chatID) + case let .search(searchID): + try await client.deleteSearch(searchID: searchID) + case .settings: + break + } + await refreshCollections(preferredSelection: nil) + } catch { + errorMessage = normalizeAPIError(error) + SybilLog.error(SybilLog.ui, "Delete failed", error: error) + } + return + } + } + + func refreshAfterSettingsChange() async { + SybilLog.info(SybilLog.ui, "Settings changed, reconnecting") + settings.persist() + await reconnect() + } + + func sendComposer() async { + let content = composer.trimmingCharacters(in: .whitespacesAndNewlines) + guard !content.isEmpty, !isSending else { + return + } + + composer = "" + errorMessage = nil + isSending = true + + do { + if isSearchMode { + SybilLog.info(SybilLog.ui, "Sending search query") + try await sendSearch(query: content) + } else { + SybilLog.info(SybilLog.ui, "Sending chat prompt") + try await sendChat(content: content) + } + } catch { + errorMessage = normalizeAPIError(error) + SybilLog.error(SybilLog.ui, "Send failed", error: error) + + if case let .chat(chatID) = selectedItem { + do { + let chat = try await client().getChat(chatID: chatID) + selectedChat = chat + } catch { + SybilLog.error(SybilLog.ui, "Fallback chat refresh after failure failed", error: error) + } + } + + if case let .search(searchID) = selectedItem { + do { + let search = try await client().getSearch(searchID: searchID) + selectedSearch = search + } catch { + SybilLog.error(SybilLog.ui, "Fallback search refresh after failure failed", error: error) + } + } + + pendingChatState = nil + } + + isSending = false + } + + private func loadInitialData(using client: SybilAPIClient) async { + isLoadingCollections = true + errorMessage = nil + + do { + async let chatsValue = client.listChats() + async let searchesValue = client.listSearches() + let (nextChats, nextSearches) = try await (chatsValue, searchesValue) + + chats = nextChats + searches = nextSearches + + SybilLog.info( + SybilLog.app, + "Loaded collections: \(nextChats.count) chats, \(nextSearches.count) searches" + ) + + do { + let nextCatalog = try await client.listModels() + self.modelCatalog = nextCatalog.providers + syncModelSelectionWithServerCatalog() + SybilLog.info(SybilLog.app, "Loaded model catalog for \(nextCatalog.providers.count) providers") + } catch { + SybilLog.error(SybilLog.app, "Model catalog load failed, using provider fallback models", error: error) + errorMessage = "Loaded chats/searches, but failed to load models: \(normalizeAPIError(error))" + } + + let nextSelection: SidebarSelection? + if case .settings = selectedItem { + nextSelection = .settings + } else if let currentSelection = selectedItem, + hasSelection(currentSelection, chats: nextChats, searches: nextSearches) { + nextSelection = currentSelection + } else { + nextSelection = sidebarItems.first?.selection + } + + selectedItem = nextSelection + if nextSelection == nil { + draftKind = .chat + } + + if let nextSelection { + switch nextSelection { + case .settings: + selectedChat = nil + selectedSearch = nil + case .chat, .search: + await refreshSelectionIfNeeded() + } + } + } catch { + errorMessage = normalizeAPIError(error) + SybilLog.error(SybilLog.app, "Initial data load failed", error: error) + } + + isLoadingCollections = false + } + + private func syncModelSelectionWithServerCatalog() { + if !providerModelOptions.contains(model), let first = providerModelOptions.first { + model = first + settings.preferredModelByProvider[provider] = first + } + + if let preferred = settings.preferredModelByProvider[provider], providerModelOptions.contains(preferred) { + model = preferred + } + + settings.persist() + } + + private func refreshCollections(preferredSelection: SidebarSelection?) async { + isLoadingCollections = true + + do { + let client = try client() + async let chatsValue = client.listChats() + async let searchesValue = client.listSearches() + let (nextChats, nextSearches) = try await (chatsValue, searchesValue) + + chats = nextChats + searches = nextSearches + + SybilLog.info( + SybilLog.app, + "Refreshed collections: \(nextChats.count) chats, \(nextSearches.count) searches" + ) + + if case .settings = selectedItem { + isLoadingCollections = false + return + } + + if let preferredSelection, + hasSelection(preferredSelection, chats: nextChats, searches: nextSearches) { + selectedItem = preferredSelection + } else if let existing = selectedItem, + hasSelection(existing, chats: nextChats, searches: nextSearches) { + selectedItem = existing + } else { + selectedItem = sidebarItems.first?.selection + } + + if selectedItem != nil { + await refreshSelectionIfNeeded() + } + } catch { + errorMessage = normalizeAPIError(error) + SybilLog.error(SybilLog.app, "Refresh collections failed", error: error) + } + + isLoadingCollections = false + } + + private func refreshSelectionIfNeeded() async { + guard let selectedItem else { + selectedChat = nil + selectedSearch = nil + return + } + + guard case .settings = selectedItem else { + isLoadingSelection = true + do { + let client = try client() + switch selectedItem { + case let .chat(chatID): + SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)") + selectedChat = try await client.getChat(chatID: chatID) + selectedSearch = nil + + if let detail = selectedChat, + let provider = detail.lastUsedProvider, + let model = detail.lastUsedModel, + !model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + self.provider = provider + self.model = model + } + + case let .search(searchID): + SybilLog.debug(SybilLog.app, "Refreshing search \(searchID)") + selectedSearch = try await client.getSearch(searchID: searchID) + selectedChat = nil + + case .settings: + break + } + } catch { + if isCancellation(error) { + SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(selectedItem.id)") + } else { + errorMessage = normalizeAPIError(error) + SybilLog.error(SybilLog.app, "Selection refresh failed", error: error) + } + } + isLoadingSelection = false + return + } + + selectedChat = nil + selectedSearch = nil + } + + private func sendChat(content: String) async throws { + let optimisticUser = Message( + id: "temp-user-\(UUID().uuidString)", + createdAt: Date(), + role: .user, + content: content, + name: nil + ) + + let optimisticAssistant = Message( + id: "temp-assistant-\(UUID().uuidString)", + createdAt: Date(), + role: .assistant, + content: "", + name: nil + ) + + pendingChatState = PendingChatState( + chatID: currentChatID, + messages: (selectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant] + ) + + let client = try client() + + var chatID = currentChatID + if chatID == nil { + let created = try await client.createChat() + chatID = created.id + draftKind = nil + selectedItem = .chat(created.id) + + chats.removeAll(where: { $0.id == created.id }) + chats.insert(created, at: 0) + + selectedChat = ChatDetail( + id: created.id, + title: created.title, + createdAt: created.createdAt, + updatedAt: created.updatedAt, + initiatedProvider: created.initiatedProvider, + initiatedModel: created.initiatedModel, + lastUsedProvider: created.lastUsedProvider, + lastUsedModel: created.lastUsedModel, + messages: [] + ) + selectedSearch = nil + + SybilLog.info(SybilLog.app, "Created chat \(created.id)") + } + + guard let chatID else { + throw APIError.invalidResponse + } + + pendingChatState?.chatID = chatID + + let baseChat: ChatDetail + if let selectedChat, selectedChat.id == chatID { + baseChat = selectedChat + } else { + baseChat = try await client.getChat(chatID: chatID) + } + + let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines) + guard !selectedModel.isEmpty else { + throw APIError.invalidResponse + } + + let requestMessages: [CompletionRequestMessage] = + baseChat.messages.map { + CompletionRequestMessage(role: $0.role, content: $0.content, name: $0.name) + } + [CompletionRequestMessage(role: .user, content: content)] + + let streamStatus = CompletionStreamStatus() + + if isUntitledChat(chatID: chatID, detail: selectedChat) { + Task { [weak self] in + guard let self else { return } + do { + let updated = try await client.suggestChatTitle(chatID: chatID, content: content) + await MainActor.run { + self.chats = self.chats.map { existing in + if existing.id == updated.id { + return updated + } + return existing + } + + if self.selectedChat?.id == updated.id { + self.selectedChat?.title = updated.title + self.selectedChat?.updatedAt = updated.updatedAt + } + } + } catch { + SybilLog.warning(SybilLog.app, "Chat title suggestion failed: \(SybilLog.describe(error))") + } + } + } + + try await client.runCompletionStream( + body: CompletionStreamRequest( + chatId: chatID, + provider: provider, + model: selectedModel, + messages: requestMessages + ) + ) { [weak self] event in + guard let self else { return } + await self.applyCompletionEvent(event, streamStatus: streamStatus) + } + + if let streamError = await streamStatus.error() { + throw APIError.httpError(statusCode: 502, message: streamError) + } + + await refreshCollections(preferredSelection: .chat(chatID)) + selectedChat = try await client.getChat(chatID: chatID) + pendingChatState = nil + } + + private func applyCompletionEvent(_ event: CompletionStreamEvent, streamStatus: CompletionStreamStatus) async { + switch event { + case let .meta(payload): + pendingChatState?.chatID = payload.chatId + + case let .delta(payload): + guard !payload.text.isEmpty else { return } + mutatePendingAssistantMessage { existing in + existing + payload.text + } + + case let .done(payload): + mutatePendingAssistantMessage { _ in + payload.text + } + + case let .error(payload): + await streamStatus.setError(payload.message) + + case .ignored: + break + } + } + + private func sendSearch(query: String) async throws { + let client = try client() + + var searchID = currentSearchID + if searchID == nil { + let created = try await client.createSearch(title: String(query.prefix(80)), query: query) + searchID = created.id + draftKind = nil + selectedItem = .search(created.id) + + searches.removeAll(where: { $0.id == created.id }) + searches.insert(created, at: 0) + + SybilLog.info(SybilLog.app, "Created search \(created.id)") + } + + guard let searchID else { + throw APIError.invalidResponse + } + + let now = Date() + selectedSearch = SearchDetail( + id: searchID, + title: String(query.prefix(80)), + query: query, + createdAt: selectedSearch?.createdAt ?? now, + updatedAt: now, + requestId: nil, + latencyMs: nil, + error: nil, + answerText: nil, + answerRequestId: nil, + answerCitations: nil, + answerError: nil, + results: [] + ) + + let streamStatus = SearchStreamStatus() + + try await client.runSearchStream( + searchID: searchID, + body: SearchRunRequest(query: query, title: String(query.prefix(80)), type: "auto", numResults: 10) + ) { [weak self] event in + guard let self else { return } + await self.applySearchEvent(event, searchID: searchID, streamStatus: streamStatus) + } + + if let streamError = await streamStatus.error() { + throw APIError.httpError(statusCode: 502, message: streamError) + } + + await refreshCollections(preferredSelection: .search(searchID)) + } + + private func applySearchEvent( + _ event: SearchStreamEvent, + searchID: String, + streamStatus: SearchStreamStatus + ) async { + guard let current = selectedSearch, current.id == searchID else { + if case let .done(payload) = event { + selectedSearch = payload.search + } + return + } + + switch event { + case let .searchResults(payload): + selectedSearch?.requestId = payload.requestId ?? current.requestId + selectedSearch?.error = nil + selectedSearch?.results = payload.results + + case let .searchError(payload): + selectedSearch?.error = payload.error + + case let .answer(payload): + selectedSearch?.answerText = payload.answerText + selectedSearch?.answerRequestId = payload.answerRequestId + selectedSearch?.answerCitations = payload.answerCitations + selectedSearch?.answerError = nil + + case let .answerError(payload): + selectedSearch?.answerError = payload.error + + case let .done(payload): + selectedSearch = payload.search + selectedChat = nil + + case let .error(payload): + await streamStatus.setError(payload.message) + + case .ignored: + break + } + } + + private func mutatePendingAssistantMessage(_ transform: (String) -> String) { + guard var pending = pendingChatState, !pending.messages.isEmpty else { + return + } + + let index = pending.messages.indices.last { pending.messages[$0].id.hasPrefix("temp-assistant-") } + guard let index else { + return + } + + var message = pending.messages[index] + message.content = transform(message.content) + pending.messages[index] = message + pendingChatState = pending + } + + private var currentChatID: String? { + if draftKind == .chat { + return nil + } + if case let .chat(chatID) = selectedItem { + return chatID + } + return nil + } + + private var currentSearchID: String? { + if draftKind == .search { + return nil + } + if case let .search(searchID) = selectedItem { + return searchID + } + return nil + } + + private func hasSelection(_ selection: SidebarSelection, chats: [ChatSummary], searches: [SearchSummary]) -> Bool { + switch selection { + case let .chat(chatID): + return chats.contains(where: { $0.id == chatID }) + case let .search(searchID): + return searches.contains(where: { $0.id == searchID }) + case .settings: + return true + } + } + + private func chatTitle(title: String?, messages: [Message]?) -> String { + if let title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { + return title + } + + if let firstUserMessage = messages?.first(where: { $0.role == .user })?.content.trimmingCharacters(in: .whitespacesAndNewlines), + !firstUserMessage.isEmpty { + return String(firstUserMessage.prefix(48)) + } + + return "New chat" + } + + private func searchTitle(title: String?, query: String?) -> String { + if let title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { + return title + } + + if let query = query?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty { + return String(query.prefix(64)) + } + + return "New search" + } + + private func normalizeAuthError(_ error: Error) -> String { + let normalized = normalizeAPIError(error) + if normalized.contains("missing bearer token") || normalized.contains("invalid bearer token") { + return "Authentication failed. Enter the ADMIN_TOKEN configured in server/.env." + } + return normalized + } + + private func normalizeAPIError(_ error: Error) -> String { + if let apiError = error as? APIError { + switch apiError { + case .invalidBaseURL: + return "Set a valid API URL in Settings." + + case let .httpError(_, message): + return message + + case let .networkError(message): + return appendLoopbackHintIfNeeded(to: message) + + case let .decodingError(message): + return message + + case .invalidResponse: + return "Unexpected server response." + + case .noResponseStream: + return "No response stream from server." + } + } + + if let decodingError = error as? DecodingError { + return "Failed to decode server response: \(SybilLog.describe(decodingError))" + } + + if let urlError = error as? URLError { + let base = "Network error \(urlError.code.rawValue): \(urlError.localizedDescription)" + return appendLoopbackHintIfNeeded(to: base) + } + + if error is CancellationError { + return "Request was cancelled." + } + + return (error as NSError).localizedDescription + } + + private func appendLoopbackHintIfNeeded(to message: String) -> String { + guard let baseURL = settings.normalizedAPIBaseURL, + let host = baseURL.host?.lowercased(), + host == "127.0.0.1" || host == "localhost" else { + return message + } + + #if targetEnvironment(simulator) + return message + #else + return message + " On physical devices, localhost/127.0.0.1 points to the phone. Use your Mac's LAN IP in Settings." + #endif + } + + private func isCancellation(_ error: Error) -> Bool { + if error is CancellationError { + return true + } + if let urlError = error as? URLError, urlError.code == .cancelled { + return true + } + return false + } + + private func client() throws -> SybilAPIClient { + guard let baseURL = settings.normalizedAPIBaseURL else { + throw APIError.invalidBaseURL + } + + SybilLog.debug( + SybilLog.app, + "Creating API client for \(baseURL.absoluteString) (token: \(settings.trimmedTokenOrNil == nil ? "none" : "set"))" + ) + + return SybilAPIClient( + configuration: APIConfiguration( + baseURL: baseURL, + authToken: settings.trimmedTokenOrNil + ) + ) + } + + private func isUntitledChat(chatID: String, detail: ChatDetail?) -> Bool { + if let detail, detail.id == chatID { + if let title = detail.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { + return false + } + return true + } + + if let summary = chats.first(where: { $0.id == chatID }) { + if let title = summary.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { + return false + } + } + + return true + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift new file mode 100644 index 0000000..bae728f --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift @@ -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) + } +} diff --git a/ios/justfile b/ios/justfile new file mode 100644 index 0000000..40b2976 --- /dev/null +++ b/ios/justfile @@ -0,0 +1,10 @@ +default: + @just build + +build: + if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi + if command -v xcbeautify >/dev/null 2>&1; then \ + xcodebuild -scheme Sybil -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' | xcbeautify; \ + else \ + xcodebuild -scheme Sybil -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest'; \ + fi diff --git a/ios/mise.toml b/ios/mise.toml new file mode 100644 index 0000000..65a3926 --- /dev/null +++ b/ios/mise.toml @@ -0,0 +1,4 @@ +[tools] +just = "latest" +xcodegen = "latest" +xcbeautify = "latest" diff --git a/ios/project.yml b/ios/project.yml index 4420c68..9b67746 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -2,5 +2,8 @@ name: Sybil options: createIntermediateGroups: true generateEmptyDirectories: true +packages: + Sybil: + path: Packages/Sybil include: - Apps/Sybil/project.yml