ios: initial commit
This commit is contained in:
41
ios/AGENTS.md
Normal file
41
ios/AGENTS.md
Normal file
@@ -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`
|
||||||
0
ios/Apps/Sybil/Resources/.gitkeep
Normal file
0
ios/Apps/Sybil/Resources/.gitkeep
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 877 KiB |
6
ios/Apps/Sybil/Resources/Artwork.xcassets/Contents.json
Normal file
6
ios/Apps/Sybil/Resources/Artwork.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
packages:
|
|
||||||
Sybil:
|
|
||||||
path: Packages/Sybil
|
|
||||||
|
|
||||||
targets:
|
targets:
|
||||||
SybilApp:
|
SybilApp:
|
||||||
type: application
|
type: application
|
||||||
@@ -9,6 +5,7 @@ targets:
|
|||||||
deploymentTarget: "18.0"
|
deploymentTarget: "18.0"
|
||||||
sources:
|
sources:
|
||||||
- Sources
|
- Sources
|
||||||
|
- Resources
|
||||||
dependencies:
|
dependencies:
|
||||||
- package: Sybil
|
- package: Sybil
|
||||||
product: Sybil
|
product: Sybil
|
||||||
@@ -21,6 +18,7 @@ targets:
|
|||||||
SWIFT_VERSION: 6.0
|
SWIFT_VERSION: 6.0
|
||||||
TARGETED_DEVICE_FAMILY: "1,2"
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
MARKETING_VERSION: 1.0
|
MARKETING_VERSION: 1.0
|
||||||
CURRENT_PROJECT_VERSION: 1
|
CURRENT_PROJECT_VERSION: 1
|
||||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||||
|
|||||||
@@ -14,11 +14,17 @@ let package = Package(
|
|||||||
name: "Sybil",
|
name: "Sybil",
|
||||||
targets: ["Sybil"]),
|
targets: ["Sybil"]),
|
||||||
],
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui.git", from: "2.4.1")
|
||||||
|
],
|
||||||
targets: [
|
targets: [
|
||||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
// 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.
|
// Targets can depend on other targets in this package and products from dependencies.
|
||||||
.target(
|
.target(
|
||||||
name: "Sybil"),
|
name: "Sybil",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "MarkdownUI", package: "swift-markdown-ui")
|
||||||
|
]),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "SybilTests",
|
name: "SybilTests",
|
||||||
dependencies: ["Sybil"]
|
dependencies: ["Sybil"]
|
||||||
|
|||||||
@@ -1,19 +1,38 @@
|
|||||||
//
|
|
||||||
// SplitView.swift
|
|
||||||
// Sybil
|
|
||||||
//
|
|
||||||
// Created by James Magahern on 2/19/26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
public struct SplitView: View
|
public struct SplitView: View {
|
||||||
{
|
@State private var viewModel = SybilViewModel()
|
||||||
public init() {
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
}
|
public init() {}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
Text("Not Yet Implemented: replace me.")
|
ZStack {
|
||||||
|
SybilTheme.backgroundGradient
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
if viewModel.isCheckingSession {
|
||||||
|
ProgressView("Checking session…")
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
} else if !viewModel.isAuthenticated {
|
||||||
|
SybilConnectionView(viewModel: viewModel)
|
||||||
|
.padding()
|
||||||
|
} else if horizontalSizeClass == .compact {
|
||||||
|
SybilPhoneShellView(viewModel: viewModel)
|
||||||
|
} else {
|
||||||
|
NavigationSplitView {
|
||||||
|
SybilSidebarView(viewModel: viewModel)
|
||||||
|
.navigationTitle("Sybil")
|
||||||
|
} detail: {
|
||||||
|
SybilWorkspaceView(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
.navigationSplitViewStyle(.balanced)
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.bootstrap()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
551
ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift
Normal file
551
ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct APIConfiguration: Sendable {
|
||||||
|
var baseURL: URL
|
||||||
|
var authToken: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AnyEncodable: Encodable {
|
||||||
|
private let encodeClosure: (Encoder) throws -> Void
|
||||||
|
|
||||||
|
init<T: Encodable>(_ value: T) {
|
||||||
|
encodeClosure = value.encode(to:)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
try encodeClosure(encoder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actor SybilAPIClient {
|
||||||
|
private let configuration: APIConfiguration
|
||||||
|
private let session: URLSession
|
||||||
|
|
||||||
|
private static let iso8601FormatterWithFractional: ISO8601DateFormatter = {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let iso8601Formatter: ISO8601DateFormatter = {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
init(configuration: APIConfiguration, session: URLSession = .shared) {
|
||||||
|
self.configuration = configuration
|
||||||
|
self.session = session
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifySession() async throws -> AuthSession {
|
||||||
|
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listChats() async throws -> [ChatSummary] {
|
||||||
|
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
|
||||||
|
return response.chats
|
||||||
|
}
|
||||||
|
|
||||||
|
func createChat(title: String? = nil) async throws -> ChatSummary {
|
||||||
|
let response = try await request(
|
||||||
|
"/v1/chats",
|
||||||
|
method: "POST",
|
||||||
|
body: AnyEncodable(ChatCreateBody(title: title)),
|
||||||
|
responseType: ChatCreateResponse.self
|
||||||
|
)
|
||||||
|
return response.chat
|
||||||
|
}
|
||||||
|
|
||||||
|
func getChat(chatID: String) async throws -> ChatDetail {
|
||||||
|
let response = try await request("/v1/chats/\(chatID)", method: "GET", responseType: ChatDetailResponse.self)
|
||||||
|
return response.chat
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteChat(chatID: String) async throws {
|
||||||
|
_ = try await request("/v1/chats/\(chatID)", method: "DELETE", responseType: DeleteResponse.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary {
|
||||||
|
let response = try await request(
|
||||||
|
"/v1/chats/title/suggest",
|
||||||
|
method: "POST",
|
||||||
|
body: AnyEncodable(SuggestTitleBody(chatId: chatID, content: content)),
|
||||||
|
responseType: ChatCreateResponse.self
|
||||||
|
)
|
||||||
|
return response.chat
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSearches() async throws -> [SearchSummary] {
|
||||||
|
let response = try await request("/v1/searches", method: "GET", responseType: SearchListResponse.self)
|
||||||
|
return response.searches
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSearch(title: String? = nil, query: String? = nil) async throws -> SearchSummary {
|
||||||
|
let response = try await request(
|
||||||
|
"/v1/searches",
|
||||||
|
method: "POST",
|
||||||
|
body: AnyEncodable(SearchCreateBody(title: title, query: query)),
|
||||||
|
responseType: SearchCreateResponse.self
|
||||||
|
)
|
||||||
|
return response.search
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSearch(searchID: String) async throws -> SearchDetail {
|
||||||
|
let response = try await request("/v1/searches/\(searchID)", method: "GET", responseType: SearchDetailResponse.self)
|
||||||
|
return response.search
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteSearch(searchID: String) async throws {
|
||||||
|
_ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listModels() async throws -> ModelCatalogResponse {
|
||||||
|
try await request("/v1/models", method: "GET", responseType: ModelCatalogResponse.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCompletionStream(
|
||||||
|
body: CompletionStreamRequest,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
let request = try makeRequest(
|
||||||
|
path: "/v1/chat-completions/stream",
|
||||||
|
method: "POST",
|
||||||
|
body: AnyEncodable(body),
|
||||||
|
acceptsSSE: true
|
||||||
|
)
|
||||||
|
|
||||||
|
SybilLog.info(
|
||||||
|
SybilLog.network,
|
||||||
|
"Starting chat stream POST \(request.url?.absoluteString ?? "<unknown>")"
|
||||||
|
)
|
||||||
|
|
||||||
|
try await stream(request: request) { eventName, dataText in
|
||||||
|
switch eventName {
|
||||||
|
case "meta":
|
||||||
|
let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName)
|
||||||
|
await onEvent(.meta(payload))
|
||||||
|
case "delta":
|
||||||
|
let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName)
|
||||||
|
await onEvent(.delta(payload))
|
||||||
|
case "done":
|
||||||
|
do {
|
||||||
|
let payload: CompletionStreamDone = try Self.decodeEvent(dataText, as: CompletionStreamDone.self, eventName: eventName)
|
||||||
|
await onEvent(.done(payload))
|
||||||
|
} catch {
|
||||||
|
if let recovered = Self.decodeLastJSONLine(dataText, as: CompletionStreamDone.self) {
|
||||||
|
SybilLog.warning(
|
||||||
|
SybilLog.network,
|
||||||
|
"Recovered chat stream done payload from concatenated SSE data"
|
||||||
|
)
|
||||||
|
await onEvent(.done(recovered))
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "error":
|
||||||
|
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.error(payload))
|
||||||
|
default:
|
||||||
|
SybilLog.warning(SybilLog.network, "Ignoring unknown chat stream event '\(eventName)'")
|
||||||
|
await onEvent(.ignored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SybilLog.info(SybilLog.network, "Chat stream completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSearchStream(
|
||||||
|
searchID: String,
|
||||||
|
body: SearchRunRequest,
|
||||||
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
let request = try makeRequest(
|
||||||
|
path: "/v1/searches/\(searchID)/run/stream",
|
||||||
|
method: "POST",
|
||||||
|
body: AnyEncodable(body),
|
||||||
|
acceptsSSE: true
|
||||||
|
)
|
||||||
|
|
||||||
|
SybilLog.info(
|
||||||
|
SybilLog.network,
|
||||||
|
"Starting search stream POST \(request.url?.absoluteString ?? "<unknown>")"
|
||||||
|
)
|
||||||
|
|
||||||
|
try await stream(request: request) { eventName, dataText in
|
||||||
|
switch eventName {
|
||||||
|
case "search_results":
|
||||||
|
let payload: SearchResultsPayload = try Self.decodeEvent(dataText, as: SearchResultsPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.searchResults(payload))
|
||||||
|
case "search_error":
|
||||||
|
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.searchError(payload))
|
||||||
|
case "answer":
|
||||||
|
let payload: SearchAnswerPayload = try Self.decodeEvent(dataText, as: SearchAnswerPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.answer(payload))
|
||||||
|
case "answer_error":
|
||||||
|
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.answerError(payload))
|
||||||
|
case "done":
|
||||||
|
let payload: SearchDonePayload = try Self.decodeEvent(dataText, as: SearchDonePayload.self, eventName: eventName)
|
||||||
|
await onEvent(.done(payload))
|
||||||
|
case "error":
|
||||||
|
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.error(payload))
|
||||||
|
default:
|
||||||
|
SybilLog.warning(SybilLog.network, "Ignoring unknown search stream event '\(eventName)'")
|
||||||
|
await onEvent(.ignored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SybilLog.info(SybilLog.network, "Search stream completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func request<Response: Decodable>(
|
||||||
|
_ path: String,
|
||||||
|
method: String,
|
||||||
|
body: AnyEncodable? = nil,
|
||||||
|
responseType: Response.Type
|
||||||
|
) async throws -> Response {
|
||||||
|
let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false)
|
||||||
|
|
||||||
|
SybilLog.debug(
|
||||||
|
SybilLog.network,
|
||||||
|
"HTTP request \(method) \(request.url?.absoluteString ?? "<unknown>")"
|
||||||
|
)
|
||||||
|
|
||||||
|
let data: Data
|
||||||
|
let response: URLResponse
|
||||||
|
|
||||||
|
do {
|
||||||
|
(data, response) = try await session.data(for: request)
|
||||||
|
} catch {
|
||||||
|
if let urlError = error as? URLError, urlError.code == .cancelled {
|
||||||
|
SybilLog.debug(
|
||||||
|
SybilLog.network,
|
||||||
|
"HTTP request cancelled \(method) \(request.url?.absoluteString ?? "<unknown>")"
|
||||||
|
)
|
||||||
|
throw CancellationError()
|
||||||
|
}
|
||||||
|
let wrapped = Self.wrapTransportError(error, method: method, url: request.url)
|
||||||
|
SybilLog.error(SybilLog.network, "HTTP transport failure", error: wrapped)
|
||||||
|
throw wrapped
|
||||||
|
}
|
||||||
|
|
||||||
|
try validate(response: response, data: data)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let decoded = try Self.decodeJSON(Response.self, from: data)
|
||||||
|
if let httpResponse = response as? HTTPURLResponse {
|
||||||
|
SybilLog.debug(
|
||||||
|
SybilLog.network,
|
||||||
|
"HTTP response \(httpResponse.statusCode) for \(method) \(request.url?.path(percentEncoded: false) ?? path)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
} catch let decodingError as DecodingError {
|
||||||
|
let details = SybilLog.describe(decodingError)
|
||||||
|
let snippet = Self.responseSnippet(data)
|
||||||
|
let message = "Failed to decode response for \(method) \(request.url?.absoluteString ?? path): \(details). Body: \(snippet)"
|
||||||
|
SybilLog.error(SybilLog.network, message)
|
||||||
|
throw APIError.decodingError(message: message)
|
||||||
|
} catch {
|
||||||
|
SybilLog.error(SybilLog.network, "Unexpected decoding failure", error: error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRequest(
|
||||||
|
path: String,
|
||||||
|
method: String,
|
||||||
|
body: AnyEncodable?,
|
||||||
|
acceptsSSE: Bool
|
||||||
|
) throws -> URLRequest {
|
||||||
|
let url = try buildURL(path: path)
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = method
|
||||||
|
request.timeoutInterval = 120
|
||||||
|
|
||||||
|
if acceptsSSE {
|
||||||
|
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
||||||
|
} else {
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let token = configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let body {
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = try Self.encodeJSON(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildURL(path: String) throws -> URL {
|
||||||
|
guard var components = URLComponents(url: configuration.baseURL, resolvingAgainstBaseURL: false) else {
|
||||||
|
throw APIError.invalidBaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
var basePath = components.path
|
||||||
|
if basePath.hasSuffix("/") {
|
||||||
|
basePath.removeLast()
|
||||||
|
}
|
||||||
|
components.path = "\(basePath)/\(trimmedPath)"
|
||||||
|
|
||||||
|
guard let url = components.url else {
|
||||||
|
throw APIError.invalidBaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private func validate(response: URLResponse, data: Data) throws {
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw APIError.invalidResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
guard (200 ... 299).contains(httpResponse.statusCode) else {
|
||||||
|
let message = Self.parseMessage(from: data) ?? "\(httpResponse.statusCode) \(HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode))"
|
||||||
|
SybilLog.warning(
|
||||||
|
SybilLog.network,
|
||||||
|
"HTTP non-success status \(httpResponse.statusCode): \(message)"
|
||||||
|
)
|
||||||
|
throw APIError.httpError(statusCode: httpResponse.statusCode, message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseMessage(from data: Data) -> String? {
|
||||||
|
guard !data.isEmpty else { return nil }
|
||||||
|
if let decoded = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let message = decoded["message"] as? String,
|
||||||
|
!message.isEmpty {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stream(
|
||||||
|
request: URLRequest,
|
||||||
|
onEvent: @escaping @Sendable (_ eventName: String, _ dataText: String) async throws -> Void
|
||||||
|
) async throws {
|
||||||
|
let bytes: URLSession.AsyncBytes
|
||||||
|
let response: URLResponse
|
||||||
|
|
||||||
|
do {
|
||||||
|
(bytes, response) = try await session.bytes(for: request)
|
||||||
|
} catch {
|
||||||
|
if let urlError = error as? URLError, urlError.code == .cancelled {
|
||||||
|
SybilLog.debug(
|
||||||
|
SybilLog.network,
|
||||||
|
"SSE request cancelled \(request.httpMethod ?? "GET") \(request.url?.absoluteString ?? "<unknown>")"
|
||||||
|
)
|
||||||
|
throw CancellationError()
|
||||||
|
}
|
||||||
|
let wrapped = Self.wrapTransportError(error, method: request.httpMethod ?? "GET", url: request.url)
|
||||||
|
SybilLog.error(SybilLog.network, "SSE transport failure", error: wrapped)
|
||||||
|
throw wrapped
|
||||||
|
}
|
||||||
|
|
||||||
|
try validate(response: response, data: Data())
|
||||||
|
|
||||||
|
var eventName = "message"
|
||||||
|
var dataLines: [String] = []
|
||||||
|
var lineBytes: [UInt8] = []
|
||||||
|
|
||||||
|
for try await byte in bytes {
|
||||||
|
if Task.isCancelled {
|
||||||
|
SybilLog.warning(SybilLog.network, "SSE task cancelled")
|
||||||
|
throw CancellationError()
|
||||||
|
}
|
||||||
|
|
||||||
|
if byte == 0x0A { // \n
|
||||||
|
var bytesForLine = lineBytes
|
||||||
|
if bytesForLine.last == 0x0D { // \r
|
||||||
|
bytesForLine.removeLast()
|
||||||
|
}
|
||||||
|
let line = String(decoding: bytesForLine, as: UTF8.self)
|
||||||
|
lineBytes.removeAll(keepingCapacity: true)
|
||||||
|
|
||||||
|
if line.isEmpty {
|
||||||
|
if let emitted = Self.flushSSEEvent(eventName: &eventName, dataLines: &dataLines) {
|
||||||
|
SybilLog.debug(SybilLog.network, "SSE event \(emitted.name) payload chars=\(emitted.payload.count)")
|
||||||
|
try await onEvent(emitted.name, emitted.payload)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if line.hasPrefix(":") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if line.hasPrefix("event:") {
|
||||||
|
if let emitted = Self.flushSSEEvent(eventName: &eventName, dataLines: &dataLines) {
|
||||||
|
SybilLog.debug(SybilLog.network, "SSE event \(emitted.name) payload chars=\(emitted.payload.count)")
|
||||||
|
try await onEvent(emitted.name, emitted.payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
eventName = line.dropFirst("event:".count).trimmingCharacters(in: .whitespaces)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if line.hasPrefix("data:") {
|
||||||
|
let payload = line.dropFirst("data:".count)
|
||||||
|
dataLines.append(Self.trimLeadingWhitespace(payload))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
SybilLog.debug(SybilLog.network, "Ignoring SSE line '\(String(line.prefix(120)))'")
|
||||||
|
} else {
|
||||||
|
lineBytes.append(byte)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !lineBytes.isEmpty {
|
||||||
|
var bytesForLine = lineBytes
|
||||||
|
if bytesForLine.last == 0x0D { // \r
|
||||||
|
bytesForLine.removeLast()
|
||||||
|
}
|
||||||
|
let line = String(decoding: bytesForLine, as: UTF8.self)
|
||||||
|
|
||||||
|
if line.isEmpty {
|
||||||
|
if let emitted = Self.flushSSEEvent(eventName: &eventName, dataLines: &dataLines) {
|
||||||
|
SybilLog.debug(SybilLog.network, "SSE event \(emitted.name) payload chars=\(emitted.payload.count)")
|
||||||
|
try await onEvent(emitted.name, emitted.payload)
|
||||||
|
}
|
||||||
|
} else if line.hasPrefix("event:") {
|
||||||
|
if let emitted = Self.flushSSEEvent(eventName: &eventName, dataLines: &dataLines) {
|
||||||
|
SybilLog.debug(SybilLog.network, "SSE event \(emitted.name) payload chars=\(emitted.payload.count)")
|
||||||
|
try await onEvent(emitted.name, emitted.payload)
|
||||||
|
}
|
||||||
|
eventName = line.dropFirst("event:".count).trimmingCharacters(in: .whitespaces)
|
||||||
|
} else if line.hasPrefix("data:") {
|
||||||
|
let payload = line.dropFirst("data:".count)
|
||||||
|
dataLines.append(Self.trimLeadingWhitespace(payload))
|
||||||
|
} else if !line.hasPrefix(":") {
|
||||||
|
SybilLog.debug(SybilLog.network, "Ignoring SSE line '\(String(line.prefix(120)))'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let emitted = Self.flushSSEEvent(eventName: &eventName, dataLines: &dataLines) {
|
||||||
|
SybilLog.debug(SybilLog.network, "SSE event \(emitted.name) payload chars=\(emitted.payload.count)")
|
||||||
|
try await onEvent(emitted.name, emitted.payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decodeJSON<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .custom { decoder in
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
let string = try container.decode(String.self)
|
||||||
|
if let value = Self.iso8601FormatterWithFractional.date(from: string) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if let value = Self.iso8601Formatter.date(from: string) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Expected ISO-8601 date")
|
||||||
|
}
|
||||||
|
return try decoder.decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decodeEvent<T: Decodable>(_ dataText: String, as type: T.Type, eventName: String) throws -> T {
|
||||||
|
guard let data = dataText.data(using: .utf8) else {
|
||||||
|
let message = "Failed to decode SSE event '\(eventName)': payload is not UTF-8"
|
||||||
|
SybilLog.error(SybilLog.network, message)
|
||||||
|
throw APIError.decodingError(message: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try Self.decodeJSON(type, from: data)
|
||||||
|
} catch let decodingError as DecodingError {
|
||||||
|
let details = SybilLog.describe(decodingError)
|
||||||
|
let snippet = dataText.replacingOccurrences(of: "\n", with: " ").prefix(400)
|
||||||
|
let message = "Failed to decode SSE event '\(eventName)': \(details). Payload: \(snippet)"
|
||||||
|
SybilLog.error(SybilLog.network, message)
|
||||||
|
throw APIError.decodingError(message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decodeLastJSONLine<T: Decodable>(_ dataText: String, as type: T.Type) -> T? {
|
||||||
|
let lines = dataText
|
||||||
|
.split(whereSeparator: \.isNewline)
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
|
||||||
|
guard let last = lines.last, let data = last.data(using: .utf8) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return try? Self.decodeJSON(type, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func flushSSEEvent(
|
||||||
|
eventName: inout String,
|
||||||
|
dataLines: inout [String]
|
||||||
|
) -> (name: String, payload: String)? {
|
||||||
|
guard !dataLines.isEmpty else {
|
||||||
|
eventName = "message"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = eventName
|
||||||
|
let payload = dataLines.joined(separator: "\n")
|
||||||
|
dataLines.removeAll(keepingCapacity: true)
|
||||||
|
eventName = "message"
|
||||||
|
return (name, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func trimLeadingWhitespace(_ text: Substring) -> String {
|
||||||
|
var index = text.startIndex
|
||||||
|
while index < text.endIndex, text[index].isWhitespace {
|
||||||
|
index = text.index(after: index)
|
||||||
|
}
|
||||||
|
return String(text[index...])
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func encodeJSON<T: Encodable>(_ value: T) throws -> Data {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
return try encoder.encode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func wrapTransportError(_ error: Error, method: String, url: URL?) -> APIError {
|
||||||
|
if let urlError = error as? URLError {
|
||||||
|
return APIError.networkError(
|
||||||
|
message: "Network error \(urlError.code.rawValue) while requesting \(method) \(url?.absoluteString ?? "<unknown>"): \(urlError.localizedDescription)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return APIError.networkError(
|
||||||
|
message: "Network request failed for \(method) \(url?.absoluteString ?? "<unknown>"): \(error.localizedDescription)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func responseSnippet(_ data: Data) -> String {
|
||||||
|
guard !data.isEmpty else { return "<empty>" }
|
||||||
|
if let string = String(data: data, encoding: .utf8) {
|
||||||
|
let normalized = string.replacingOccurrences(of: "\n", with: " ")
|
||||||
|
return String(normalized.prefix(500))
|
||||||
|
}
|
||||||
|
return "<non-utf8 body, \(data.count) bytes>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CompletionStreamRequest: Codable, Sendable {
|
||||||
|
var chatId: String?
|
||||||
|
var provider: Provider
|
||||||
|
var model: String
|
||||||
|
var messages: [CompletionRequestMessage]
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ChatCreateBody: Encodable {
|
||||||
|
var title: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SearchCreateBody: Encodable {
|
||||||
|
var title: String?
|
||||||
|
var query: String?
|
||||||
|
}
|
||||||
126
ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift
Normal file
126
ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import MarkdownUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SybilChatTranscriptView: View {
|
||||||
|
var messages: [Message]
|
||||||
|
var isLoading: Bool
|
||||||
|
var isSending: Bool
|
||||||
|
|
||||||
|
private var hasPendingAssistant: Bool {
|
||||||
|
messages.contains { message in
|
||||||
|
message.id.hasPrefix("temp-assistant-") && message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 24) {
|
||||||
|
if isLoading && messages.isEmpty {
|
||||||
|
Text("Loading messages…")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.padding(.top, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(messages) { message in
|
||||||
|
MessageBubble(message: message, isSending: isSending)
|
||||||
|
.id(message.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isSending && !hasPendingAssistant {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(SybilTheme.textMuted)
|
||||||
|
Text("Assistant is typing…")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.id("typing-indicator")
|
||||||
|
}
|
||||||
|
|
||||||
|
Color.clear
|
||||||
|
.frame(height: 2)
|
||||||
|
.id("chat-bottom-anchor")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 18)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
.onAppear {
|
||||||
|
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
|
||||||
|
}
|
||||||
|
.onChange(of: messages.map(\.id)) { _, _ in
|
||||||
|
withAnimation(.easeOut(duration: 0.22)) {
|
||||||
|
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: isSending) { _, _ in
|
||||||
|
withAnimation(.easeOut(duration: 0.22)) {
|
||||||
|
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MessageBubble: View {
|
||||||
|
var message: Message
|
||||||
|
var isSending: Bool
|
||||||
|
|
||||||
|
private var isUser: Bool {
|
||||||
|
message.role == .user
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isPendingAssistant: Bool {
|
||||||
|
message.id.hasPrefix("temp-assistant-") &&
|
||||||
|
isSending &&
|
||||||
|
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if isPendingAssistant {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(SybilTheme.textMuted)
|
||||||
|
Text("Thinking…")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
} else {
|
||||||
|
Markdown(message.content)
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
|
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
||||||
|
.markdownTextStyle {
|
||||||
|
FontSize(15)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, isUser ? 14 : 2)
|
||||||
|
.padding(.vertical, isUser ? 11 : 2)
|
||||||
|
.background(
|
||||||
|
Group {
|
||||||
|
if isUser {
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(SybilTheme.userBubble.opacity(0.86))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
RoundedRectangle(cornerRadius: 0)
|
||||||
|
.fill(Color.clear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.frame(maxWidth: isUser ? 420 : nil, alignment: isUser ? .trailing : .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
ios/Packages/Sybil/Sources/Sybil/SybilConnectionView.swift
Normal file
113
ios/Packages/Sybil/Sources/Sybil/SybilConnectionView.swift
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SybilConnectionView: View {
|
||||||
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
@Bindable var settings = viewModel.settings
|
||||||
|
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Image(systemName: "shield.lefthalf.filled")
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.foregroundStyle(SybilTheme.primary)
|
||||||
|
.frame(width: 34, height: 34)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(SybilTheme.primary.opacity(0.18))
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Connect to Sybil")
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
|
||||||
|
Text("Point the app at your backend and sign in with ADMIN_TOKEN if token mode is enabled.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("API URL")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
|
||||||
|
TextField("http://127.0.0.1:8787/api", text: $settings.apiBaseURL)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(SybilTheme.surface)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(SybilTheme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text("Admin Token")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
|
||||||
|
SecureField("ADMIN_TOKEN (optional in open mode)", text: $settings.adminToken)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(SybilTheme.surface)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(SybilTheme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await viewModel.refreshAfterSettingsChange()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Connect")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(SybilTheme.primarySoft)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
settings.adminToken = ""
|
||||||
|
Task {
|
||||||
|
await viewModel.refreshAfterSettingsChange()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Continue Without Token")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let authError = viewModel.authError {
|
||||||
|
Text(authError)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(SybilTheme.danger)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(maxWidth: 520)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 20)
|
||||||
|
.fill(SybilTheme.card)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 20)
|
||||||
|
.stroke(SybilTheme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
63
ios/Packages/Sybil/Sources/Sybil/SybilLog.swift
Normal file
63
ios/Packages/Sybil/Sources/Sybil/SybilLog.swift
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
enum SybilLog {
|
||||||
|
private static let subsystem = "com.sybil.ios"
|
||||||
|
|
||||||
|
static let app = Logger(subsystem: subsystem, category: "app")
|
||||||
|
static let network = Logger(subsystem: subsystem, category: "network")
|
||||||
|
static let ui = Logger(subsystem: subsystem, category: "ui")
|
||||||
|
|
||||||
|
static func info(_ logger: Logger, _ message: String) {
|
||||||
|
logger.info("\(message, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func debug(_ logger: Logger, _ message: String) {
|
||||||
|
logger.debug("\(message, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func warning(_ logger: Logger, _ message: String) {
|
||||||
|
logger.warning("\(message, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func error(_ logger: Logger, _ message: String) {
|
||||||
|
logger.error("\(message, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func error(_ logger: Logger, _ message: String, error: Error) {
|
||||||
|
logger.error("\(message, privacy: .public) | \(describe(error), privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func describe(_ error: Error) -> String {
|
||||||
|
if let apiError = error as? APIError {
|
||||||
|
return apiError.localizedDescription
|
||||||
|
}
|
||||||
|
if let decodingError = error as? DecodingError {
|
||||||
|
return describe(decodingError)
|
||||||
|
}
|
||||||
|
let nsError = error as NSError
|
||||||
|
return "\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func describe(_ decodingError: DecodingError) -> String {
|
||||||
|
switch decodingError {
|
||||||
|
case let .typeMismatch(type, context):
|
||||||
|
return "Type mismatch for \(type): \(context.debugDescription) at \(codingPath(context.codingPath))"
|
||||||
|
case let .valueNotFound(type, context):
|
||||||
|
return "Value not found for \(type): \(context.debugDescription) at \(codingPath(context.codingPath))"
|
||||||
|
case let .keyNotFound(key, context):
|
||||||
|
return "Key \(key.stringValue) not found: \(context.debugDescription) at \(codingPath(context.codingPath))"
|
||||||
|
case let .dataCorrupted(context):
|
||||||
|
return "Data corrupted: \(context.debugDescription) at \(codingPath(context.codingPath))"
|
||||||
|
@unknown default:
|
||||||
|
return "Unknown decoding error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func codingPath(_ path: [CodingKey]) -> String {
|
||||||
|
if path.isEmpty {
|
||||||
|
return "<root>"
|
||||||
|
}
|
||||||
|
return path.map(\.stringValue).joined(separator: ".")
|
||||||
|
}
|
||||||
|
}
|
||||||
314
ios/Packages/Sybil/Sources/Sybil/SybilModels.swift
Normal file
314
ios/Packages/Sybil/Sources/Sybil/SybilModels.swift
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
|
||||||
|
case openai
|
||||||
|
case anthropic
|
||||||
|
case xai
|
||||||
|
|
||||||
|
public var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .openai: return "OpenAI"
|
||||||
|
case .anthropic: return "Anthropic"
|
||||||
|
case .xai: return "xAI"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MessageRole: String, Codable, Hashable, Sendable {
|
||||||
|
case system
|
||||||
|
case user
|
||||||
|
case assistant
|
||||||
|
case tool
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
||||||
|
public var id: String
|
||||||
|
public var title: String?
|
||||||
|
public var createdAt: Date
|
||||||
|
public var updatedAt: Date
|
||||||
|
public var initiatedProvider: Provider?
|
||||||
|
public var initiatedModel: String?
|
||||||
|
public var lastUsedProvider: Provider?
|
||||||
|
public var lastUsedModel: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
|
||||||
|
public var id: String
|
||||||
|
public var title: String?
|
||||||
|
public var query: String?
|
||||||
|
public var createdAt: Date
|
||||||
|
public var updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Message: Codable, Identifiable, Hashable, Sendable {
|
||||||
|
public var id: String
|
||||||
|
public var createdAt: Date
|
||||||
|
public var role: MessageRole
|
||||||
|
public var content: String
|
||||||
|
public var name: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
||||||
|
public var id: String
|
||||||
|
public var title: String?
|
||||||
|
public var createdAt: Date
|
||||||
|
public var updatedAt: Date
|
||||||
|
public var initiatedProvider: Provider?
|
||||||
|
public var initiatedModel: String?
|
||||||
|
public var lastUsedProvider: Provider?
|
||||||
|
public var lastUsedModel: String?
|
||||||
|
public var messages: [Message]
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SearchResultItem: Codable, Identifiable, Hashable, Sendable {
|
||||||
|
public var id: String
|
||||||
|
public var createdAt: Date
|
||||||
|
public var rank: Int
|
||||||
|
public var title: String?
|
||||||
|
public var url: String
|
||||||
|
public var publishedDate: String?
|
||||||
|
public var author: String?
|
||||||
|
public var text: String?
|
||||||
|
public var highlights: [String]?
|
||||||
|
public var highlightScores: [Double]?
|
||||||
|
public var score: Double?
|
||||||
|
public var favicon: String?
|
||||||
|
public var image: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SearchCitation: Codable, Hashable, Sendable {
|
||||||
|
public var id: String?
|
||||||
|
public var url: String?
|
||||||
|
public var title: String?
|
||||||
|
public var publishedDate: String?
|
||||||
|
public var author: String?
|
||||||
|
public var text: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SearchDetail: Codable, Identifiable, Hashable, Sendable {
|
||||||
|
public var id: String
|
||||||
|
public var title: String?
|
||||||
|
public var query: String?
|
||||||
|
public var createdAt: Date
|
||||||
|
public var updatedAt: Date
|
||||||
|
public var requestId: String?
|
||||||
|
public var latencyMs: Int?
|
||||||
|
public var error: String?
|
||||||
|
public var answerText: String?
|
||||||
|
public var answerRequestId: String?
|
||||||
|
public var answerCitations: [SearchCitation]?
|
||||||
|
public var answerError: String?
|
||||||
|
public var results: [SearchResultItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SearchRunRequest: Codable, Sendable {
|
||||||
|
public var query: String?
|
||||||
|
public var title: String?
|
||||||
|
public var type: String?
|
||||||
|
public var numResults: Int?
|
||||||
|
public var includeDomains: [String]?
|
||||||
|
public var excludeDomains: [String]?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
query: String? = nil,
|
||||||
|
title: String? = nil,
|
||||||
|
type: String? = nil,
|
||||||
|
numResults: Int? = nil,
|
||||||
|
includeDomains: [String]? = nil,
|
||||||
|
excludeDomains: [String]? = nil
|
||||||
|
) {
|
||||||
|
self.query = query
|
||||||
|
self.title = title
|
||||||
|
self.type = type
|
||||||
|
self.numResults = numResults
|
||||||
|
self.includeDomains = includeDomains
|
||||||
|
self.excludeDomains = excludeDomains
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CompletionRequestMessage: Codable, Sendable {
|
||||||
|
public var role: MessageRole
|
||||||
|
public var content: String
|
||||||
|
public var name: String?
|
||||||
|
|
||||||
|
public init(role: MessageRole, content: String, name: String? = nil) {
|
||||||
|
self.role = role
|
||||||
|
self.content = content
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CompletionStreamMeta: Codable, Sendable {
|
||||||
|
public var chatId: String
|
||||||
|
public var callId: String
|
||||||
|
public var provider: Provider
|
||||||
|
public var model: String
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CompletionStreamDelta: Codable, Sendable {
|
||||||
|
public var text: String
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CompletionStreamDone: Codable, Sendable {
|
||||||
|
public var text: String
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct StreamErrorPayload: Codable, Sendable {
|
||||||
|
public var message: String
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CompletionStreamEvent: Sendable {
|
||||||
|
case meta(CompletionStreamMeta)
|
||||||
|
case delta(CompletionStreamDelta)
|
||||||
|
case done(CompletionStreamDone)
|
||||||
|
case error(StreamErrorPayload)
|
||||||
|
case ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SearchResultsPayload: Codable, Sendable {
|
||||||
|
public var requestId: String?
|
||||||
|
public var results: [SearchResultItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SearchErrorPayload: Codable, Sendable {
|
||||||
|
public var error: String
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SearchAnswerPayload: Codable, Sendable {
|
||||||
|
public var answerText: String?
|
||||||
|
public var answerRequestId: String?
|
||||||
|
public var answerCitations: [SearchCitation]?
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SearchDonePayload: Codable, Sendable {
|
||||||
|
public var search: SearchDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SearchStreamEvent: Sendable {
|
||||||
|
case searchResults(SearchResultsPayload)
|
||||||
|
case searchError(SearchErrorPayload)
|
||||||
|
case answer(SearchAnswerPayload)
|
||||||
|
case answerError(SearchErrorPayload)
|
||||||
|
case done(SearchDonePayload)
|
||||||
|
case error(StreamErrorPayload)
|
||||||
|
case ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ProviderModelInfo: Codable, Hashable, Sendable {
|
||||||
|
public var models: [String]
|
||||||
|
public var loadedAt: Date?
|
||||||
|
public var error: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ModelCatalogResponse: Codable, Hashable, Sendable {
|
||||||
|
public var providers: [Provider: ProviderModelInfo]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case providers
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(providers: [Provider: ProviderModelInfo]) {
|
||||||
|
self.providers = providers
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
let rawProviders = try container.decode([String: ProviderModelInfo].self, forKey: .providers)
|
||||||
|
var mapped: [Provider: ProviderModelInfo] = [:]
|
||||||
|
for (key, value) in rawProviders {
|
||||||
|
if let provider = Provider(rawValue: key) {
|
||||||
|
mapped[provider] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.providers = mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
let raw = Dictionary(uniqueKeysWithValues: providers.map { ($0.key.rawValue, $0.value) })
|
||||||
|
try container.encode(raw, forKey: .providers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AuthSession: Codable {
|
||||||
|
var authenticated: Bool
|
||||||
|
var mode: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatListResponse: Codable {
|
||||||
|
var chats: [ChatSummary]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SearchListResponse: Codable {
|
||||||
|
var searches: [SearchSummary]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatDetailResponse: Codable {
|
||||||
|
var chat: ChatDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SearchDetailResponse: Codable {
|
||||||
|
var search: SearchDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatCreateResponse: Codable {
|
||||||
|
var chat: ChatSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SearchCreateResponse: Codable {
|
||||||
|
var search: SearchSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DeleteResponse: Codable {
|
||||||
|
var deleted: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SuggestTitleBody: Codable {
|
||||||
|
var chatId: String
|
||||||
|
var content: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum APIError: LocalizedError {
|
||||||
|
case invalidBaseURL
|
||||||
|
case httpError(statusCode: Int, message: String)
|
||||||
|
case networkError(message: String)
|
||||||
|
case decodingError(message: String)
|
||||||
|
case invalidResponse
|
||||||
|
case noResponseStream
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidBaseURL:
|
||||||
|
return "Invalid API URL"
|
||||||
|
case let .httpError(_, message):
|
||||||
|
return message
|
||||||
|
case let .networkError(message):
|
||||||
|
return message
|
||||||
|
case let .decodingError(message):
|
||||||
|
return message
|
||||||
|
case .invalidResponse:
|
||||||
|
return "Unexpected server response"
|
||||||
|
case .noResponseStream:
|
||||||
|
return "No response stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DateFormatter {
|
||||||
|
static let sybilDisplayDate: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = .autoupdatingCurrent
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Date {
|
||||||
|
var sybilRelativeLabel: String {
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.locale = .autoupdatingCurrent
|
||||||
|
formatter.unitsStyle = .abbreviated
|
||||||
|
return formatter.localizedString(for: self, relativeTo: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
222
ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift
Normal file
222
ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum PhoneRoute: Hashable {
|
||||||
|
case chat(String)
|
||||||
|
case search(String)
|
||||||
|
case draftChat
|
||||||
|
case draftSearch
|
||||||
|
case settings
|
||||||
|
|
||||||
|
static func from(selection: SidebarSelection) -> PhoneRoute {
|
||||||
|
switch selection {
|
||||||
|
case let .chat(chatID):
|
||||||
|
return .chat(chatID)
|
||||||
|
case let .search(searchID):
|
||||||
|
return .search(searchID)
|
||||||
|
case .settings:
|
||||||
|
return .settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SybilPhoneShellView: View {
|
||||||
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
@State private var path: [PhoneRoute] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack(path: $path) {
|
||||||
|
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
|
||||||
|
.navigationTitle("Sybil")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.navigationDestination(for: PhoneRoute.self) { route in
|
||||||
|
SybilPhoneDestinationView(viewModel: viewModel, route: route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SybilPhoneSidebarRoot: View {
|
||||||
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
@Binding var path: [PhoneRoute]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
buttonRow
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.overlay(SybilTheme.border)
|
||||||
|
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(SybilTheme.danger)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.overlay(SybilTheme.border)
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
|
Text("Loading conversations…")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.padding(16)
|
||||||
|
} else if viewModel.sidebarItems.isEmpty {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Image(systemName: "message.badge")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
Text("Start a chat or run your first search.")
|
||||||
|
.font(.footnote)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding(16)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(viewModel.sidebarItems) { item in
|
||||||
|
NavigationLink(value: PhoneRoute.from(selection: item.selection)) {
|
||||||
|
SybilPhoneSidebarRow(item: item)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task {
|
||||||
|
await viewModel.deleteItem(item.selection)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.overlay(SybilTheme.border)
|
||||||
|
|
||||||
|
NavigationLink(value: PhoneRoute.settings) {
|
||||||
|
Label("Settings", systemImage: "gearshape")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
.background(SybilTheme.surfaceStrong.opacity(0.84))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buttonRow: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button {
|
||||||
|
viewModel.startNewChat()
|
||||||
|
path.append(.draftChat)
|
||||||
|
} label: {
|
||||||
|
Label("New chat", systemImage: "plus")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(SybilTheme.primarySoft)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.startNewSearch()
|
||||||
|
path.append(.draftSearch)
|
||||||
|
} label: {
|
||||||
|
Label("New search", systemImage: "magnifyingglass")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SybilPhoneSidebarRow: View {
|
||||||
|
var item: SidebarItem
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: item.kind == .chat ? "message" : "globe")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
|
||||||
|
Text(item.title)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(item.updatedAt.sybilRelativeLabel)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
|
||||||
|
if let initiated = item.initiatedLabel {
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(initiated)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(SybilTheme.surface.opacity(0.55))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SybilPhoneDestinationView: View {
|
||||||
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
let route: PhoneRoute
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
SybilWorkspaceView(viewModel: viewModel)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.task(id: route) {
|
||||||
|
applyRoute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyRoute() {
|
||||||
|
switch route {
|
||||||
|
case let .chat(chatID):
|
||||||
|
viewModel.select(.chat(chatID))
|
||||||
|
case let .search(searchID):
|
||||||
|
viewModel.select(.search(searchID))
|
||||||
|
case .draftChat:
|
||||||
|
viewModel.startNewChat()
|
||||||
|
case .draftSearch:
|
||||||
|
viewModel.startNewSearch()
|
||||||
|
case .settings:
|
||||||
|
viewModel.openSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
269
ios/Packages/Sybil/Sources/Sybil/SybilSearchResultsView.swift
Normal file
269
ios/Packages/Sybil/Sources/Sybil/SybilSearchResultsView.swift
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import MarkdownUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SybilSearchResultsView: View {
|
||||||
|
var search: SearchDetail?
|
||||||
|
var isLoading: Bool
|
||||||
|
var isRunning: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
if let query = search?.query, !query.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Results for")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
Text(query)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
Text(resultCountLabel)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isRunning || (search?.answerText?.isEmpty == false) || (search?.answerError?.isEmpty == false) {
|
||||||
|
answerCard
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || isRunning) && (search?.results.isEmpty ?? true) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(SybilTheme.textMuted)
|
||||||
|
Text(isRunning ? "Searching Exa…" : "Loading search…")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isLoading, !isRunning, let search, !search.query.orEmpty.isEmpty, search.results.isEmpty {
|
||||||
|
Text("No results found.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(search?.results ?? []) { result in
|
||||||
|
SearchResultCard(result: result)
|
||||||
|
.accessibilityLabel("SearchResultCard")
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = search?.error, !error.isEmpty {
|
||||||
|
Text(error)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(SybilTheme.danger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
}
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var resultCountLabel: String {
|
||||||
|
let count = search?.results.count ?? 0
|
||||||
|
let latency = search?.latencyMs
|
||||||
|
if let latency {
|
||||||
|
return "\(count) result\(count == 1 ? "" : "s") • \(latency) ms"
|
||||||
|
}
|
||||||
|
return "\(count) result\(count == 1 ? "" : "s")"
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var answerCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Answer")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.textCase(.uppercase)
|
||||||
|
.foregroundStyle(SybilTheme.primary)
|
||||||
|
|
||||||
|
if isRunning && (search?.answerText.orEmpty.isEmpty ?? true) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(SybilTheme.textMuted)
|
||||||
|
Text("Generating answer…")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
} else if let answer = search?.answerText, !answer.isEmpty {
|
||||||
|
Markdown(answer)
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.markdownTextStyle {
|
||||||
|
FontSize(15)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let answerError = search?.answerError, !answerError.isEmpty {
|
||||||
|
Text(answerError)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(SybilTheme.danger)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let citations = search?.answerCitations, !citations.isEmpty {
|
||||||
|
FlowLayout(spacing: 8) {
|
||||||
|
ForEach(Array(citations.prefix(8).enumerated()), id: \.offset) { index, citation in
|
||||||
|
if let link = citation.url ?? citation.id {
|
||||||
|
Link(destination: URL(string: link) ?? URL(string: "https://example.com")!) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("[\(index + 1)]")
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.foregroundStyle(SybilTheme.primary)
|
||||||
|
Text(citation.title.orEmpty.isEmpty ? host(for: link) : citation.title.orEmpty)
|
||||||
|
.font(.caption)
|
||||||
|
.lineLimit(1)
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(SybilTheme.surface)
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.stroke(SybilTheme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(SybilTheme.searchCard)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.stroke(SybilTheme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func host(for raw: String) -> String {
|
||||||
|
guard let url = URL(string: raw), let host = url.host else { return raw }
|
||||||
|
if host.hasPrefix("www.") {
|
||||||
|
return String(host.dropFirst(4))
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SearchResultCard: View {
|
||||||
|
var result: SearchResultItem
|
||||||
|
|
||||||
|
private var resolvedURL: URL? {
|
||||||
|
URL(string: result.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 7) {
|
||||||
|
Text(host)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(SybilTheme.primary.opacity(0.9))
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
if let resolvedURL {
|
||||||
|
Link(destination: resolvedURL) {
|
||||||
|
Text(result.title.orEmpty.orFallback(result.url))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(SybilTheme.primary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
} else {
|
||||||
|
Text(result.title.orEmpty.orFallback(result.url))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(SybilTheme.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let date = result.publishedDate, !date.isEmpty {
|
||||||
|
Text(date + (result.author.orEmpty.isEmpty ? "" : " • \(result.author.orEmpty)"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
} else if let author = result.author, !author.isEmpty {
|
||||||
|
Text(author)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(result.url)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(SybilTheme.text.opacity(0.92))
|
||||||
|
.lineLimit(3)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
|
||||||
|
if let highlights = result.highlights, !highlights.isEmpty {
|
||||||
|
ForEach(Array(highlights.prefix(2).enumerated()), id: \.offset) { _, highlight in
|
||||||
|
Text("• \(highlight)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(SybilTheme.searchCard.opacity(0.95))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(SybilTheme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var host: String {
|
||||||
|
guard let resolvedURL, let host = resolvedURL.host else {
|
||||||
|
return result.url
|
||||||
|
}
|
||||||
|
if host.hasPrefix("www.") {
|
||||||
|
return String(host.dropFirst(4))
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FlowLayout<Content: View>: View {
|
||||||
|
var spacing: CGFloat
|
||||||
|
@ViewBuilder var content: Content
|
||||||
|
|
||||||
|
init(spacing: CGFloat = 8, @ViewBuilder content: () -> Content) {
|
||||||
|
self.spacing = spacing
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: spacing) {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Optional where Wrapped == String {
|
||||||
|
var orEmpty: String {
|
||||||
|
self ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
func orFallback(_ fallback: String) -> String {
|
||||||
|
isEmpty ? fallback : self
|
||||||
|
}
|
||||||
|
}
|
||||||
73
ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift
Normal file
73
ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class SybilSettingsStore {
|
||||||
|
private enum Keys {
|
||||||
|
static let apiBaseURL = "sybil.ios.apiBaseURL"
|
||||||
|
static let adminToken = "sybil.ios.adminToken"
|
||||||
|
static let preferredProvider = "sybil.ios.preferredProvider"
|
||||||
|
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
|
||||||
|
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
|
||||||
|
static let preferredXAIModel = "sybil.ios.preferredXAIModel"
|
||||||
|
}
|
||||||
|
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
|
var apiBaseURL: String
|
||||||
|
var adminToken: String
|
||||||
|
var preferredProvider: Provider
|
||||||
|
var preferredModelByProvider: [Provider: String]
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
|
||||||
|
let storedBaseURL = defaults.string(forKey: Keys.apiBaseURL)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let fallbackBaseURL = "http://127.0.0.1:8787/api"
|
||||||
|
self.apiBaseURL = storedBaseURL?.isEmpty == false ? storedBaseURL! : fallbackBaseURL
|
||||||
|
|
||||||
|
self.adminToken = defaults.string(forKey: Keys.adminToken) ?? ""
|
||||||
|
|
||||||
|
let provider = defaults.string(forKey: Keys.preferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
|
||||||
|
self.preferredProvider = provider
|
||||||
|
|
||||||
|
self.preferredModelByProvider = [
|
||||||
|
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
|
||||||
|
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
|
||||||
|
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
func persist() {
|
||||||
|
defaults.set(apiBaseURL.trimmingCharacters(in: .whitespacesAndNewlines), forKey: Keys.apiBaseURL)
|
||||||
|
|
||||||
|
let trimmedToken = adminToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmedToken.isEmpty {
|
||||||
|
defaults.removeObject(forKey: Keys.adminToken)
|
||||||
|
} else {
|
||||||
|
defaults.set(trimmedToken, forKey: Keys.adminToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaults.set(preferredProvider.rawValue, forKey: Keys.preferredProvider)
|
||||||
|
defaults.set(preferredModelByProvider[.openai], forKey: Keys.preferredOpenAIModel)
|
||||||
|
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
|
||||||
|
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmedTokenOrNil: String? {
|
||||||
|
let value = adminToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return value.isEmpty ? nil : value
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedAPIBaseURL: URL? {
|
||||||
|
var raw = apiBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !raw.isEmpty else { return nil }
|
||||||
|
|
||||||
|
if raw.hasSuffix("/") {
|
||||||
|
raw.removeLast()
|
||||||
|
}
|
||||||
|
|
||||||
|
return URL(string: raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
106
ios/Packages/Sybil/Sources/Sybil/SybilSettingsView.swift
Normal file
106
ios/Packages/Sybil/Sources/Sybil/SybilSettingsView.swift
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SybilSettingsView: View {
|
||||||
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
@Bindable var settings = viewModel.settings
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Connection")
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
Text("Use the same API root as the web client. Example: `http://127.0.0.1:8787/api`")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("API URL")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
|
||||||
|
TextField("http://127.0.0.1:8787/api", text: $settings.apiBaseURL)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(SybilTheme.surface)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(SybilTheme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text("Admin Token")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
|
||||||
|
SecureField("ADMIN_TOKEN (optional in open mode)", text: $settings.adminToken)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(SybilTheme.surface)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(SybilTheme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await viewModel.refreshAfterSettingsChange()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Save & Reconnect")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(SybilTheme.primarySoft)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
settings.adminToken = ""
|
||||||
|
Task {
|
||||||
|
await viewModel.refreshAfterSettingsChange()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("No Token")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let mode = viewModel.authMode {
|
||||||
|
Label(mode == "open" ? "Server is in open mode" : "Server requires token", systemImage: "dot.radiowaves.left.and.right")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let authError = viewModel.authError {
|
||||||
|
Text(authError)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(SybilTheme.danger)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let runtimeError = viewModel.errorMessage {
|
||||||
|
Text(runtimeError)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(SybilTheme.danger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.frame(maxWidth: 640, alignment: .leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
162
ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift
Normal file
162
ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SybilSidebarView: View {
|
||||||
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
|
||||||
|
private func iconName(for item: SidebarItem) -> String {
|
||||||
|
switch item.kind {
|
||||||
|
case .chat: return "message"
|
||||||
|
case .search: return "globe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isSelected(_ item: SidebarItem) -> Bool {
|
||||||
|
viewModel.draftKind == nil && viewModel.selectedItem == item.selection
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button {
|
||||||
|
viewModel.startNewChat()
|
||||||
|
} label: {
|
||||||
|
Label("New chat", systemImage: "plus")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(viewModel.draftKind == .chat ? SybilTheme.primary : SybilTheme.primarySoft)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.startNewSearch()
|
||||||
|
} label: {
|
||||||
|
Label("New search", systemImage: "magnifyingglass")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(viewModel.draftKind == .search ? SybilTheme.primary : SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.overlay(SybilTheme.border)
|
||||||
|
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(SybilTheme.danger)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.overlay(SybilTheme.border)
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
|
Text("Loading conversations…")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.padding(16)
|
||||||
|
} else if viewModel.sidebarItems.isEmpty {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Image(systemName: "message.badge")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
Text("Start a chat or run your first search.")
|
||||||
|
.font(.footnote)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding(16)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(viewModel.sidebarItems) { item in
|
||||||
|
Button {
|
||||||
|
viewModel.select(item.selection)
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: iconName(for: item))
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
|
||||||
|
Text(item.title)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(item.updatedAt.sybilRelativeLabel)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
|
||||||
|
if let initiated = item.initiatedLabel {
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(initiated)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(isSelected(item) ? SybilTheme.primary.opacity(0.28) : SybilTheme.surface.opacity(0.4))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(isSelected(item) ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task {
|
||||||
|
await viewModel.deleteItem(item.selection)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.overlay(SybilTheme.border)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.openSettings()
|
||||||
|
} label: {
|
||||||
|
Label("Settings", systemImage: "gearshape")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(viewModel.selectedItem == .settings ? SybilTheme.primary.opacity(0.28) : Color.clear)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
.background(SybilTheme.surfaceStrong.opacity(0.84))
|
||||||
|
}
|
||||||
|
}
|
||||||
28
ios/Packages/Sybil/Sources/Sybil/SybilTheme.swift
Normal file
28
ios/Packages/Sybil/Sources/Sybil/SybilTheme.swift
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum SybilTheme {
|
||||||
|
static let background = Color(red: 0.07, green: 0.05, blue: 0.11)
|
||||||
|
static let surface = Color(red: 0.14, green: 0.09, blue: 0.19)
|
||||||
|
static let surfaceStrong = Color(red: 0.17, green: 0.10, blue: 0.23)
|
||||||
|
static let card = Color(red: 0.12, green: 0.08, blue: 0.17)
|
||||||
|
static let border = Color(red: 0.30, green: 0.22, blue: 0.39)
|
||||||
|
static let primary = Color(red: 0.66, green: 0.47, blue: 0.94)
|
||||||
|
static let primarySoft = Color(red: 0.53, green: 0.37, blue: 0.78)
|
||||||
|
static let text = Color(red: 0.95, green: 0.91, blue: 0.98)
|
||||||
|
static let textMuted = Color(red: 0.72, green: 0.65, blue: 0.80)
|
||||||
|
static let searchCard = Color(red: 0.16, green: 0.10, blue: 0.22)
|
||||||
|
static let userBubble = Color(red: 0.35, green: 0.20, blue: 0.62)
|
||||||
|
static let danger = Color(red: 0.93, green: 0.38, blue: 0.42)
|
||||||
|
|
||||||
|
static var backgroundGradient: LinearGradient {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.27, green: 0.12, blue: 0.36),
|
||||||
|
Color(red: 0.15, green: 0.09, blue: 0.23),
|
||||||
|
Color(red: 0.07, green: 0.05, blue: 0.11)
|
||||||
|
],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
1044
ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift
Normal file
1044
ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift
Normal file
File diff suppressed because it is too large
Load Diff
193
ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift
Normal file
193
ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SybilWorkspaceView: View {
|
||||||
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
@FocusState private var composerFocused: Bool
|
||||||
|
|
||||||
|
private var isSettingsSelected: Bool {
|
||||||
|
if case .settings = viewModel.selectedItem {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.overlay(SybilTheme.border)
|
||||||
|
|
||||||
|
Group {
|
||||||
|
if isSettingsSelected {
|
||||||
|
SybilSettingsView(viewModel: viewModel)
|
||||||
|
} else if viewModel.isSearchMode {
|
||||||
|
SybilSearchResultsView(
|
||||||
|
search: viewModel.selectedSearch,
|
||||||
|
isLoading: viewModel.isLoadingSelection,
|
||||||
|
isRunning: viewModel.isSending
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
SybilChatTranscriptView(
|
||||||
|
messages: viewModel.displayedMessages,
|
||||||
|
isLoading: viewModel.isLoadingSelection,
|
||||||
|
isSending: viewModel.isSending
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
|
if viewModel.showsComposer {
|
||||||
|
Divider()
|
||||||
|
.overlay(SybilTheme.border)
|
||||||
|
composerBar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(viewModel.selectedTitle)
|
||||||
|
.background(SybilTheme.background)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.onChange(of: viewModel.isSending) { _, isSending in
|
||||||
|
if !isSending, viewModel.showsComposer {
|
||||||
|
composerFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if !viewModel.isSearchMode && !isSettingsSelected {
|
||||||
|
providerControls
|
||||||
|
} else if viewModel.isSearchMode {
|
||||||
|
Label("Search mode", systemImage: "globe")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(SybilTheme.surface)
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.stroke(SybilTheme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = viewModel.errorMessage {
|
||||||
|
Text(error)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(SybilTheme.danger)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var providerControls: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Menu {
|
||||||
|
ForEach(Provider.allCases, id: \.self) { candidate in
|
||||||
|
Button(candidate.displayName) {
|
||||||
|
viewModel.setProvider(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label(viewModel.provider.displayName, systemImage: "chevron.down")
|
||||||
|
.labelStyle(.titleAndIcon)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(SybilTheme.surface)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(SybilTheme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
ForEach(viewModel.providerModelOptions, id: \.self) { model in
|
||||||
|
Button(model) {
|
||||||
|
viewModel.setModel(model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label(viewModel.model, systemImage: "chevron.down")
|
||||||
|
.labelStyle(.titleAndIcon)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(SybilTheme.surface)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(SybilTheme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var composerBar: some View {
|
||||||
|
HStack(alignment: .bottom, spacing: 10) {
|
||||||
|
TextField(
|
||||||
|
viewModel.isSearchMode ? "Search the web" : "Message Sybil",
|
||||||
|
text: $viewModel.composer,
|
||||||
|
axis: .vertical
|
||||||
|
)
|
||||||
|
.focused($composerFocused)
|
||||||
|
.textInputAutocapitalization(.sentences)
|
||||||
|
.autocorrectionDisabled(false)
|
||||||
|
.lineLimit(1 ... 6)
|
||||||
|
.submitLabel(.send)
|
||||||
|
.onSubmit {
|
||||||
|
Task {
|
||||||
|
await viewModel.sendComposer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(SybilTheme.surface)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(SybilTheme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await viewModel.sendComposer()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
|
||||||
|
.font(.headline.weight(.semibold))
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending ? SybilTheme.surface : SybilTheme.primary)
|
||||||
|
)
|
||||||
|
.foregroundStyle(viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending ? SybilTheme.textMuted : SybilTheme.text)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(SybilTheme.background)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
ios/justfile
Normal file
10
ios/justfile
Normal file
@@ -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
|
||||||
4
ios/mise.toml
Normal file
4
ios/mise.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[tools]
|
||||||
|
just = "latest"
|
||||||
|
xcodegen = "latest"
|
||||||
|
xcbeautify = "latest"
|
||||||
@@ -2,5 +2,8 @@ name: Sybil
|
|||||||
options:
|
options:
|
||||||
createIntermediateGroups: true
|
createIntermediateGroups: true
|
||||||
generateEmptyDirectories: true
|
generateEmptyDirectories: true
|
||||||
|
packages:
|
||||||
|
Sybil:
|
||||||
|
path: Packages/Sybil
|
||||||
include:
|
include:
|
||||||
- Apps/Sybil/project.yml
|
- Apps/Sybil/project.yml
|
||||||
|
|||||||
Reference in New Issue
Block a user