Initial iOS port - Complete source code and build system

- 19 Swift source files (~4900 lines)
- Complete UI with SwiftUI (MainView, SettingsView, MessageBubble, InputBar)
- Inference layer (LlmEngine, Agent, ToolCalling, ConversationContext)
- Services (Audio, TTS, WebSearch, ModelDownload, Storage)
- Build system: Makefile, Package.swift, Podfile
- Documentation: BUILD.md, plan.md, PROJECT_STATUS.md
- Ready for Xcode build - just need LiteRT dependency added
This commit is contained in:
2026-04-06 14:26:08 +02:00
commit bbcf0c74bb
28 changed files with 5747 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
import SwiftUI
import AVFoundation
@main
struct SleepyAgentApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
let container = AppContainer()
var body: some Scene {
WindowGroup {
MainView()
.environmentObject(container.mainViewModel)
.environmentObject(container.settingsViewModel)
}
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
AudioSessionManager.shared.configure()
return true
}
}
// MARK: - Audio Session Manager
class AudioSessionManager {
static let shared = AudioSessionManager()
func configure() {
let audioSession = AVAudioSession.sharedInstance()
do {
try audioSession.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetooth])
try audioSession.setActive(true)
} catch {
print("Failed to configure audio session: \(error)")
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import Foundation
@MainActor
final class AppContainer: ObservableObject {
let audioRecorder = AudioRecorder()
let ttsService = TtsService()
let webSearchService = WebSearchService()
let modelDownloadService = ModelDownloadService()
let conversationStorage = ConversationStorage()
let llmEngine = LlmEngine()
let conversationContext = ConversationContext()
lazy var agent: Agent = {
Agent(llmEngine: llmEngine, context: conversationContext, webSearch: webSearchService)
}()
lazy var mainViewModel: MainViewModel = {
MainViewModel(
agent: agent,
audioRecorder: audioRecorder,
ttsService: ttsService,
conversationStorage: conversationStorage,
llmEngine: llmEngine
)
}()
lazy var settingsViewModel: SettingsViewModel = {
SettingsViewModel(
modelDownloadService: modelDownloadService,
llmEngine: llmEngine
)
}()
init() {
Task {
await autoDetectModel()
}
}
private func autoDetectModel() async {
let fileManager = FileManager.default
guard let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path else { return }
let modelsPath = (documentsPath as NSString).appendingPathComponent("models")
let e2bPath = (modelsPath as NSString).appendingPathComponent("gemma-4-E2B-it.litertlm")
let e4bPath = (modelsPath as NSString).appendingPathComponent("gemma-4-E4B-it.litertlm")
if fileManager.fileExists(atPath: e2bPath) {
_ = try? await llmEngine.loadModel(path: e2bPath)
} else if fileManager.fileExists(atPath: e4bPath) {
_ = try? await llmEngine.loadModel(path: e4bPath)
}
}
}
+62
View File
@@ -0,0 +1,62 @@
import Foundation
enum MessageRole: String, Codable {
case user
case assistant
case tool
case system
}
struct Message: Identifiable, Codable, Equatable {
let id: UUID
var role: MessageRole
var content: String
var toolCalls: [ToolCall]?
var toolCallId: String?
let timestamp: Date
init(id: UUID = UUID(), role: MessageRole, content: String, toolCalls: [ToolCall]? = nil, toolCallId: String? = nil, timestamp: Date = Date()) {
self.id = id
self.role = role
self.content = content
self.toolCalls = toolCalls
self.toolCallId = toolCallId
self.timestamp = timestamp
}
static func user(_ content: String) -> Message {
Message(role: .user, content: content)
}
static func assistant(_ content: String, toolCalls: [ToolCall]? = nil) -> Message {
Message(role: .assistant, content: content, toolCalls: toolCalls)
}
}
struct ToolCall: Codable, Equatable {
let id: String
let name: String
let arguments: [String: String]
}
enum ModelVariant: String, Codable, CaseIterable {
case e2b = "E2B"
case e4b = "E4B"
case custom = "Custom"
var displayName: String {
switch self {
case .e2b: return "Gemma 4 E2B (Fast)"
case .e4b: return "Gemma 4 E4B (Quality)"
case .custom: return "Custom Model"
}
}
var sizeDescription: String {
switch self {
case .e2b: return "~2.7 GB"
case .e4b: return "~4.5 GB"
case .custom: return "Varies"
}
}
}
+359
View File
@@ -0,0 +1,359 @@
import Foundation
import UIKit
// MARK: - Agent Events
/// Events emitted by the Agent during processing
public enum AgentEvent: Sendable {
case token(String)
case executingTool(toolName: String, arguments: [String: String])
case toolResult(toolName: String, result: String)
case complete(String)
case error(String)
}
// MARK: - Agent State
public enum AgentState: Sendable, Equatable {
case idle
case generating
case executingTool
case streaming
case error
}
// MARK: - Agent Errors
public enum AgentError: LocalizedError {
case modelNotLoaded
case maxIterationsReached
case processingFailed(underlying: Error)
public var errorDescription: String? {
switch self {
case .modelNotLoaded:
return "LLM model is not loaded"
case .maxIterationsReached:
return "Maximum tool calling iterations reached"
case .processingFailed(let error):
return "Processing failed: \(error.localizedDescription)"
}
}
}
// MARK: - Agent
/// High-level agent that manages conversation with the LLM, including tool calling
/// Supports multiple Gemma tool call formats
@MainActor
public final class Agent: ObservableObject {
// MARK: - Constants
private let maxIterations = 5
// MARK: - Dependencies
private let llmEngine: any LlmEngine
private let context: ConversationContext
private let toolRegistry: ToolRegistry
private let toolParser = ToolParser()
// MARK: - State
@Published public private(set) var state: AgentState = .idle
private var conversation: Conversation?
private let systemPrompt: String
// MARK: - Initialization
public init(
llmEngine: any LlmEngine,
context: ConversationContext,
toolRegistry: ToolRegistry,
customSystemPrompt: String? = nil
) {
self.llmEngine = llmEngine
self.context = context
self.toolRegistry = toolRegistry
// Build system prompt with tools description
var basePrompt = customSystemPrompt ?? Self.defaultSystemPrompt
// We'll append tool descriptions asynchronously
self.systemPrompt = basePrompt
}
private static let defaultSystemPrompt = """
You are a helpful AI assistant with access to tools.
When you need to use a tool, you MUST output EXACTLY in this format:
<|tool_call>call:tool_name{"name": "tool_name", "arguments": {"param": "value"}}<tool_call|>
IMPORTANT:
- Replace 'tool_name' with the actual tool name you want to use
- Do NOT use 'tool_name' as a placeholder - use the real tool name
- For web_search, use: {"name": "web_search", "arguments": {"query": "..."}}
After receiving tool results, provide a helpful response to the user.
"""
// MARK: - Conversation Management
private func ensureConversation() async throws -> Conversation {
if conversation?.isAlive != true {
// Build complete system prompt with current tools
let toolsDescription = await toolRegistry.buildToolsDescription()
let completePrompt = systemPrompt + toolsDescription
conversation = try llmEngine.createConversation(systemPrompt: completePrompt)
}
return conversation!
}
/// Pre-warms the KV cache with system prompt
public func prewarmCache() async {
do {
_ = try await ensureConversation()
} catch {
print("Failed to pre-warm cache: \(error)")
}
}
/// Resets the conversation and clears context
public func reset() async {
conversation?.close()
conversation = nil
await context.clear()
state = .idle
}
// MARK: - Processing
/// Process user input with optional multimodal data
/// - Parameters:
/// - input: The user's text input
/// - audioData: Optional audio data (WAV or PCM)
/// - images: Optional images
/// - Returns: AsyncStream of AgentEvents
public func processInput(
input: String,
audioData: Data? = nil,
images: [UIImage]? = nil
) -> AsyncStream<AgentEvent> {
AsyncStream { continuation in
Task {
do {
try await processInputInternal(
input: input,
audioData: audioData,
images: images,
continuation: continuation
)
} catch {
continuation.yield(.error(error.localizedDescription))
await MainActor.run { state = .error }
}
continuation.finish()
}
}
}
private func processInputInternal(
input: String,
audioData: Data?,
images: [UIImage]?,
continuation: AsyncStream<AgentEvent>.Continuation
) async throws {
// Add user message to context
let _ = await context.addMessage(.user(content: input))
// Ensure conversation is ready
let conv = try await ensureConversation()
var iteration = 0
var currentAudio = audioData
var currentImages = images
// Main conversation loop
while iteration < maxIterations {
iteration += 1
await MainActor.run { state = .generating }
// Build prompt from context (for subsequent iterations)
let prompt = iteration == 1 ? input : await context.buildPrompt()
// Generate response
var fullResponse = ""
let stream = llmEngine.generateStream(
conversation: conv,
prompt: prompt,
audioData: currentAudio,
images: currentImages
)
for try await token in stream {
fullResponse.append(token)
}
// Clear multimodal data after first iteration
currentAudio = nil
currentImages = nil
// Parse for tool calls
let toolCalls = toolParser.parseToolCalls(from: fullResponse)
if toolCalls.isEmpty {
// No tool calls - stream response to user
await MainActor.run { state = .streaming }
// Yield tokens with slight delay for streaming effect
for char in fullResponse {
continuation.yield(.token(String(char)))
try? await Task.sleep(nanoseconds: 5_000_000) // 5ms
}
// Add to context
let _ = await context.addMessage(.assistant(content: fullResponse, toolCalls: nil))
continuation.yield(.complete(fullResponse))
await MainActor.run { state = .idle }
return
}
// Tool calls detected
await MainActor.run { state = .executingTool }
let contentBeforeTools = toolParser.extractContentBeforeTools(from: fullResponse)
// Notify about tool execution
for toolCall in toolCalls {
let tool = await toolRegistry.getTool(named: toolCall.name)
let displayName = tool?.displayName ?? toolCall.name
continuation.yield(.executingTool(toolName: displayName, arguments: toolCall.arguments))
}
// Execute tools
let toolResults = await toolRegistry.executeToolCalls(toolCalls)
// Add assistant message with tool calls to context
let _ = await context.addMessage(.assistant(content: contentBeforeTools, toolCalls: toolCalls))
// Add tool results to context and notify
for result in toolResults {
let _ = await context.addMessage(.toolResult(
toolCallId: result.id,
toolName: result.name,
result: result.result
))
continuation.yield(.toolResult(toolName: result.name, result: result.result))
}
}
// Max iterations reached - generate final response
await MainActor.run { state = .generating }
let finalPrompt = await context.buildPrompt()
let finalStream = llmEngine.generateStream(
conversation: conv,
prompt: finalPrompt,
audioData: nil,
images: nil
)
var finalResponse = ""
for try await token in finalStream {
finalResponse.append(token)
}
await MainActor.run { state = .streaming }
for char in finalResponse {
continuation.yield(.token(String(char)))
try? await Task.sleep(nanoseconds: 5_000_000)
}
let _ = await context.addMessage(.assistant(content: finalResponse, toolCalls: nil))
continuation.yield(.complete(finalResponse))
await MainActor.run { state = .idle }
}
/// Process input and collect full response (non-streaming)
public func processInputComplete(
input: String,
audioData: Data? = nil,
images: [UIImage]? = nil
) async throws -> String {
var fullResponse = ""
for await event in processInput(input: input, audioData: audioData, images: images) {
switch event {
case .token(let text):
fullResponse.append(text)
case .complete(let response):
return response
case .error(let message):
throw AgentError.processingFailed(underlying: NSError(
domain: "AgentError",
code: -1,
userInfo: [NSLocalizedDescriptionKey: message]
))
default:
break
}
}
return fullResponse
}
// MARK: - Convenience Methods
/// Check if the agent is ready to process input
public func isReady() async -> Bool {
await llmEngine.isLoaded()
}
/// Get current conversation history
public func getHistory() async -> [Message] {
await context.getMessages()
}
/// Export conversation to JSON
public func exportConversation() async throws -> String {
try await context.exportToJSON()
}
/// Import conversation from JSON
public func importConversation(json: String) async throws {
try await context.importFromJSON(json)
}
}
// MARK: - Convenience Initializers
extension Agent {
/// Create an Agent with default web search tool
public static func createWithWebSearch(
llmEngine: any LlmEngine,
searchBaseUrl: String,
customSystemPrompt: String? = nil
) async -> Agent {
let context = ConversationContext()
let registry = ToolRegistry()
let webSearchTool = WebSearchTool(baseUrl: searchBaseUrl)
await registry.register(webSearchTool)
return Agent(
llmEngine: llmEngine,
context: context,
toolRegistry: registry,
customSystemPrompt: customSystemPrompt
)
}
}
@@ -0,0 +1,332 @@
import Foundation
// MARK: - Message Types
public enum Message: Codable, Equatable, Sendable {
case user(content: String)
case assistant(content: String, toolCalls: [ToolCall]?)
case toolResult(toolCallId: String, toolName: String, result: String)
case system(content: String)
private enum CodingKeys: String, CodingKey {
case type, content, toolCalls, toolCallId, toolName, result
}
private enum MessageType: String, Codable {
case user, assistant, toolResult, system
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .user(let content):
try container.encode(MessageType.user, forKey: .type)
try container.encode(content, forKey: .content)
case .assistant(let content, let toolCalls):
try container.encode(MessageType.assistant, forKey: .type)
try container.encode(content, forKey: .content)
try container.encode(toolCalls, forKey: .toolCalls)
case .toolResult(let toolCallId, let toolName, let result):
try container.encode(MessageType.toolResult, forKey: .type)
try container.encode(toolCallId, forKey: .toolCallId)
try container.encode(toolName, forKey: .toolName)
try container.encode(result, forKey: .result)
case .system(let content):
try container.encode(MessageType.system, forKey: .type)
try container.encode(content, forKey: .content)
}
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(MessageType.self, forKey: .type)
switch type {
case .user:
let content = try container.decode(String.self, forKey: .content)
self = .user(content: content)
case .assistant:
let content = try container.decode(String.self, forKey: .content)
let toolCalls = try container.decodeIfPresent([ToolCall].self, forKey: .toolCalls)
self = .assistant(content: content, toolCalls: toolCalls)
case .toolResult:
let toolCallId = try container.decode(String.self, forKey: .toolCallId)
let toolName = try container.decode(String.self, forKey: .toolName)
let result = try container.decode(String.self, forKey: .result)
self = .toolResult(toolCallId: toolCallId, toolName: toolName, result: result)
case .system:
let content = try container.decode(String.self, forKey: .content)
self = .system(content: content)
}
}
/// Convert message to text for token estimation
public func toText() -> String {
switch self {
case .user(let content):
return content
case .assistant(let content, let toolCalls):
let toolText = toolCalls?.map { "\($0.name) \($0.arguments)" }.joined(separator: " ") ?? ""
return content + toolText
case .toolResult(let toolName, _, let result):
return "\(toolName) \(result)"
case .system(let content):
return content
}
}
}
// MARK: - ToolCall
public struct ToolCall: Codable, Equatable, Sendable, Identifiable {
public let id: String
public let name: String
public let arguments: [String: String]
public init(id: String, name: String, arguments: [String: String]) {
self.id = id
self.name = name
self.arguments = arguments
}
}
// MARK: - Conversation Context Errors
public enum ConversationContextError: LocalizedError {
case messageTooLarge
case serializationFailed(underlying: Error)
case persistenceFailed(underlying: Error)
public var errorDescription: String? {
switch self {
case .messageTooLarge:
return "Message exceeds available token budget"
case .serializationFailed(let error):
return "Failed to serialize messages: \(error.localizedDescription)"
case .persistenceFailed(let error):
return "Failed to persist conversation: \(error.localizedDescription)"
}
}
}
// MARK: - Conversation Context
/// Manages conversation history with token budgeting and persistence
public actor ConversationContext {
// MARK: - Constants
public static let charsPerToken = 4
private static let defaultMaxTokens = 32768
private static let defaultReservedForResponse = 4096
// MARK: - Properties
private let systemPrompt: String
private let maxTokens: Int
private let reservedForResponse: Int
private var effectiveBudget: Int { maxTokens - reservedForResponse }
private var messages: [Message] = []
// MARK: - Initialization
public init(
systemPrompt: String = "You are a helpful AI assistant.",
maxTokens: Int = ConversationContext.defaultMaxTokens,
reservedForResponse: Int = ConversationContext.defaultReservedForResponse
) {
self.systemPrompt = systemPrompt
self.maxTokens = maxTokens
self.reservedForResponse = reservedForResponse
}
// MARK: - Message Management
/// Adds a message to the conversation context
/// - Parameter message: The message to add
/// - Returns: true if message was added successfully, false if it exceeds budget
@discardableResult
public func addMessage(_ message: Message) -> Bool {
let messageTokens = estimateTokens(message.toText())
// If even with empty context this message exceeds budget, reject it
if messageTokens > effectiveBudget {
return false
}
messages.append(message)
// Prune if necessary to stay within budget
pruneIfNeeded()
return true
}
/// Returns all messages in the conversation, including system prompt as first message
public func getMessages() -> [Message] {
[.system(content: systemPrompt)] + messages
}
/// Returns only the user/assistant/tool messages (excluding system)
public func getConversationMessages() -> [Message] {
messages
}
/// Clears all conversation messages (except system prompt)
public func clear() {
messages.removeAll()
}
// MARK: - Prompt Building
/// Builds a formatted prompt string with XML-style tags for LLM consumption
public func buildPrompt() -> String {
var result = ""
// System message first
result += "<system>\(escapeXml(systemPrompt))</system>\n"
// User messages
for message in messages {
switch message {
case .user(let content):
result += "<user>\(escapeXml(content))</user>\n"
case .assistant(let content, let toolCalls):
result += "<assistant>\(escapeXml(content))"
if let toolCalls = toolCalls {
for toolCall in toolCalls {
result += "\n<tool_call id=\"\(escapeXml(toolCall.id))\""
result += " name=\"\(escapeXml(toolCall.name))\">"
let argsStr = toolCall.arguments.map { (key, value) in
"\"\(escapeXml(key))\": \"\(escapeXml(value))\""
}.joined(separator: ", ")
result += "{\(argsStr)}"
result += "</tool_call>"
}
}
result += "</assistant>\n"
case .toolResult(let toolCallId, let toolName, let resultContent):
result += "<tool_result"
result += " id=\"\(escapeXml(toolCallId))\""
result += " tool=\"\(escapeXml(toolName))\">"
result += "\(escapeXml(resultContent))"
result += "</tool_result>\n"
case .system:
// System messages from the list shouldn't appear here
break
}
}
return result.trimmingCharacters(in: .whitespacesAndNewlines)
}
// MARK: - Token Management
/// Estimates token count for given text
/// Uses a simple heuristic: ~4 characters per token for English text
public func estimateTokens(_ text: String) -> Int {
text.count / ConversationContext.charsPerToken
}
/// Returns the current total token count including system prompt
public func getTokenCount() -> Int {
let systemTokens = estimateTokens(systemPrompt)
let messagesTokens = messages.map { estimateTokens($0.toText()) }.reduce(0, +)
return systemTokens + messagesTokens
}
/// Returns the available token budget for new messages
public func getAvailableTokens() -> Int {
effectiveBudget - getTokenCount() + estimateTokens(systemPrompt)
}
// MARK: - Persistence
/// Saves conversation to JSON file
public func save(to url: URL) throws {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(messages)
try data.write(to: url)
} catch {
throw ConversationContextError.persistenceFailed(underlying: error)
}
}
/// Loads conversation from JSON file
public func load(from url: URL) throws {
do {
let data = try Data(contentsOf: url)
let decoder = JSONDecoder()
let loadedMessages = try decoder.decode([Message].self, from: data)
messages = loadedMessages
} catch {
throw ConversationContextError.persistenceFailed(underlying: error)
}
}
/// Exports conversation to JSON string
public func exportToJSON() throws -> String {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(messages)
return String(data: data, encoding: .utf8) ?? "[]"
} catch {
throw ConversationContextError.serializationFailed(underlying: error)
}
}
/// Imports conversation from JSON string
public func importFromJSON(_ json: String) throws {
do {
guard let data = json.data(using: .utf8) else {
throw ConversationContextError.serializationFailed(underlying: NSError(domain: "Invalid JSON string", code: -1))
}
let decoder = JSONDecoder()
let loadedMessages = try decoder.decode([Message].self, from: data)
messages = loadedMessages
} catch {
throw ConversationContextError.serializationFailed(underlying: error)
}
}
// MARK: - Private Helpers
private func pruneIfNeeded() {
while getTokenCount() > effectiveBudget && !messages.isEmpty {
// Find and remove the oldest non-system message
if let indexToRemove = messages.firstIndex(where: { message in
if case .system = message {
return false
}
return true
}) {
messages.remove(at: indexToRemove)
} else {
// No more non-system messages to remove
break
}
}
}
private func escapeXml(_ text: String) -> String {
text
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
}
}
+335
View File
@@ -0,0 +1,335 @@
import Foundation
import LiteRT
import UIKit
// MARK: - Errors
public enum LlmEngineError: LocalizedError {
case modelNotFound(path: String)
case modelNotLoaded
case conversationClosed
case generationFailed(underlying: Error)
case invalidMultimodalInput
case engineInitializationFailed(underlying: Error)
public var errorDescription: String? {
switch self {
case .modelNotFound(let path):
return "Model file not found at: \(path)"
case .modelNotLoaded:
return "No model is currently loaded"
case .conversationClosed:
return "Conversation has been closed"
case .generationFailed(let error):
return "Generation failed: \(error.localizedDescription)"
case .invalidMultimodalInput:
return "Invalid multimodal input provided"
case .engineInitializationFailed(let error):
return "Failed to initialize engine: \(error.localizedDescription)"
}
}
}
// MARK: - LiteRT Conversation Wrapper
/// Wrapper for LiteRT Conversation to manage lifecycle
public final class Conversation: @unchecked Sendable {
internal let liteRtConversation: LRTConversation
private let lock = NSLock()
private var isClosed = false
public var isAlive: Bool {
lock.lock()
defer { lock.unlock() }
return !isClosed
}
internal init(liteRtConversation: LRTConversation) {
self.liteRtConversation = liteRtConversation
}
public func close() {
lock.lock()
defer { lock.unlock() }
guard !isClosed else { return }
isClosed = true
liteRtConversation.close()
}
deinit {
close()
}
}
// MARK: - LlmEngine Protocol
/// LLM Engine interface for text generation with optional multimodal inputs
public protocol LlmEngine: Actor {
/// Load a model from the given path
func loadModel(path: String) async throws
/// Creates a new conversation with the given system prompt
/// This should be called once per chat session to enable KV cache reuse
func createConversation(systemPrompt: String) throws -> Conversation
/// Generate a response within an existing conversation
/// This reuses the KV cache from previous turns
func generate(
conversation: Conversation,
prompt: String,
audioData: Data?,
images: [UIImage]?
) async throws -> String
/// Generate a streaming response within an existing conversation
func generateStream(
conversation: Conversation,
prompt: String,
audioData: Data?,
images: [UIImage]?
) -> AsyncThrowingStream<String, Error>
/// Check if a model is currently loaded
func isLoaded() -> Bool
/// Unload the current model and free resources
func unload()
}
// MARK: - LiteRT-LM Engine Implementation
/// LiteRT-LM based LLM Engine implementation for Gemma models
/// Uses .litert model format - download from HuggingFace LiteRT Community
@globalActor
public actor LiteRtLlmEngine: LlmEngine {
public static let shared = LiteRtLlmEngine()
private var engine: LRTEngine?
private var currentModelPath: String?
private let maxTokens = 16384
private let cacheDirName = "litertlm_cache"
private init() {}
// MARK: - Model Loading
public func loadModel(path: String) async throws {
unload()
let modelFile = URL(fileURLWithPath: path)
guard FileManager.default.fileExists(atPath: path) else {
throw LlmEngineError.modelNotFound(path: path)
}
// Ensure cache directory exists
let cacheDir = FileManager.default.temporaryDirectory.appendingPathComponent(cacheDirName)
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
do {
let engineConfig = LRTEngineConfig(
modelPath: path,
backend: .cpu,
visionBackend: .cpu,
audioBackend: .cpu,
maxNumTokens: maxTokens,
cacheDir: cacheDir.path
)
let newEngine = LRTEngine(config: engineConfig)
try newEngine.initialize()
self.engine = newEngine
self.currentModelPath = path
} catch {
throw LlmEngineError.engineInitializationFailed(underlying: error)
}
}
// MARK: - Conversation Management
public func createConversation(systemPrompt: String) throws -> Conversation {
guard let engine = engine else {
throw LlmEngineError.modelNotLoaded
}
let systemContent = LRTContent.text(systemPrompt)
let conversationConfig = LRTConversationConfig(systemInstruction: systemContent)
let liteRtConversation = engine.createConversation(config: conversationConfig)
return Conversation(liteRtConversation: liteRtConversation)
}
// MARK: - Generation
public func generate(
conversation: Conversation,
prompt: String,
audioData: Data? = nil,
images: [UIImage]? = nil
) async throws -> String {
guard conversation.isAlive else {
throw LlmEngineError.conversationClosed
}
let contents = try buildContents(prompt: prompt, audioData: audioData, images: images)
let response = try conversation.liteRtConversation.sendMessage(contents)
return response.stringValue ?? ""
}
public func generateStream(
conversation: Conversation,
prompt: String,
audioData: Data? = nil,
images: [UIImage]? = nil
) -> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in
Task {
do {
guard conversation.isAlive else {
throw LlmEngineError.conversationClosed
}
// For multimodal inputs, use Contents API (non-streaming for now)
if audioData != nil || !(images?.isEmpty ?? true) {
let contents = try buildContents(prompt: prompt, audioData: audioData, images: images)
let response = try conversation.liteRtConversation.sendMessage(contents)
if let text = response.stringValue {
continuation.yield(text)
}
continuation.finish()
return
}
// Text-only streaming - reuses KV cache
let stream = conversation.liteRtConversation.sendMessageAsync(prompt)
for try await message in stream {
if let text = message.stringValue {
continuation.yield(text)
}
}
continuation.finish()
} catch {
continuation.finish(throwing: LlmEngineError.generationFailed(underlying: error))
}
}
}
}
// MARK: - Utility Methods
public func isLoaded() -> Bool {
engine != nil
}
public func unload() {
engine?.close()
engine = nil
currentModelPath = nil
}
// MARK: - Private Helpers
private func buildContents(
prompt: String,
audioData: Data?,
images: [UIImage]?
) throws -> LRTContents {
var contents: [LRTContent] = []
// Add images first if provided (max 1 for efficiency)
if let images = images {
for image in images.prefix(1) {
if let resizedImage = resizeImage(image, maxSize: CGSize(width: 512, height: 512)),
let jpegData = resizedImage.jpegData(compressionQuality: 0.85) {
contents.append(LRTContent.imageData(jpegData))
}
}
}
// Add audio if provided
if let audioData = audioData, audioData.count >= 6400 {
// Assume audio is already in WAV format or convert if needed
let wavData = isWavData(audioData) ? audioData : try convertPcmToWav(audioData)
contents.append(LRTContent.audioData(wavData))
}
// Add text prompt
contents.append(LRTContent.text(prompt))
return LRTContents(contents: contents)
}
private func resizeImage(_ image: UIImage, maxSize: CGSize) -> UIImage? {
let size = image.size
guard size.width > maxSize.width || size.height > maxSize.height else {
return image
}
let widthRatio = maxSize.width / size.width
let heightRatio = maxSize.height / size.height
let ratio = min(widthRatio, heightRatio)
let newSize = CGSize(width: size.width * ratio, height: size.height * ratio)
UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0)
defer { UIGraphicsEndImageContext() }
image.draw(in: CGRect(origin: .zero, size: newSize))
return UIGraphicsGetImageFromCurrentImageContext()
}
private func isWavData(_ data: Data) -> Bool {
// Check for WAV header: "RIFF" magic number
guard data.count >= 12 else { return false }
let header = data.prefix(4)
return header.elementsEqual([0x52, 0x49, 0x46, 0x46]) // "RIFF"
}
private func convertPcmToWav(_ pcmData: Data, sampleRate: Int32 = 16000, channels: UInt16 = 1) throws -> Data {
var wavData = Data()
// RIFF header
wavData.append("RIFF".data(using: .ascii)!)
// File size (will be filled later)
let fileSize = UInt32(pcmData.count + 36)
wavData.append(withUnsafeBytes(of: fileSize.littleEndian) { Data($0) })
// WAVE header
wavData.append("WAVE".data(using: .ascii)!)
// fmt chunk
wavData.append("fmt ".data(using: .ascii)!)
let fmtChunkSize: UInt32 = 16
wavData.append(withUnsafeBytes(of: fmtChunkSize.littleEndian) { Data($0) })
let audioFormat: UInt16 = 1 // PCM
wavData.append(withUnsafeBytes(of: audioFormat.littleEndian) { Data($0) })
wavData.append(withUnsafeBytes(of: channels.littleEndian) { Data($0) })
wavData.append(withUnsafeBytes(of: sampleRate.littleEndian) { Data($0) })
let byteRate = UInt32(sampleRate) * UInt32(channels) * 2 // 16-bit
wavData.append(withUnsafeBytes(of: byteRate.littleEndian) { Data($0) })
let blockAlign = channels * 2
wavData.append(withUnsafeBytes(of: blockAlign.littleEndian) { Data($0) })
let bitsPerSample: UInt16 = 16
wavData.append(withUnsafeBytes(of: bitsPerSample.littleEndian) { Data($0) })
// data chunk
wavData.append("data".data(using: .ascii)!)
let dataChunkSize = UInt32(pcmData.count)
wavData.append(withUnsafeBytes(of: dataChunkSize.littleEndian) { Data($0) })
wavData.append(pcmData)
return wavData
}
}
+421
View File
@@ -0,0 +1,421 @@
import Foundation
// MARK: - Tool Protocol
/// Protocol for tools that can be called by the Agent
public protocol Tool: Sendable {
var name: String { get }
var displayName: String { get }
var description: String { get }
func execute(arguments: [String: String]) async throws -> String
}
// MARK: - Web Search Tool
/// Web search tool using SearxNG API
public actor WebSearchTool: Tool {
public let name = "web_search"
public let displayName = "Web Search"
public let description = "Search the web for information. Parameters: query (string, required)"
private var baseUrl: String
private let urlSession: URLSession
/// Response models for SearxNG API
private struct SearxngResponse: Codable {
let query: String
let results: [SearchResult]
}
private struct SearchResult: Codable {
let title: String
let url: String
let content: String
let engine: String?
}
public init(baseUrl: String, urlSession: URLSession = .shared) {
self.baseUrl = baseUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
self.urlSession = urlSession
}
/// Updates the base URL for the search endpoint
public func updateBaseUrl(_ newUrl: String) {
self.baseUrl = newUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
}
public func execute(arguments: [String: String]) async throws -> String {
guard let query = arguments["query"] else {
return "Error: 'query' parameter is required"
}
return try await performSearch(query: query)
}
private func performSearch(query: String) async throws -> String {
guard var urlComponents = URLComponents(string: "\(baseUrl)/search") else {
throw ToolError.invalidURL
}
urlComponents.queryItems = [
URLQueryItem(name: "q", value: query),
URLQueryItem(name: "format", value: "json"),
URLQueryItem(name: "safesearch", value: "0")
]
guard let url = urlComponents.url else {
throw ToolError.invalidURL
}
let (data, response) = try await urlSession.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw ToolError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
throw ToolError.httpError(statusCode: httpResponse.statusCode)
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let searchResponse = try decoder.decode(SearxngResponse.self, from: data)
if searchResponse.results.isEmpty {
return "No results found for '\(query)'"
}
return searchResponse.results.prefix(5).map { result in
"""
Title: \(result.title)
URL: \(result.url)
Content: \(result.content)
"""
}.joined(separator: "\n\n")
}
}
// MARK: - Tool Errors
public enum ToolError: LocalizedError {
case invalidURL
case invalidResponse
case httpError(statusCode: Int)
case executionFailed(underlying: Error)
public var errorDescription: String? {
switch self {
case .invalidURL:
return "Invalid URL for tool execution"
case .invalidResponse:
return "Invalid response from tool"
case .httpError(let statusCode):
return "HTTP error: \(statusCode)"
case .executionFailed(let error):
return "Tool execution failed: \(error.localizedDescription)"
}
}
}
// MARK: - Tool Result
/// Result of a tool execution
public struct ToolResult: Sendable, Identifiable {
public let id: String
public let name: String
public let result: String
public init(id: String, name: String, result: String) {
self.id = id
self.name = name
self.result = result
}
}
// MARK: - Tool Parser
/// Parses tool calls from LLM responses
public struct ToolParser: Sendable {
/// Supported tool call patterns
private let patterns: [(start: String, end: String)] = [
("<|tool_call>call:", "<tool_call|>"),
("<|tool_call>", "<tool_call|>"),
("<tool_call>", "</tool_call>")
]
/// Parses tool calls from model response
/// Handles multiple Gemma tool call formats
public func parseToolCalls(from response: String) -> [ToolCall] {
var toolCalls: [ToolCall] = []
for (startTag, endTag) in patterns {
var currentIndex = response.startIndex
while true {
guard let startRange = response.range(of: startTag, range: currentIndex..<response.endIndex) else {
break
}
let contentStart = startRange.upperBound
guard let endRange = response.range(of: endTag, range: contentStart..<response.endIndex) else {
break
}
let content = String(response[contentStart..<endRange.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines)
if let toolCall = parseToolCallContent(content) {
toolCalls.append(toolCall)
}
currentIndex = endRange.upperBound
}
}
return toolCalls
}
/// Extracts content before any tool calls
public func extractContentBeforeTools(from response: String) -> String {
var firstIndex: String.Index? = nil
for (startTag, _) in patterns {
if let range = response.range(of: startTag) {
if firstIndex == nil || range.lowerBound < firstIndex! {
firstIndex = range.lowerBound
}
}
}
if let index = firstIndex {
return String(response[..<index]).trimmingCharacters(in: .whitespacesAndNewlines)
}
return response.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// Parse individual tool call content
/// Handles:
/// - tool_name{"name": "...", "arguments": {...}}
/// - tool_name{query: "value"}
/// - tool_name{query:<|"|>value<|"|>}
/// - {"name": "...", ...} (legacy)
private func parseToolCallContent(_ content: String) -> ToolCall? {
// Clean up special quote tokens first
let cleaned = content
.replacingOccurrences(of: "<|\"|>", with: "\"")
.replacingOccurrences(of: "\">|>", with: "\"")
// Has braces - parse as {args} or tool_name{args}
if let braceRange = cleaned.range(of: "{") {
let toolNamePart = String(cleaned[..<braceRange.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines)
let inner = String(cleaned[braceRange.lowerBound...])
// Try JSON first, then direct args
return parseAsJson(toolNamePrefix: toolNamePart, jsonStr: inner)
?? parseAsDirectArgs(toolName: toolNamePart, argsStr: inner)
}
// Try as pure JSON
if cleaned.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") {
return parseAsJson(toolNamePrefix: "", jsonStr: cleaned)
}
return nil
}
private func parseAsJson(toolNamePrefix: String, jsonStr: String) -> ToolCall? {
guard let data = jsonStr.data(using: .utf8) else {
return nil
}
do {
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
// Get tool name from "name" field or prefix
let toolName = (json["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
?? (toolNamePrefix.isEmpty ? nil : toolNamePrefix)
guard let name = toolName, !name.isEmpty else {
return nil
}
// Get arguments from "arguments" field or top-level
let args: [String: String]
if let arguments = json["arguments"] as? [String: Any] {
args = arguments.compactMapValues { value in
if let stringValue = value as? String {
return stringValue
}
return String(describing: value)
}
} else {
args = json
.filter { $0.key != "name" }
.compactMapValues { value in
if let stringValue = value as? String {
return stringValue
}
return String(describing: value)
}
}
return ToolCall(id: generateToolCallId(), name: name, arguments: args)
} catch {
return nil
}
}
private func parseAsDirectArgs(toolName: String, argsStr: String) -> ToolCall? {
// Extract content between outer braces
let inner = argsStr.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "{}"))
var args: [String: String] = [:]
var depth = 0
var current = ""
var parts: [String] = []
// Split by comma, respecting nested structures
for char in inner {
switch char {
case "{", "[":
depth += 1
current.append(char)
case "}", "]":
depth -= 1
current.append(char)
case ",":
if depth == 0 {
parts.append(current.trimmingCharacters(in: .whitespacesAndNewlines))
current = ""
} else {
current.append(char)
}
default:
current.append(char)
}
}
if !current.isEmpty {
parts.append(current.trimmingCharacters(in: .whitespacesAndNewlines))
}
// Parse each key:value pair
for part in parts {
if let colonRange = part.range(of: ":") {
let key = String(part[..<colonRange.lowerBound])
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
var value = String(part[colonRange.upperBound...])
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'{}"))
if !key.isEmpty {
args[key] = value
}
}
}
guard !args.isEmpty && !toolName.isEmpty else {
return nil
}
return ToolCall(id: generateToolCallId(), name: toolName, arguments: args)
}
private func generateToolCallId() -> String {
let uuid = UUID().uuidString
let prefix = String(uuid.prefix(8))
return "call_\(prefix)"
}
}
// MARK: - Tool Registry
/// Registry for managing available tools
public actor ToolRegistry {
private var tools: [String: any Tool] = [:]
public init() {}
/// Register a tool
public func register(_ tool: any Tool) {
tools[tool.name] = tool
}
/// Unregister a tool by name
public func unregister(name: String) {
tools.removeValue(forKey: name)
}
/// Get a tool by name
public func getTool(named name: String) -> (any Tool)? {
tools[name]
}
/// Get all registered tools
public func getAllTools() -> [any Tool] {
Array(tools.values)
}
/// Execute a tool with given arguments
public func executeTool(named name: String, arguments: [String: String]) async -> ToolResult {
let id = generateToolCallId()
guard let tool = tools[name] else {
return ToolResult(id: id, name: name, result: "Tool '\(name)' not found")
}
do {
let result = try await tool.execute(arguments: arguments)
return ToolResult(id: id, name: name, result: result)
} catch {
return ToolResult(id: id, name: name, result: "Error: \(error.localizedDescription)")
}
}
/// Execute multiple tool calls
public func executeToolCalls(_ toolCalls: [ToolCall]) async -> [ToolResult] {
var results: [ToolResult] = []
for toolCall in toolCalls {
let result = await executeTool(named: toolCall.name, arguments: toolCall.arguments)
results.append(result)
}
return results
}
/// Build system prompt snippet describing available tools
public func buildToolsDescription() -> String {
guard !tools.isEmpty else { return "" }
var description = "\nAvailable tools:\n"
for (_, tool) in tools.sorted(by: { $0.key < $1.key }) {
description += "- \(tool.name): \(tool.description)\n"
}
description += """
\nWhen you need to use a tool, you MUST output EXACTLY in this format:
<|tool_call>call:web_search{"name": "web_search", "arguments": {"query": "your search query here"}}<tool_call|>
IMPORTANT:
- Replace 'web_search' with the actual tool name you want to use
- Do NOT use 'tool_name' as a placeholder - use the real tool name
"""
return description
}
private func generateToolCallId() -> String {
let uuid = UUID().uuidString
let prefix = String(uuid.prefix(8))
return "call_\(prefix)"
}
}
+67
View File
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Sleepy Agent</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<!-- Privacy Permissions -->
<key>NSCameraUsageDescription</key>
<string>Sleepy Agent needs camera access to take photos for analysis</string>
<key>NSMicrophoneUsageDescription</key>
<string>Sleepy Agent needs microphone access for voice input</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Sleepy Agent needs photo library access to send images</string>
<!-- Network -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
+378
View File
@@ -0,0 +1,378 @@
import Foundation
import AVFoundation
// MARK: - Errors
enum AudioRecorderError: LocalizedError {
case alreadyRecording
case notRecording
case permissionDenied
case setupFailed(String)
case audioSessionFailed(String)
case engineFailed(String)
case invalidFormat
var errorDescription: String? {
switch self {
case .alreadyRecording:
return "Already recording"
case .notRecording:
return "Not currently recording"
case .permissionDenied:
return "Microphone permission denied"
case .setupFailed(let reason):
return "Setup failed: \(reason)"
case .audioSessionFailed(let reason):
return "Audio session failed: \(reason)"
case .engineFailed(let reason):
return "Audio engine failed: \(reason)"
case .invalidFormat:
return "Invalid audio format"
}
}
}
// MARK: - Delegate Protocol
protocol AudioRecorderDelegate: AnyObject {
func audioRecorder(_ recorder: AudioRecorder, didUpdateAudioLevel level: Float)
func audioRecorderDidDetectSpeech(_ recorder: AudioRecorder)
func audioRecorderDidDetectSilence(_ recorder: AudioRecorder)
}
// MARK: - Audio Recorder
/// Records audio with Voice Activity Detection for automatic silence-based stopping.
/// Outputs PCM 16-bit, 16kHz, mono WAV format suitable for Gemma 4 speech recognition.
actor AudioRecorder {
// MARK: - Constants
static let sampleRate: Double = 16000
static let channelCount: AVAudioChannelCount = 1
static let bitsPerSample = 16
static let bytesPerSample = 2 // 16-bit = 2 bytes
// VAD Configuration
private let speechThresholdDb: Float = -40.0
private let silenceThresholdDb: Float = -50.0
private let silenceTimeoutMs: TimeInterval = 2.0
private let minSpeechDurationMs: TimeInterval = 0.5
// MARK: - Properties
weak var delegate: AudioRecorderDelegate?
private var audioEngine: AVAudioEngine?
private var isRecording = false
private var audioData = Data()
// VAD State
private var hasDetectedSpeech = false
private var speechStartTime: Date?
private var lastSpeechTime: Date?
private var silenceTimer: Timer?
// MARK: - Initialization
init() {}
// MARK: - Public Methods
/// Checks if microphone permission is granted.
func checkPermission() async -> AVAuthorizationStatus {
await withCheckedContinuation { continuation in
AVAudioApplication.requestRecordPermission { granted in
continuation.resume(returning: granted ? .authorized : .denied)
}
}
}
/// Requests microphone permission if needed.
func requestPermission() async -> Bool {
let status = await checkPermission()
return status == .authorized
}
/// Starts recording audio with VAD.
/// - Throws: AudioRecorderError if setup or permission fails
func startRecording() async throws {
guard !isRecording else {
throw AudioRecorderError.alreadyRecording
}
// Check permission
let status = AVCaptureDevice.authorizationStatus(for: .audio)
guard status == .authorized else {
throw AudioRecorderError.permissionDenied
}
do {
try await setupAudioSession()
try await setupAudioEngine()
isRecording = true
audioData = Data()
// Reset VAD state
hasDetectedSpeech = false
speechStartTime = nil
lastSpeechTime = nil
try audioEngine?.start()
// Start silence monitoring timer
startSilenceMonitoring()
} catch {
cleanup()
throw AudioRecorderError.setupFailed(error.localizedDescription)
}
}
/// Stops recording and returns the recorded audio data as WAV.
/// - Returns: WAV formatted audio data
/// - Throws: AudioRecorderError if not recording or processing fails
func stopRecording() async throws -> Data {
guard isRecording else {
throw AudioRecorderError.notRecording
}
return try await performStopRecording()
}
/// Returns whether currently recording.
var isCurrentlyRecording: Bool {
get { isRecording }
}
// MARK: - Private Methods
private func setupAudioSession() async throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
try session.setActive(true)
}
private func setupAudioEngine() throws {
audioEngine = AVAudioEngine()
guard let engine = audioEngine else {
throw AudioRecorderError.engineFailed("Failed to create engine")
}
let inputNode = engine.inputNode
let inputFormat = inputNode.outputFormat(forBus: 0)
// Configure format: 16kHz, mono, 16-bit PCM
guard let format = AVAudioFormat(
commonFormat: .pcmFormatInt16,
sampleRate: Self.sampleRate,
channels: Self.channelCount,
interleaved: true
) else {
throw AudioRecorderError.invalidFormat
}
// Install tap on input node
inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] buffer, _ in
Task {
await self?.processAudioBuffer(buffer, targetFormat: format)
}
}
try engine.start()
engine.pause() // Pause until we're ready
}
private func processAudioBuffer(_ buffer: AVAudioPCMBuffer, targetFormat: AVAudioFormat) {
guard let converter = AVAudioConverter(from: buffer.format, to: targetFormat) else {
return
}
let convertedBuffer = AVAudioPCMBuffer(
pcmFormat: targetFormat,
frameCapacity: AVAudioFrameCount(Double(buffer.frameCapacity) * targetFormat.sampleRate / buffer.format.sampleRate)
)!
var error: NSError?
let inputBlock: AVAudioConverterInputBlock = { _, outStatus in
outStatus.pointee = .haveData
return buffer
}
converter.convert(to: convertedBuffer, error: &error, withInputFrom: inputBlock)
if let error = error {
print("Conversion error: \(error)")
return
}
// Extract PCM data
guard let channelData = convertedBuffer.int16ChannelData?[0] else { return }
let data = Data(bytes: channelData, count: Int(convertedBuffer.frameLength) * Self.bytesPerSample)
audioData.append(data)
// Calculate audio level for VAD
let level = calculateAudioLevel(from: data)
Task { @MainActor in
delegate?.audioRecorder(self, didUpdateAudioLevel: level)
}
// Process VAD
processVAD(audioLevel: level)
}
private func calculateAudioLevel(from data: Data) -> Float {
guard data.count >= 2 else { return -100.0 }
var sum: Double = 0
var count = 0
var offset = 0
while offset < data.count - 1 {
let sample = data.withUnsafeBytes { ptr -> Int16 in
ptr.load(fromByteOffset: offset, as: Int16.self)
}
let normalized = Double(sample) / Double(Int16.max)
sum += normalized * normalized
count += 1
offset += 2
}
guard count > 0 else { return -100.0 }
let rms = sqrt(sum / Double(count))
let db = 20 * log10(max(rms, 1e-10))
return Float(db)
}
private func processVAD(audioLevel: Float) {
let now = Date()
if audioLevel > speechThresholdDb {
// Speech detected
if !hasDetectedSpeech {
hasDetectedSpeech = true
speechStartTime = now
lastSpeechTime = now
Task { @MainActor in
delegate?.audioRecorderDidDetectSpeech(self)
}
} else {
lastSpeechTime = now
}
}
}
private func startSilenceMonitoring() {
silenceTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
Task {
await self?.checkSilenceTimeout()
}
}
}
private func checkSilenceTimeout() {
guard hasDetectedSpeech,
let speechStart = speechStartTime,
let lastSpeech = lastSpeechTime else { return }
let now = Date()
let speechDuration = now.timeIntervalSince(speechStart)
let silenceDuration = now.timeIntervalSince(lastSpeech)
if speechDuration > minSpeechDurationMs && silenceDuration > silenceTimeoutMs {
Task { @MainActor in
delegate?.audioRecorderDidDetectSilence(self)
}
}
}
private func performStopRecording() async throws -> Data {
silenceTimer?.invalidate()
silenceTimer = nil
audioEngine?.stop()
audioEngine?.inputNode.removeTap(onBus: 0)
isRecording = false
// Validate audio data
guard !audioData.isEmpty else {
throw AudioRecorderError.setupFailed("No audio data recorded")
}
// Minimum duration check (200ms)
let minBytes = Int(Self.sampleRate * Self.bytesPerSample * 0.2)
guard audioData.count >= minBytes else {
throw AudioRecorderError.setupFailed("Audio too short")
}
// Create WAV file
let wavData = createWavFile(from: audioData)
cleanup()
return wavData
}
private func createWavFile(from pcmData: Data) -> Data {
let sampleRate = UInt32(Self.sampleRate)
let channels = UInt16(Self.channelCount)
let bitsPerSample = UInt16(Self.bitsPerSample)
let byteRate = sampleRate * UInt32(channels) * UInt32(bitsPerSample / 8)
let blockAlign = channels * UInt16(bitsPerSample / 8)
var wavData = Data()
// RIFF header
wavData.append("RIFF".data(using: .ascii)!)
wavData.append(UInt32(36 + pcmData.count).littleEndianBytes)
wavData.append("WAVE".data(using: .ascii)!)
// fmt chunk
wavData.append("fmt ".data(using: .ascii)!)
wavData.append(UInt32(16).littleEndianBytes) // Subchunk1Size
wavData.append(UInt16(1).littleEndianBytes) // AudioFormat (PCM)
wavData.append(channels.littleEndianBytes)
wavData.append(sampleRate.littleEndianBytes)
wavData.append(byteRate.littleEndianBytes)
wavData.append(blockAlign.littleEndianBytes)
wavData.append(bitsPerSample.littleEndianBytes)
// data chunk
wavData.append("data".data(using: .ascii)!)
wavData.append(UInt32(pcmData.count).littleEndianBytes)
wavData.append(pcmData)
return wavData
}
private func cleanup() {
silenceTimer?.invalidate()
silenceTimer = nil
audioEngine?.stop()
audioEngine?.inputNode.removeTap(onBus: 0)
audioEngine = nil
isRecording = false
audioData = Data()
hasDetectedSpeech = false
speechStartTime = nil
lastSpeechTime = nil
}
}
// MARK: - Extensions
extension FixedWidthInteger {
var littleEndianBytes: Data {
var value = self.littleEndian
return Data(bytes: &value, count: MemoryLayout<Self>.size)
}
}
@@ -0,0 +1,385 @@
import Foundation
// MARK: - Errors
enum ConversationStorageError: LocalizedError {
case saveFailed(Error)
case loadFailed(Error)
case deleteFailed(Error)
case invalidData
case conversationNotFound
var errorDescription: String? {
switch self {
case .saveFailed(let error):
return "Failed to save conversation: \(error.localizedDescription)"
case .loadFailed(let error):
return "Failed to load conversation: \(error.localizedDescription)"
case .deleteFailed(let error):
return "Failed to delete conversation: \(error.localizedDescription)"
case .invalidData:
return "Invalid conversation data"
case .conversationNotFound:
return "Conversation not found"
}
}
}
// MARK: - Data Models
/// Represents a single message in a conversation.
struct ConversationMessage: Codable, Equatable, Identifiable {
let id: String
let text: String
let isUser: Bool
let isToolCall: Bool
let timestamp: Date
init(
id: String = UUID().uuidString,
text: String,
isUser: Bool,
isToolCall: Bool = false,
timestamp: Date = Date()
) {
self.id = id
self.text = text
self.isUser = isUser
self.isToolCall = isToolCall
self.timestamp = timestamp
}
}
/// Metadata about a saved conversation.
struct ConversationInfo: Codable, Equatable, Identifiable {
let id: String
let title: String
let timestamp: Date
let messageCount: Int
}
/// Full conversation data for storage.
struct SavedConversation: Codable {
let id: String
let title: String
let timestamp: Date
let messages: [ConversationMessage]
let version: Int
init(
id: String,
title: String,
timestamp: Date = Date(),
messages: [ConversationMessage],
version: Int = 1
) {
self.id = id
self.title = title
self.timestamp = timestamp
self.messages = messages
self.version = version
}
}
// MARK: - Storage
/// Manages persistence of conversations using JSON files.
actor ConversationStorage {
// MARK: - Properties
private let fileManager: FileManager
private let encoder: JSONEncoder
private let decoder: JSONDecoder
/// Directory where conversations are stored
let storageDirectory: URL
/// Maximum number of conversations to keep
var maxConversations: Int = 50
// MARK: - Initialization
init(
fileManager: FileManager = .default,
storageDirectory: URL? = nil
) {
self.fileManager = fileManager
// Setup encoder/decoder
self.encoder = JSONEncoder()
self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
self.encoder.dateEncodingStrategy = .iso8601
self.decoder = JSONDecoder()
self.decoder.dateDecodingStrategy = .iso8601
// Setup storage directory
if let directory = storageDirectory {
self.storageDirectory = directory
} else {
let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
self.storageDirectory = documentsDirectory.appendingPathComponent("conversations", isDirectory: true)
}
// Create directory if needed
try? fileManager.createDirectory(at: self.storageDirectory, withIntermediateDirectories: true)
}
// MARK: - Public Methods
/// Creates a new unique conversation ID.
func createNewConversationId() -> String {
UUID().uuidString
}
/// Saves a conversation to storage.
/// - Parameters:
/// - id: Conversation identifier
/// - messages: List of messages to save
/// - Throws: ConversationStorageError if save fails
func saveConversation(id: String, messages: [ConversationMessage]) throws {
let title = generateTitle(from: messages)
let conversation = SavedConversation(
id: id,
title: title,
timestamp: Date(),
messages: messages
)
let fileURL = storageDirectory.appendingPathComponent("\(id).json")
do {
let data = try encoder.encode(conversation)
try data.write(to: fileURL, options: [.atomic, .completeFileProtection])
// Clean up old conversations if needed
try cleanupOldConversations()
} catch {
throw ConversationStorageError.saveFailed(error)
}
}
/// Loads a conversation from storage.
/// - Parameter id: Conversation identifier
/// - Returns: Array of messages, or nil if not found
/// - Throws: ConversationStorageError if load fails
func loadConversation(id: String) throws -> [ConversationMessage]? {
let fileURL = storageDirectory.appendingPathComponent("\(id).json")
guard fileManager.fileExists(atPath: fileURL.path) else {
return nil
}
do {
let data = try Data(contentsOf: fileURL)
let conversation = try decoder.decode(SavedConversation.self, from: data)
return conversation.messages
} catch {
throw ConversationStorageError.loadFailed(error)
}
}
/// Deletes a conversation.
/// - Parameter id: Conversation identifier
/// - Throws: ConversationStorageError if delete fails
func deleteConversation(id: String) throws {
let fileURL = storageDirectory.appendingPathComponent("\(id).json")
guard fileManager.fileExists(atPath: fileURL.path) else {
throw ConversationStorageError.conversationNotFound
}
do {
try fileManager.removeItem(at: fileURL)
} catch {
throw ConversationStorageError.deleteFailed(error)
}
}
/// Gets metadata for all saved conversations, sorted by most recent.
/// - Returns: Array of conversation info
func getAllConversations() -> [ConversationInfo] {
do {
let files = try fileManager.contentsOfDirectory(at: storageDirectory, includingPropertiesForKeys: nil)
let jsonFiles = files.filter { $0.pathExtension == "json" }
return jsonFiles.compactMap { url -> ConversationInfo? in
guard let conversation = try? loadConversationMetadata(from: url) else { return nil }
return ConversationInfo(
id: conversation.id,
title: conversation.title,
timestamp: conversation.timestamp,
messageCount: conversation.messages.count
)
}.sorted { $0.timestamp > $1.timestamp }
} catch {
return []
}
}
/// Checks if a conversation exists.
func conversationExists(id: String) -> Bool {
let fileURL = storageDirectory.appendingPathComponent("\(id).json")
return fileManager.fileExists(atPath: fileURL.path)
}
/// Gets the total storage size used by conversations.
func storageSize() -> Int64 {
do {
let files = try fileManager.contentsOfDirectory(at: storageDirectory, includingPropertiesForKeys: [.fileSizeKey])
var totalSize: Int64 = 0
for file in files where file.pathExtension == "json" {
let attributes = try fileManager.attributesOfItem(atPath: file.path)
totalSize += attributes[.size] as? Int64 ?? 0
}
return totalSize
} catch {
return 0
}
}
/// Exports a conversation to a shareable format.
func exportConversation(id: String) throws -> Data {
let fileURL = storageDirectory.appendingPathComponent("\(id).json")
return try Data(contentsOf: fileURL)
}
/// Imports a conversation from exported data.
func importConversation(data: Data) throws -> String {
let conversation = try decoder.decode(SavedConversation.self, from: data)
let newId = createNewConversationId()
let importedConversation = SavedConversation(
id: newId,
title: conversation.title,
timestamp: Date(),
messages: conversation.messages
)
let fileURL = storageDirectory.appendingPathComponent("\(newId).json")
let encodedData = try encoder.encode(importedConversation)
try encodedData.write(to: fileURL, options: [.atomic, .completeFileProtection])
return newId
}
/// Clears all conversations.
func clearAllConversations() throws {
let files = try fileManager.contentsOfDirectory(at: storageDirectory, includingPropertiesForKeys: nil)
for file in files where file.pathExtension == "json" {
try fileManager.removeItem(at: file)
}
}
// MARK: - Private Methods
private func loadConversationMetadata(from url: URL) throws -> SavedConversation {
let data = try Data(contentsOf: url)
return try decoder.decode(SavedConversation.self, from: data)
}
private func generateTitle(from messages: [ConversationMessage]) -> String {
let maxLength = 50
// Use first user message as title
if let firstUserMessage = messages.first(where: { $0.isUser }) {
let text = firstUserMessage.text.trimmingCharacters(in: .whitespacesAndNewlines)
if text.count > maxLength {
let index = text.index(text.startIndex, offsetBy: maxLength)
return String(text[..<index]) + "..."
}
return text
}
// Fallback to first message or default
if let firstMessage = messages.first {
return firstMessage.text.prefix(maxLength).trimmingCharacters(in: .whitespacesAndNewlines)
}
return "New Chat"
}
private func cleanupOldConversations() throws {
let conversations = getAllConversations()
if conversations.count > maxConversations {
let toDelete = conversations.dropFirst(maxConversations)
for info in toDelete {
try? deleteConversation(id: info.id)
}
}
}
}
// MARK: - Conversation Observer
/// Observes conversation storage changes.
actor ConversationObserver {
private var continuations: [UUID: AsyncStream<[ConversationInfo]>.Continuation] = [:]
private let storage: ConversationStorage
private var lastKnownConversations: [ConversationInfo] = []
private var timer: Timer?
init(storage: ConversationStorage) {
self.storage = storage
}
/// Creates a stream that emits when conversations change.
func observe() -> AsyncStream<[ConversationInfo]> {
AsyncStream { continuation in
let id = UUID()
continuations[id] = continuation
// Initial emit
Task {
let conversations = await storage.getAllConversations()
continuation.yield(conversations)
}
continuation.onTermination = { [weak self] _ in
Task {
await self?.removeContinuation(id: id)
}
}
}
}
private func removeContinuation(id: UUID) {
continuations.removeValue(forKey: id)
}
/// Manually trigger a check for changes.
func checkForChanges() async {
let current = await storage.getAllConversations()
if current != lastKnownConversations {
lastKnownConversations = current
for continuation in continuations.values {
continuation.yield(current)
}
}
}
}
// MARK: - Preview Helpers
extension ConversationStorage {
/// Creates a storage instance with sample data for previews.
static func preview() -> ConversationStorage {
let storage = ConversationStorage()
// Add sample conversations in background
Task {
let sampleMessages = [
ConversationMessage(text: "Hello, how can you help me?", isUser: true),
ConversationMessage(text: "I'm an AI assistant. I can help with various tasks like answering questions, searching the web, or having conversations.", isUser: false)
]
try? await storage.saveConversation(
id: "preview-1",
messages: sampleMessages
)
}
return storage
}
}
@@ -0,0 +1,435 @@
import Foundation
// MARK: - Errors
enum ModelDownloadError: LocalizedError {
case invalidURL
case networkError(Error)
case serverError(Int)
case insufficientStorage
case fileError(Error)
case invalidResponse
case checksumMismatch
case cancelled
var errorDescription: String? {
switch self {
case .invalidURL:
return "Invalid download URL"
case .networkError(let error):
return "Network error: \(error.localizedDescription)"
case .serverError(let code):
return "Server error: HTTP \(code)"
case .insufficientStorage:
return "Insufficient storage space"
case .fileError(let error):
return "File error: \(error.localizedDescription)"
case .invalidResponse:
return "Invalid server response"
case .checksumMismatch:
return "Downloaded file checksum mismatch"
case .cancelled:
return "Download cancelled"
}
}
}
// MARK: - Progress & State
enum DownloadState: Equatable {
case idle
case checking
case downloading(progress: Double, bytesDownloaded: Int64, totalBytes: Int64)
case paused(bytesDownloaded: Int64, totalBytes: Int64)
case completed(url: URL)
case error(ModelDownloadError)
static func == (lhs: DownloadState, rhs: DownloadState) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle), (.checking, .checking):
return true
case let (.downloading(p1, b1, t1), .downloading(p2, b2, t2)):
return abs(p1 - p2) < 0.001 && b1 == b2 && t1 == t2
case let (.paused(b1, t1), .paused(b2, t2)):
return b1 == b2 && t1 == t2
case let (.completed(u1), .completed(u2)):
return u1 == u2
case let (.error(e1), .error(e2)):
return e1.localizedDescription == e2.localizedDescription
default:
return false
}
}
}
// MARK: - Model Info
struct ModelInfo {
let url: URL
let filename: String
let expectedSize: Int64?
let variant: ModelVariant
enum ModelVariant: String, CaseIterable {
case e2b = "gemma-4-E2B-it"
case e4b = "gemma-4-E4B-it"
var filename: String {
"\(rawValue).litertlm"
}
var huggingFaceURL: URL {
URL(string: "https://huggingface.co/litert-community/\(rawValue)-litert-lm/resolve/main/\(filename)")!
}
var expectedSize: Int64 {
switch self {
case .e2b:
return 2_717_263_232 // ~2.53 GB
case .e4b:
return 4_831_838_208 // ~4.5 GB
}
}
}
static func e2b() -> ModelInfo {
ModelInfo(
url: ModelVariant.e2b.huggingFaceURL,
filename: ModelVariant.e2b.filename,
expectedSize: ModelVariant.e2b.expectedSize,
variant: .e2b
)
}
static func e4b() -> ModelInfo {
ModelInfo(
url: ModelVariant.e4b.huggingFaceURL,
filename: ModelVariant.e4b.filename,
expectedSize: ModelVariant.e4b.expectedSize,
variant: .e4b
)
}
}
// MARK: - Download Task
actor ModelDownloadService {
// MARK: - Properties
private let fileManager: FileManager
private let urlSession: URLSession
private var currentTask: Task<Void, Never>?
private var downloadContinuation: AsyncStream<DownloadState>.Continuation?
private var currentState: DownloadState = .idle
private var tempFileURL: URL?
private var destinationURL: URL?
/// Current download state
var state: DownloadState { currentState }
/// Path to the models directory
let modelsDirectory: URL
// MARK: - Constants
private let chunkSize = 8192 // 8KB chunks
private let progressUpdateInterval: TimeInterval = 0.5
// MARK: - Initialization
init(
fileManager: FileManager = .default,
urlSession: URLSession = .shared
) {
self.fileManager = fileManager
self.urlSession = urlSession
// Setup models directory in documents
let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
self.modelsDirectory = documentsDirectory.appendingPathComponent("models", isDirectory: true)
// Create models directory if needed
try? fileManager.createDirectory(at: modelsDirectory, withIntermediateDirectories: true)
}
// MARK: - Public Methods
/// Checks if a model variant is downloaded.
func isDownloaded(variant: ModelInfo.ModelVariant) -> Bool {
let fileURL = modelsDirectory.appendingPathComponent(variant.filename)
guard fileManager.fileExists(atPath: fileURL.path) else { return false }
let minSize: Int64 = 100_000_000 // At least 100MB
do {
let attributes = try fileManager.attributesOfItem(atPath: fileURL.path)
let fileSize = attributes[.size] as? Int64 ?? 0
return fileSize > minSize
} catch {
return false
}
}
/// Gets the URL for a model file.
func modelFileURL(variant: ModelInfo.ModelVariant) -> URL {
modelsDirectory.appendingPathComponent(variant.filename)
}
/// Gets download progress for a variant.
func downloadProgress(variant: ModelInfo.ModelVariant) -> Double {
let fileURL = modelsDirectory.appendingPathComponent(variant.filename)
guard fileManager.fileExists(atPath: fileURL.path) else { return 0 }
do {
let attributes = try fileManager.attributesOfItem(atPath: fileURL.path)
let fileSize = attributes[.size] as? Int64 ?? 0
return min(Double(fileSize) / Double(variant.expectedSize), 1.0)
} catch {
return 0
}
}
/// Downloads a model with progress reporting.
/// - Parameter modelInfo: The model to download
/// - Returns: AsyncStream of download state updates
func download(modelInfo: ModelInfo) -> AsyncStream<DownloadState> {
// Cancel any existing download
cancelDownload()
return AsyncStream { continuation in
self.downloadContinuation = continuation
currentTask = Task {
await performDownload(modelInfo: modelInfo, continuation: continuation)
}
continuation.onTermination = { [weak self] _ in
Task {
await self?.cancelDownload()
}
}
}
}
/// Cancels the current download.
func cancelDownload() {
currentTask?.cancel()
currentTask = nil
// Clean up temp file
if let tempURL = tempFileURL {
try? fileManager.removeItem(at: tempURL)
tempFileURL = nil
}
updateState(.idle)
}
/// Deletes a downloaded model.
func deleteModel(variant: ModelInfo.ModelVariant) throws {
let fileURL = modelsDirectory.appendingPathComponent(variant.filename)
if fileManager.fileExists(atPath: fileURL.path) {
try fileManager.removeItem(at: fileURL)
}
// Also delete temp file if exists
let tempURL = modelsDirectory.appendingPathComponent("\(variant.filename).tmp")
if fileManager.fileExists(atPath: tempURL.path) {
try fileManager.removeItem(at: tempURL)
}
}
/// Gets available storage space in bytes.
func availableStorage() -> Int64? {
do {
let attributes = try fileManager.attributesOfFileSystem(forPath: modelsDirectory.path)
return attributes[.systemFreeSize] as? Int64
} catch {
return nil
}
}
/// Formats bytes to human-readable string.
static func formatBytes(_ bytes: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: bytes)
}
// MARK: - Private Methods
private func performDownload(
modelInfo: ModelInfo,
continuation: AsyncStream<DownloadState>.Continuation
) async {
updateState(.checking)
destinationURL = modelsDirectory.appendingPathComponent(modelInfo.filename)
let tempURL = modelsDirectory.appendingPathComponent("\(modelInfo.filename).tmp")
tempFileURL = tempURL
// Check available storage
if let available = availableStorage(), let expected = modelInfo.expectedSize {
if available < expected {
updateState(.error(.insufficientStorage))
continuation.finish()
return
}
}
// Check for partial download to resume
let resumeFrom: Int64
if fileManager.fileExists(atPath: tempURL.path) {
do {
let attributes = try fileManager.attributesOfItem(atPath: tempURL.path)
resumeFrom = attributes[.size] as? Int64 ?? 0
} catch {
resumeFrom = 0
}
} else {
resumeFrom = 0
}
do {
try await downloadFile(
from: modelInfo.url,
to: tempURL,
resumeFrom: resumeFrom,
expectedSize: modelInfo.expectedSize,
continuation: continuation
)
// Move temp file to final location
if fileManager.fileExists(atPath: destinationURL!.path) {
try fileManager.removeItem(at: destinationURL!)
}
try fileManager.moveItem(at: tempURL, to: destinationURL!)
tempFileURL = nil
updateState(.completed(url: destinationURL!))
} catch is CancellationError {
updateState(.idle)
} catch let error as ModelDownloadError {
updateState(.error(error))
} catch {
updateState(.error(.networkError(error)))
}
continuation.finish()
}
private func downloadFile(
from url: URL,
to destination: URL,
resumeFrom: Int64,
expectedSize: Int64?,
continuation: AsyncStream<DownloadState>.Continuation
) async throws {
var request = URLRequest(url: url)
request.timeoutInterval = 30
// Set up resume with Range header
if resumeFrom > 0 {
request.setValue("bytes=\(resumeFrom)-", forHTTPHeaderField: "Range")
}
request.setValue("SleepyAgent-iOS/1.0", forHTTPHeaderField: "User-Agent")
let (asyncBytes, response) = try await urlSession.bytes(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw ModelDownloadError.invalidResponse
}
let validCodes = resumeFrom > 0 ? [200, 206] : [200]
guard validCodes.contains(httpResponse.statusCode) else {
throw ModelDownloadError.serverError(httpResponse.statusCode)
}
// Determine total size
let totalBytes: Int64
if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"),
let length = Int64(contentLength) {
if httpResponse.statusCode == 206 && resumeFrom > 0 {
totalBytes = resumeFrom + length
} else {
totalBytes = length
}
} else if let expected = expectedSize {
totalBytes = expected
} else {
totalBytes = 0
}
// Open file for writing/appending
let fileHandle: FileHandle
if resumeFrom > 0 && fileManager.fileExists(atPath: destination.path) {
fileHandle = try FileHandle(forWritingTo: destination)
try fileHandle.seekToEnd()
} else {
if fileManager.fileExists(atPath: destination.path) {
try fileManager.removeItem(at: destination)
}
fileManager.createFile(atPath: destination.path, contents: nil)
fileHandle = try FileHandle(forWritingTo: destination)
}
defer {
try? fileHandle.close()
}
var bytesDownloaded = resumeFrom
var lastProgressUpdate = Date()
for try await byte in asyncBytes {
try Task.checkCancellation()
fileHandle.write(Data([byte]))
bytesDownloaded += 1
// Update progress periodically
let now = Date()
if now.timeIntervalSince(lastProgressUpdate) > progressUpdateInterval {
let progress = totalBytes > 0 ? Double(bytesDownloaded) / Double(totalBytes) : 0
updateState(.downloading(
progress: progress,
bytesDownloaded: bytesDownloaded,
totalBytes: totalBytes
))
lastProgressUpdate = now
}
}
// Final progress update
let finalProgress = totalBytes > 0 ? Double(bytesDownloaded) / Double(totalBytes) : 1.0
updateState(.downloading(
progress: finalProgress,
bytesDownloaded: bytesDownloaded,
totalBytes: totalBytes
))
// Verify download completed
if let expected = expectedSize, bytesDownloaded < expected - 1000 {
throw ModelDownloadError.networkError(NSError(domain: "Download incomplete", code: -1))
}
}
private func updateState(_ state: DownloadState) {
currentState = state
downloadContinuation?.yield(state)
}
}
// MARK: - Background Download Support
extension ModelDownloadService {
/// Creates a background URLSession configuration for downloads.
static func backgroundConfiguration(identifier: String) -> URLSessionConfiguration {
let config = URLSessionConfiguration.background(withIdentifier: identifier)
config.isDiscretionary = false
config.sessionSendsLaunchEvents = true
config.allowsCellularAccess = true
return config
}
}
+222
View File
@@ -0,0 +1,222 @@
import Foundation
import AVFoundation
// MARK: - Errors
enum TtsServiceError: LocalizedError {
case notAvailable
case synthesisFailed(String)
var errorDescription: String? {
switch self {
case .notAvailable:
return "Text-to-speech is not available on this device"
case .synthesisFailed(let reason):
return "Speech synthesis failed: \(reason)"
}
}
}
// MARK: - State
enum TtsState: Equatable {
case initializing
case ready
case speaking
case error(String)
}
// MARK: - TTS Service
/// Text-to-Speech service using AVSpeechSynthesizer.
actor TtsService: NSObject {
// MARK: - Properties
private var synthesizer: AVSpeechSynthesizer?
private var currentState: TtsState = .initializing
private var completionContinuation: CheckedContinuation<Void, Never>?
/// Current TTS state
var state: TtsState { currentState }
/// Whether the synthesizer is currently speaking
var isSpeaking: Bool {
synthesizer?.isSpeaking ?? false
}
/// Whether TTS is available on this device
var isAvailable: Bool {
AVSpeechSynthesisVoice.speechVoices().count > 0
}
// MARK: - Initialization
override init() {
super.init()
}
deinit {
synthesizer?.stopSpeaking(at: .immediate)
}
// MARK: - Setup
/// Initializes the TTS engine.
/// - Returns: Async stream of state changes
func initialize() async -> AsyncStream<TtsState> {
AsyncStream { continuation in
Task {
await setupSynthesizer()
continuation.yield(currentState)
continuation.finish()
}
}
}
private func setupSynthesizer() {
guard isAvailable else {
currentState = .error("No voices available")
return
}
synthesizer = AVSpeechSynthesizer()
synthesizer?.delegate = self
currentState = .ready
}
// MARK: - Speech Methods
/// Speaks the given text.
/// - Parameters:
/// - text: The text to speak
/// - language: Optional language code (e.g., "en-US"). Defaults to system language.
/// - Throws: TtsServiceError if synthesis fails
func speak(text: String, language: String? = nil) async throws {
guard let synthesizer = synthesizer else {
throw TtsServiceError.notAvailable
}
// Stop any current speech
synthesizer.stopSpeaking(at: .immediate)
let utterance = AVSpeechUtterance(string: text)
// Configure voice
if let language = language {
utterance.voice = AVSpeechSynthesisVoice(language: language)
} else {
// Try system language, fallback to US English
let systemLanguage = Locale.current.language.languageCode?.identifier ?? "en"
let region = Locale.current.region?.identifier ?? "US"
let locale = "\(systemLanguage)-\(region)"
if let voice = AVSpeechSynthesisVoice(language: locale) {
utterance.voice = voice
} else if let voice = AVSpeechSynthesisVoice(language: "en-US") {
utterance.voice = voice
}
}
// Configure speech parameters
utterance.rate = AVSpeechUtteranceDefaultSpeechRate
utterance.pitchMultiplier = 1.0
utterance.volume = 1.0
// Speak and wait for completion
await withCheckedContinuation { continuation in
self.completionContinuation = continuation
synthesizer.speak(utterance)
}
}
/// Speaks the given text with completion callback.
/// - Parameters:
/// - text: The text to speak
/// - language: Optional language code
/// - onComplete: Called when speech completes or fails
func speak(text: String, language: String? = nil, onComplete: (() -> Void)? = nil) {
Task {
do {
try await speak(text: text, language: language)
} catch {
print("TTS error: \(error)")
}
onComplete?()
}
}
/// Stops the current speech immediately.
func stop() {
synthesizer?.stopSpeaking(at: .immediate)
completionContinuation?.resume()
completionContinuation = nil
currentState = .ready
}
/// Stops the current speech at the end of the word.
func stopAtEndOfWord() {
synthesizer?.stopSpeaking(at: .word)
}
/// Shuts down the TTS engine and releases resources.
func shutdown() {
synthesizer?.stopSpeaking(at: .immediate)
synthesizer?.delegate = nil
synthesizer = nil
completionContinuation = nil
currentState = .initializing
}
/// Sets the speech rate (0.0 to 1.0, default is 0.5).
func setRate(_ rate: Float) {
// Applied per utterance
}
/// Sets the speech volume (0.0 to 1.0).
func setVolume(_ volume: Float) {
// Applied per utterance
}
}
// MARK: - AVSpeechSynthesizerDelegate
extension TtsService: AVSpeechSynthesizerDelegate {
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
Task {
await updateState(.speaking)
}
}
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
Task {
await completeSpeech()
}
}
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
Task {
await completeSpeech()
}
}
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) {
// Handle pause if needed
}
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) {
Task {
await updateState(.speaking)
}
}
private func updateState(_ state: TtsState) {
currentState = state
}
private func completeSpeech() {
completionContinuation?.resume()
completionContinuation = nil
currentState = .ready
}
}
+234
View File
@@ -0,0 +1,234 @@
import Foundation
// MARK: - Errors
enum WebSearchError: LocalizedError {
case invalidURL
case networkError(Error)
case invalidResponse
case decodingError(Error)
case serverError(Int)
case noResults
var errorDescription: String? {
switch self {
case .invalidURL:
return "Invalid search URL"
case .networkError(let error):
return "Network error: \(error.localizedDescription)"
case .invalidResponse:
return "Invalid response from server"
case .decodingError(let error):
return "Failed to parse response: \(error.localizedDescription)"
case .serverError(let code):
return "Server error: HTTP \(code)"
case .noResults:
return "No search results found"
}
}
}
// MARK: - Data Models
struct SearxngResponse: Codable {
let query: String
let results: [SearchResult]
enum CodingKeys: String, CodingKey {
case query
case results
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.query = try container.decodeIfPresent(String.self, forKey: .query) ?? ""
self.results = try container.decodeIfPresent([SearchResult].self, forKey: .results) ?? []
}
}
struct SearchResult: Codable {
let title: String
let url: String
let content: String
let engine: String?
enum CodingKeys: String, CodingKey {
case title
case url
case content
case engine
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.title = try container.decodeIfPresent(String.self, forKey: .title) ?? ""
self.url = try container.decodeIfPresent(String.self, forKey: .url) ?? ""
self.content = try container.decodeIfPresent(String.self, forKey: .content) ?? ""
self.engine = try container.decodeIfPresent(String.self, forKey: .engine)
}
}
// MARK: - Configuration
struct WebSearchConfiguration {
var baseURL: String
var safeSearch: Int
var timeout: TimeInterval
var maxResults: Int
static let `default` = WebSearchConfiguration(
baseURL: "https://search.sleepy.io",
safeSearch: 0,
timeout: 30.0,
maxResults: 5
)
}
// MARK: - Web Search Service
/// SearXNG client for web search functionality.
actor WebSearchService {
// MARK: - Properties
private var configuration: WebSearchConfiguration
private let urlSession: URLSession
private let decoder: JSONDecoder
/// Current base URL for the SearXNG instance
var baseURL: String {
get { configuration.baseURL }
set { configuration.baseURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "/")) }
}
// MARK: - Initialization
init(
configuration: WebSearchConfiguration = .default,
urlSession: URLSession = .shared
) {
self.configuration = configuration
self.urlSession = urlSession
self.decoder = JSONDecoder()
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
}
/// Updates the base URL for the search endpoint.
func updateBaseURL(_ url: String) {
baseURL = url
}
// MARK: - Search Methods
/// Performs a web search query.
/// - Parameters:
/// - query: The search query string
/// - maxResults: Maximum number of results to return (defaults to config)
/// - Returns: Formatted search results as text for LLM consumption
/// - Throws: WebSearchError if the search fails
func search(query: String, maxResults: Int? = nil) async throws -> String {
let results = try await performSearch(query: query)
return formatResults(results, query: query, maxResults: maxResults ?? configuration.maxResults)
}
/// Performs a web search and returns raw results.
/// - Parameter query: The search query string
/// - Returns: Array of search results
/// - Throws: WebSearchError if the search fails
func searchRaw(query: String) async throws -> [SearchResult] {
try await performSearch(query: query)
}
// MARK: - Private Methods
private func performSearch(query: String) async throws -> [SearchResult] {
guard var urlComponents = URLComponents(string: "\(configuration.baseURL)/search") else {
throw WebSearchError.invalidURL
}
urlComponents.queryItems = [
URLQueryItem(name: "q", value: query),
URLQueryItem(name: "format", value: "json"),
URLQueryItem(name: "safesearch", value: String(configuration.safeSearch))
]
guard let url = urlComponents.url else {
throw WebSearchError.invalidURL
}
var request = URLRequest(url: url)
request.timeoutInterval = configuration.timeout
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("SleepyAgent-iOS/1.0", forHTTPHeaderField: "User-Agent")
let data: Data
let response: URLResponse
do {
(data, response) = try await urlSession.data(for: request)
} catch {
throw WebSearchError.networkError(error)
}
guard let httpResponse = response as? HTTPURLResponse else {
throw WebSearchError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw WebSearchError.serverError(httpResponse.statusCode)
}
let searchResponse: SearxngResponse
do {
searchResponse = try decoder.decode(SearxngResponse.self, from: data)
} catch {
throw WebSearchError.decodingError(error)
}
return searchResponse.results
}
private func formatResults(_ results: [SearchResult], query: String, maxResults: Int) -> String {
guard !results.isEmpty else {
return "No results found for '\(query)'"
}
return results.prefix(maxResults).map { result in
var text = "Title: \(result.title)\n"
text += "URL: \(result.url)\n"
text += "Content: \(result.content)"
return text
}.joined(separator: "\n\n")
}
// MARK: - Tool Protocol Support
/// Executes search as a tool call with arguments.
/// - Parameter arguments: Dictionary containing "query" key
/// - Returns: Formatted search results
func execute(arguments: [String: String]) async -> String {
guard let query = arguments["query"] else {
return "Error: 'query' parameter is required"
}
do {
return try await search(query: query)
} catch {
return "Error performing web search: \(error.localizedDescription)"
}
}
}
// MARK: - Convenience Extensions
extension WebSearchService {
/// Quick search with default settings.
static func quickSearch(query: String, baseURL: String? = nil) async throws -> String {
var config = WebSearchConfiguration.default
if let baseURL = baseURL {
config.baseURL = baseURL
}
let service = WebSearchService(configuration: config)
return try await service.search(query: query)
}
}
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,166 @@
import Foundation
import SwiftUI
import Combine
@MainActor
class MainViewModel: ObservableObject {
@Published var messages: [Message] = []
@Published var inputText: String = ""
@Published var isGenerating: Bool = false
@Published var isRecording: Bool = false
@Published var currentResponse: String = ""
@Published var errorMessage: String?
@Published var showError: Bool = false
@Published var showImagePicker: Bool = false
@Published var selectedImage: UIImage?
@Published var conversations: [ConversationInfo] = []
@Published var currentConversationId: UUID = UUID()
private let agent: Agent
private let audioRecorder: AudioRecorder
private let ttsService: TtsService
private let conversationStorage: ConversationStorage
private let llmEngine: LlmEngine
private var firstInputWasVoice: Bool?
init(agent: Agent, audioRecorder: AudioRecorder, ttsService: TtsService, conversationStorage: ConversationStorage, llmEngine: LlmEngine) {
self.agent = agent
self.audioRecorder = audioRecorder
self.ttsService = ttsService
self.conversationStorage = conversationStorage
self.llmEngine = llmEngine
loadConversations()
loadCurrentConversation()
}
func sendMessage() async {
guard !inputText.isEmpty else { return }
let text = inputText
inputText = ""
await processTextMessage(text)
}
func sendImage(_ image: UIImage, text: String = "") async {
selectedImage = image
let displayText = text.isEmpty ? "[Image]" : text
messages.append(Message.user("🖼️ \(displayText)"))
saveConversation()
firstInputWasVoice = false
await generateResponse(withImage: image, text: text)
}
func startRecording() {
guard !isRecording else { return }
isRecording = true
if firstInputWasVoice == nil {
firstInputWasVoice = true
}
audioRecorder.startRecording()
}
func stopRecording() async {
guard isRecording else { return }
isRecording = false
do {
let audioData = try await audioRecorder.stopRecording()
messages.append(Message.user("🎤 [Voice message]"))
saveConversation()
await generateResponse(audioData: audioData)
} catch {
showError(error.localizedDescription)
}
}
func toggleRecording() async {
if isRecording {
await stopRecording()
} else {
startRecording()
}
}
func newConversation() {
saveConversation()
currentConversationId = UUID()
messages = []
agent.resetConversation()
}
func loadConversation(id: UUID) {
saveConversation()
currentConversationId = id
messages = conversationStorage.loadConversation(id: id) ?? []
}
func deleteConversation(id: UUID) {
conversationStorage.deleteConversation(id: id)
loadConversations()
if currentConversationId == id {
newConversation()
}
}
private func processTextMessage(_ text: String) async {
if firstInputWasVoice == nil {
firstInputWasVoice = false
}
messages.append(Message.user(text))
saveConversation()
await generateResponse()
}
private func generateResponse(withImage: UIImage? = nil, text: String? = nil, audioData: Data? = nil) async {
guard !isGenerating else { return }
if !llmEngine.isLoaded {
currentResponse = "Loading model..."
}
isGenerating = true
currentResponse = ""
defer { isGenerating = false }
do {
let stream = agent.processStream(input: text ?? "", image: withImage, audioData: audioData)
var fullResponse = ""
for try await token in stream {
currentResponse += token
fullResponse += token
}
messages.append(Message.assistant(fullResponse))
saveConversation()
currentResponse = ""
if firstInputWasVoice == true {
ttsService.speak(fullResponse)
}
} catch {
showError(error.localizedDescription)
}
}
private func saveConversation() {
conversationStorage.saveConversation(id: currentConversationId, messages: messages)
loadConversations()
}
private func loadCurrentConversation() {
messages = conversationStorage.loadConversation(id: currentConversationId) ?? []
}
private func loadConversations() {
conversations = conversationStorage.listConversations()
}
private func showError(_ message: String) {
errorMessage = message
showError = true
}
}
struct ConversationInfo: Identifiable {
let id: UUID
let title: String
let messageCount: Int
let lastUpdated: Date
}
@@ -0,0 +1,152 @@
import Foundation
import SwiftUI
@MainActor
class SettingsViewModel: ObservableObject {
@Published var selectedModel: ModelVariant = .e2b
@Published var modelPath: String = ""
@Published var isModelLoaded: Bool = false
@Published var isModelLoading: Bool = false
@Published var searchServerUrl: String = ""
@Published var delegateServerUrl: String = ""
@Published var ttsEnabled: Bool = true
@Published var ttsAutoMode: Bool = true
@Published var downloadProgress: Double = 0
@Published var isDownloading: Bool = false
@Published var downloadStatus: String = ""
@Published var deviceInfo: DeviceInfo = DeviceInfo()
@Published var errorMessage: String?
@Published var showError: Bool = false
private let modelDownloadService: ModelDownloadService
private let llmEngine: LlmEngine
private let userDefaults = UserDefaults.standard
init(modelDownloadService: ModelDownloadService, llmEngine: LlmEngine) {
self.modelDownloadService = modelDownloadService
self.llmEngine = llmEngine
loadSettings()
updateDeviceInfo()
}
func loadModel() async {
guard !modelPath.isEmpty else {
showError("No model selected")
return
}
isModelLoading = true
defer { isModelLoading = false }
do {
try await llmEngine.loadModel(path: modelPath)
isModelLoaded = true
saveSettings()
} catch {
showError("Failed to load model: \(error.localizedDescription)")
isModelLoaded = false
}
}
func downloadModel(variant: ModelVariant) async {
guard !isDownloading else { return }
isDownloading = true
downloadProgress = 0
let (url, filename): (String, String)
switch variant {
case .e2b:
url = "https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm"
filename = "gemma-4-E2B-it.litertlm"
case .e4b:
url = "https://huggingface.co/litert-community/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it.litertlm"
filename = "gemma-4-E4B-it.litertlm"
case .custom:
showError("Cannot download custom model")
isDownloading = false
return
}
do {
let stream = modelDownloadService.downloadModel(from: url, filename: filename)
for try await progress in stream {
downloadProgress = progress
downloadStatus = String(format: "%.1f%%", progress * 100)
}
selectedModel = variant
modelPath = modelDownloadService.localPath(for: filename)
await loadModel()
} catch {
showError("Download failed: \(error.localizedDescription)")
}
isDownloading = false
}
func deleteModel(variant: ModelVariant) {
let filename: String
switch variant {
case .e2b: filename = "gemma-4-E2B-it.litertlm"
case .e4b: filename = "gemma-4-E4B-it.litertlm"
case .custom: return
}
modelDownloadService.deleteModel(filename: filename)
if selectedModel == variant {
selectedModel = .e2b
modelPath = ""
isModelLoaded = false
}
}
func isModelDownloaded(variant: ModelVariant) -> Bool {
let filename: String
switch variant {
case .e2b: filename = "gemma-4-E2B-it.litertlm"
case .e4b: filename = "gemma-4-E4B-it.litertlm"
case .custom: return !modelPath.isEmpty
}
return modelDownloadService.isDownloaded(filename: filename)
}
func saveSettings() {
userDefaults.set(selectedModel.rawValue, forKey: "selectedModel")
userDefaults.set(modelPath, forKey: "modelPath")
userDefaults.set(searchServerUrl, forKey: "searchServerUrl")
userDefaults.set(delegateServerUrl, forKey: "delegateServerUrl")
userDefaults.set(ttsEnabled, forKey: "ttsEnabled")
userDefaults.set(ttsAutoMode, forKey: "ttsAutoMode")
}
func updateDeviceInfo() {
deviceInfo = DeviceInfo.current()
}
private func loadSettings() {
if let modelRaw = userDefaults.string(forKey: "selectedModel"),
let model = ModelVariant(rawValue: modelRaw) {
selectedModel = model
}
modelPath = userDefaults.string(forKey: "modelPath") ?? ""
searchServerUrl = userDefaults.string(forKey: "searchServerUrl") ?? ""
delegateServerUrl = userDefaults.string(forKey: "delegateServerUrl") ?? ""
ttsEnabled = userDefaults.object(forKey: "ttsEnabled") as? Bool ?? true
ttsAutoMode = userDefaults.object(forKey: "ttsAutoMode") as? Bool ?? true
isModelLoaded = llmEngine.isLoaded
}
private func showError(_ message: String) {
errorMessage = message
showError = true
}
}
struct DeviceInfo {
let totalRAM: String
let freeRAM: String
let deviceModel: String
let systemVersion: String
static func current() -> DeviceInfo {
let device = UIDevice.current
let physicalMemory = ProcessInfo.processInfo.physicalMemory
let totalRAM = String(format: "%.0f GB", Double(physicalMemory) / 1024 / 1024 / 1024)
return DeviceInfo(totalRAM: totalRAM, freeRAM: "Unknown", deviceModel: device.model, systemVersion: "\(device.systemName) \(device.systemVersion)")
}
}
+273
View File
@@ -0,0 +1,273 @@
import SwiftUI
struct InputBar: View {
@Binding var text: String
let onSend: (String) -> Void
let onVoiceTap: () -> Void
let onImageTap: () -> Void
let isRecording: Bool
let isProcessing: Bool
let isExecutingTool: Bool
@FocusState private var isFocused: Bool
@State private var showVoiceOverlay = false
var body: some View {
HStack(spacing: 12) {
// Text input field
textField
// Action buttons
actionButtons
}
.padding(.horizontal, 4)
.padding(.vertical, 8)
.background(Color(.systemBackground))
.overlay(
// Voice recording overlay
voiceOverlay
)
}
private var textField: some View {
HStack(spacing: 8) {
TextField(placeholderText, text: $text, axis: .vertical)
.focused($isFocused)
.lineLimit(1...4)
.disabled(isProcessing || isExecutingTool)
.submitLabel(.send)
.onSubmit {
sendMessage()
}
if !text.isEmpty {
Button(action: { text = "" }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color(.systemGray6))
.cornerRadius(20)
}
private var actionButtons: some View {
HStack(spacing: 8) {
// Image picker button
Button(action: onImageTap) {
Image(systemName: "photo")
.font(.system(size: 22))
.frame(width: 40, height: 40)
}
.disabled(isProcessing || isExecutingTool)
.opacity(isProcessing || isExecutingTool ? 0.5 : 1)
// Voice/Record button
Button(action: handleVoiceTap) {
ZStack {
Circle()
.fill(buttonBackgroundColor)
.frame(width: 44, height: 44)
if isProcessing || isExecutingTool {
ProgressView()
.scaleEffect(0.8)
.tint(.white)
} else if isRecording {
Image(systemName: "stop.fill")
.font(.system(size: 20))
.foregroundColor(.white)
} else {
Image(systemName: "mic.fill")
.font(.system(size: 20))
.foregroundColor(.white)
}
}
}
.disabled(isProcessing || isExecutingTool)
// Send button
Button(action: sendMessage) {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 32))
.foregroundColor(canSend ? .accentColor : .secondary.opacity(0.5))
}
.disabled(!canSend)
}
}
@ViewBuilder
private var voiceOverlay: some View {
if isRecording {
VoiceRecordingOverlay()
.transition(.opacity)
}
}
// MARK: - Computed Properties
private var placeholderText: String {
if isExecutingTool {
return "🔧 Executing tool..."
} else if isProcessing {
return "Thinking..."
} else if isRecording {
return "Recording..."
} else {
return "Type a message..."
}
}
private var buttonBackgroundColor: Color {
if isRecording {
return .red
} else if isProcessing || isExecutingTool {
return .orange
} else {
return .accentColor
}
}
private var canSend: Bool {
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!isProcessing &&
!isExecutingTool
}
// MARK: - Actions
private func sendMessage() {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
onSend(trimmed)
text = ""
isFocused = false
}
private func handleVoiceTap() {
withAnimation(.spring()) {
onVoiceTap()
}
}
}
// MARK: - Voice Recording Overlay
struct VoiceRecordingOverlay: View {
@State private var pulseScale: CGFloat = 1.0
var body: some View {
ZStack {
Color.black.opacity(0.5)
.ignoresSafeArea()
VStack(spacing: 24) {
Spacer()
// Recording indicator
ZStack {
Circle()
.fill(Color.red.opacity(0.3))
.frame(width: 120, height: 120)
.scaleEffect(pulseScale)
Circle()
.fill(Color.red)
.frame(width: 80, height: 80)
Image(systemName: "mic.fill")
.font(.system(size: 40))
.foregroundColor(.white)
}
Text("Recording...")
.font(.title3)
.foregroundColor(.white)
Text("Tap stop button to finish")
.font(.body)
.foregroundColor(.white.opacity(0.7))
Spacer()
}
}
.onAppear {
withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) {
pulseScale = 1.3
}
}
}
}
// MARK: - Audio Waveform View (Visual feedback while recording)
struct AudioWaveformView: View {
@State private var bars: [CGFloat] = Array(repeating: 0.5, count: 20)
var body: some View {
HStack(spacing: 4) {
ForEach(0..<bars.count, id: \.self) { index in
RoundedRectangle(cornerRadius: 2)
.fill(Color.red)
.frame(width: 4, height: bars[index] * 40)
.animation(.easeInOut(duration: 0.1).delay(Double(index) * 0.02), value: bars[index])
}
}
.frame(height: 40)
.onAppear {
startAnimating()
}
}
private func startAnimating() {
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
for i in bars.indices {
bars[i] = CGFloat.random(in: 0.3...1.0)
}
}
}
}
// MARK: - Preview
struct InputBar_Previews: PreviewProvider {
static var previews: some View {
VStack {
InputBar(
text: .constant("Hello"),
onSend: { _ in },
onVoiceTap: {},
onImageTap: {},
isRecording: false,
isProcessing: false,
isExecutingTool: false
)
InputBar(
text: .constant(""),
onSend: { _ in },
onVoiceTap: {},
onImageTap: {},
isRecording: true,
isProcessing: false,
isExecutingTool: false
)
InputBar(
text: .constant(""),
onSend: { _ in },
onVoiceTap: {},
onImageTap: {},
isRecording: false,
isProcessing: true,
isExecutingTool: false
)
Spacer()
}
.padding()
}
}
+333
View File
@@ -0,0 +1,333 @@
import SwiftUI
struct MainView: View {
@StateObject private var viewModel = MainViewModel()
@State private var showingSettings = false
@State private var showingSidebar = false
@State private var inputText = ""
@State private var scrollProxy: ScrollViewProxy?
var body: some View {
NavigationView {
ZStack {
// Main content
VStack(spacing: 0) {
// Messages list
messagesList
// Loading indicator
if viewModel.isLoading {
ProgressView()
.padding(.vertical, 8)
}
// Input bar
InputBar(
text: $inputText,
onSend: { text in
viewModel.sendMessage(text: text)
inputText = ""
},
onVoiceTap: {
viewModel.toggleRecording()
},
onImageTap: {
viewModel.showImagePicker = true
},
isRecording: viewModel.isRecording,
isProcessing: viewModel.isProcessing,
isExecutingTool: viewModel.isExecutingTool
)
.padding(.horizontal)
.padding(.bottom, 8)
}
// Sidebar overlay
if showingSidebar {
sidebarOverlay
}
}
.navigationTitle("Sleepy Agent")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: { showingSidebar.toggle() }) {
Image(systemName: "line.3.horizontal")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { showingSettings = true }) {
Image(systemName: "gear")
}
}
}
}
.sheet(isPresented: $showingSettings) {
SettingsView()
}
.sheet(isPresented: $viewModel.showImagePicker) {
ImagePicker(selectedImage: $viewModel.selectedImage)
}
.alert("Error", isPresented: $viewModel.showError) {
Button("OK") { viewModel.dismissError() }
} message: {
Text(viewModel.errorMessage)
}
.onChange(of: viewModel.messages.count) { _ in
scrollToBottom()
}
.onChange(of: viewModel.streamingText) { _ in
scrollToBottom()
}
}
private var messagesList: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
if viewModel.messages.isEmpty {
welcomeView
} else {
ForEach(viewModel.messages) { message in
MessageBubble(message: message)
.id(message.id)
}
// Streaming response
if !viewModel.streamingText.isEmpty && viewModel.isResponding {
MessageBubble(
message: ChatMessage(
id: "streaming",
text: viewModel.streamingText + (viewModel.isSpeaking ? "" : ""),
isUser: false,
isStreaming: true
)
)
.id("streaming")
}
}
}
.padding()
}
.onAppear {
scrollProxy = proxy
}
}
}
private var welcomeView: some View {
VStack(spacing: 16) {
Spacer()
Text("👋 Welcome to Sleepy Agent")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.accentColor)
Text("Tap the microphone to start speaking\nor type a message below")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
if viewModel.uiState == .error {
Text(viewModel.errorMessage)
.font(.callout)
.foregroundColor(.red)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
Spacer()
}
.frame(maxWidth: .infinity, minHeight: 300)
}
private var sidebarOverlay: some View {
GeometryReader { geometry in
HStack(spacing: 0) {
// Sidebar content
ConversationSidebar(
conversations: viewModel.conversations,
currentId: viewModel.currentConversationId,
onSelect: { id in
viewModel.loadConversation(id: id)
showingSidebar = false
},
onNewChat: {
viewModel.startNewConversation()
showingSidebar = false
},
onDelete: { id in
viewModel.deleteConversation(id: id)
}
)
.frame(width: min(300, geometry.size.width * 0.75))
.background(Color(.systemBackground))
// Tap to dismiss
Color.black.opacity(0.3)
.onTapGesture {
showingSidebar = false
}
}
}
.transition(.move(edge: .leading))
.zIndex(1)
}
private func scrollToBottom() {
guard let proxy = scrollProxy else { return }
withAnimation {
if viewModel.isResponding && !viewModel.streamingText.isEmpty {
proxy.scrollTo("streaming", anchor: .bottom)
} else if let lastMessage = viewModel.messages.last {
proxy.scrollTo(lastMessage.id, anchor: .bottom)
}
}
}
}
// MARK: - Conversation Sidebar
struct ConversationSidebar: View {
let conversations: [ConversationInfo]
let currentId: String
let onSelect: (String) -> Void
let onNewChat: () -> Void
let onDelete: (String) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack {
Text("Chat History")
.font(.headline)
Spacer()
Button(action: onNewChat) {
Image(systemName: "plus")
}
}
.padding()
Divider()
// New Chat button
Button(action: onNewChat) {
Label("New Chat", systemImage: "plus.circle")
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.bordered)
.padding()
Divider()
// Conversations list
if conversations.isEmpty {
Spacer()
Text("No previous chats")
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
Spacer()
} else {
List {
ForEach(conversations) { conversation in
ConversationRow(
conversation: conversation,
isSelected: conversation.id == currentId
)
.contentShape(Rectangle())
.onTapGesture {
onSelect(conversation.id)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
onDelete(conversation.id)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.listStyle(.plain)
}
}
}
}
struct ConversationRow: View {
let conversation: ConversationInfo
let isSelected: Bool
private var dateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(conversation.title)
.font(.body)
.lineLimit(1)
.foregroundColor(isSelected ? .accentColor : .primary)
Text("\(dateFormatter.string(from: conversation.date))\(conversation.messageCount) messages")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
.cornerRadius(8)
}
}
// MARK: - Image Picker
struct ImagePicker: UIViewControllerRepresentable {
@Binding var selectedImage: UIImage?
@Environment(\.presentationMode) var presentationMode
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = .photoLibrary
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: ImagePicker
init(_ parent: ImagePicker) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let image = info[.originalImage] as? UIImage {
parent.selectedImage = image
}
parent.presentationMode.wrappedValue.dismiss()
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.presentationMode.wrappedValue.dismiss()
}
}
}
// MARK: - Preview
struct MainView_Previews: PreviewProvider {
static var previews: some View {
MainView()
}
}
+364
View File
@@ -0,0 +1,364 @@
import SwiftUI
struct MessageBubble: View {
let message: ChatMessage
@State private var isCopied = false
var body: some View {
HStack {
if message.isUser {
Spacer(minLength: 60)
}
VStack(alignment: message.isUser ? .trailing : .leading, spacing: 4) {
// Tool call indicator
if message.isToolCall {
Label("Tool Call", systemImage: "wrench.fill")
.font(.caption)
.foregroundColor(.orange)
.padding(.horizontal, 12)
.padding(.top, 8)
}
// Message content
messageContent
.padding(.horizontal, 12)
.padding(.vertical, message.isToolCall ? 4 : 10)
.background(backgroundColor)
.foregroundColor(foregroundColor)
.cornerRadius(16, corners: cornerRadii)
.contextMenu {
Button(action: copyText) {
Label(isCopied ? "Copied!" : "Copy", systemImage: isCopied ? "checkmark" : "doc.on.doc")
}
if !message.isUser {
Button(action: { /* Regenerate action */ }) {
Label("Regenerate", systemImage: "arrow.clockwise")
}
}
}
// Timestamp
if !message.isStreaming {
Text(formattedTime)
.font(.caption2)
.foregroundColor(.secondary)
.padding(.horizontal, 4)
}
}
if !message.isUser {
Spacer(minLength: 60)
}
}
}
@ViewBuilder
private var messageContent: some View {
if message.isUser {
// Plain text for user messages
Text(message.text)
.font(.body)
.textSelection(.enabled)
} else {
// Markdown for AI messages
MarkdownText(message.text)
.font(.body)
}
}
private var backgroundColor: Color {
if message.isToolCall {
return Color.orange.opacity(0.15)
}
return message.isUser ? Color.accentColor.opacity(0.9) : Color(.systemGray5)
}
private var foregroundColor: Color {
if message.isToolCall {
return .orange
}
return message.isUser ? .white : .primary
}
private var cornerRadii: UIRectCorner {
if message.isUser {
return [.topLeft, .topRight, .bottomLeft]
} else {
return [.topLeft, .topRight, .bottomRight]
}
}
private var formattedTime: String {
let formatter = DateFormatter()
formatter.timeStyle = .short
return formatter.string(from: message.timestamp)
}
private func copyText() {
UIPasteboard.general.string = message.text
isCopied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
isCopied = false
}
}
}
// MARK: - Markdown Text View
struct MarkdownText: UIViewRepresentable {
let markdown: String
var font: UIFont = .preferredFont(forTextStyle: .body)
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.isEditable = false
textView.isSelectable = true
textView.isScrollEnabled = false
textView.backgroundColor = .clear
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
return textView
}
func updateUIView(_ textView: UITextView, context: Context) {
// Use NSAttributedString with markdown parsing for iOS 15+
if let attributedString = parseMarkdown(markdown) {
textView.attributedText = attributedString
} else {
textView.text = markdown
}
}
private func parseMarkdown(_ text: String) -> NSAttributedString? {
// Basic markdown parsing using data detectors and attributes
let attributedString = NSMutableAttributedString(string: text)
// Apply base font
let range = NSRange(location: 0, length: attributedString.length)
attributedString.addAttribute(.font, value: font, range: range)
// Parse inline code `code`
parseInlineCode(in: attributedString)
// Parse bold **text**
parseBold(in: attributedString)
// Parse italic *text*
parseItalic(in: attributedString)
// Parse code blocks ```code```
parseCodeBlocks(in: attributedString)
// Parse headers
parseHeaders(in: attributedString)
// Parse links [text](url)
parseLinks(in: attributedString)
return attributedString
}
private func parseInlineCode(in string: NSMutableAttributedString) {
let pattern = "`([^`]+)`"
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
let matches = regex.matches(in: string.string, options: [], range: NSRange(location: 0, length: string.length))
for match in matches.reversed() {
let codeRange = match.range(at: 1)
let fullRange = match.range
string.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: font.pointSize * 0.9, weight: .regular), range: codeRange)
string.addAttribute(.backgroundColor, value: UIColor.systemGray5, range: codeRange)
string.addAttribute(.foregroundColor, value: UIColor.systemRed, range: codeRange)
// Remove backticks
string.replaceCharacters(in: NSRange(location: fullRange.location, length: 1), with: "")
string.replaceCharacters(in: NSRange(location: fullRange.location + codeRange.length, length: 1), with: "")
}
}
private func parseBold(in string: NSMutableAttributedString) {
let pattern = "\\*\\*([^*]+)\\*\\*"
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
let matches = regex.matches(in: string.string, options: [], range: NSRange(location: 0, length: string.length))
for match in matches.reversed() {
let boldRange = match.range(at: 1)
let fullRange = match.range
string.addAttribute(.font, value: UIFont.systemFont(ofSize: font.pointSize, weight: .bold), range: boldRange)
// Remove **
string.replaceCharacters(in: NSRange(location: fullRange.location, length: 2), with: "")
string.replaceCharacters(in: NSRange(location: fullRange.location + boldRange.length, length: 2), with: "")
}
}
private func parseItalic(in string: NSMutableAttributedString) {
let pattern = "(?<!\\*)\\*([^*]+)\\*(?!\\*)"
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
let matches = regex.matches(in: string.string, options: [], range: NSRange(location: 0, length: string.length))
for match in matches.reversed() {
let italicRange = match.range(at: 1)
let fullRange = match.range
string.addAttribute(.font, value: UIFont.italicSystemFont(ofSize: font.pointSize), range: italicRange)
// Remove *
string.replaceCharacters(in: NSRange(location: fullRange.location, length: 1), with: "")
string.replaceCharacters(in: NSRange(location: fullRange.location + italicRange.length, length: 1), with: "")
}
}
private func parseCodeBlocks(in string: NSMutableAttributedString) {
let pattern = "```(?:\\w+\\n)?([^`]+)```"
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]) else { return }
let matches = regex.matches(in: string.string, options: [], range: NSRange(location: 0, length: string.length))
for match in matches.reversed() {
let codeRange = match.range(at: 1)
let fullRange = match.range
// Style the code block
string.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: font.pointSize * 0.85, weight: .regular), range: codeRange)
string.addAttribute(.backgroundColor, value: UIColor.systemGray6, range: codeRange)
// Replace the entire block with just the code
let code = (string.string as NSString).substring(with: codeRange)
string.replaceCharacters(in: fullRange, with: "\n\(code)\n")
}
}
private func parseHeaders(in string: NSMutableAttributedString) {
let pattern = "^(#{1,6})\\s*(.+)$"
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else { return }
let matches = regex.matches(in: string.string, options: [], range: NSRange(location: 0, length: string.length))
for match in matches.reversed() {
let headerRange = match.range(at: 2)
let hashesRange = match.range(at: 1)
let level = (string.string as NSString).substring(with: hashesRange).count
let fontSize = font.pointSize + CGFloat(6 - level) * 2
string.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: fontSize), range: headerRange)
// Remove #
string.replaceCharacters(in: NSRange(location: match.range.location, length: hashesRange.length + 1), with: "")
}
}
private func parseLinks(in string: NSMutableAttributedString) {
let pattern = "\\[([^\\]]+)\\]\\(([^\\)]+)\\)"
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
let matches = regex.matches(in: string.string, options: [], range: NSRange(location: 0, length: string.length))
for match in matches.reversed() {
let textRange = match.range(at: 1)
let urlRange = match.range(at: 2)
let fullRange = match.range
let urlString = (string.string as NSString).substring(with: urlRange)
if let url = URL(string: urlString) {
string.addAttribute(.link, value: url, range: textRange)
}
string.addAttribute(.foregroundColor, value: UIColor.systemBlue, range: textRange)
string.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: textRange)
// Replace with just the text
let text = (string.string as NSString).substring(with: textRange)
string.replaceCharacters(in: fullRange, with: text)
}
}
}
// MARK: - Rounded Corner Modifier
extension View {
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
clipShape(RoundedCorner(radius: radius, corners: corners))
}
}
struct RoundedCorner: Shape {
var radius: CGFloat = .infinity
var corners: UIRectCorner = .allCorners
func path(in rect: CGRect) -> Path {
let path = UIBezierPath(
roundedRect: rect,
byRoundingCorners: corners,
cornerRadii: CGSize(width: radius, height: radius)
)
return Path(path.cgPath)
}
}
// MARK: - Chat Message Model
struct ChatMessage: Identifiable {
let id: String
var text: String
let isUser: Bool
let isToolCall: Bool
let timestamp: Date
let isStreaming: Bool
init(
id: String = UUID().uuidString,
text: String,
isUser: Bool,
isToolCall: Bool = false,
timestamp: Date = Date(),
isStreaming: Bool = false
) {
self.id = id
self.text = text
self.isUser = isUser
self.isToolCall = isToolCall
self.timestamp = timestamp
self.isStreaming = isStreaming
}
}
// MARK: - Preview
struct MessageBubble_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 16) {
MessageBubble(message: ChatMessage(
text: "Hello! How can I help you today?",
isUser: false
))
MessageBubble(message: ChatMessage(
text: "I have a question about Swift programming.",
isUser: true
))
MessageBubble(message: ChatMessage(
text: "Here's some `inline code` and **bold text**.",
isUser: false
))
MessageBubble(message: ChatMessage(
text: "Searching the web...",
isUser: false,
isToolCall: true
))
Spacer()
}
.padding()
.previewLayout(.sizeThatFits)
}
}
+345
View File
@@ -0,0 +1,345 @@
import SwiftUI
struct SettingsView: View {
@StateObject private var viewModel = SettingsViewModel()
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationView {
Form {
// Model Section
modelSection
// Server Section
serverSection
// TTS Section
ttsSection
// Device Info
deviceSection
// About
aboutSection
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
.onAppear {
viewModel.loadSettings()
}
}
}
// MARK: - Model Section
private var modelSection: some View {
Section("Models") {
// Current model status
HStack {
Text("Status")
Spacer()
modelStatusView
}
// Download progress
if let variant = viewModel.downloadingVariant {
VStack(alignment: .leading, spacing: 4) {
Text("Downloading \(variant.uppercased())")
.font(.caption)
ProgressView(value: viewModel.downloadProgress, total: 100)
Text("\(Int(viewModel.downloadProgress))%")
.font(.caption2)
.foregroundColor(.secondary)
}
}
// Gemma 4 E2B
ModelVariantRow(
name: "Gemma 4 E2B",
description: "2B params, fastest, good for most tasks (~2.7GB)",
isDownloaded: viewModel.isE2BDownloaded,
isSelected: viewModel.selectedVariant == "e2b",
isDownloading: viewModel.downloadingVariant == "e2b",
onSelect: { viewModel.selectVariant("e2b") },
onDownload: { viewModel.downloadModel("e2b") },
onDelete: { viewModel.deleteModel("e2b") }
)
// Gemma 4 E4B
ModelVariantRow(
name: "Gemma 4 E4B",
description: "4B params, better quality, slower (~4.5GB)",
isDownloaded: viewModel.isE4BDownloaded,
isSelected: viewModel.selectedVariant == "e4b",
isDownloading: viewModel.downloadingVariant == "e4b",
onSelect: { viewModel.selectVariant("e4b") },
onDownload: { viewModel.downloadModel("e4b") },
onDelete: { viewModel.deleteModel("e4b") }
)
// Custom model
Button(action: { viewModel.showDocumentPicker = true }) {
Label("Select from Files", systemImage: "folder")
}
.sheet(isPresented: $viewModel.showDocumentPicker) {
DocumentPicker(selectedURL: $viewModel.customModelURL)
}
// Load button
if !viewModel.modelPath.isEmpty && !viewModel.modelLoaded && !viewModel.isLoadingModel {
Button(action: { viewModel.loadModel() }) {
Label("Load Selected Model", systemImage: "play.circle")
}
}
}
}
@ViewBuilder
private var modelStatusView: some View {
if viewModel.isLoadingModel {
HStack(spacing: 8) {
ProgressView()
.scaleEffect(0.8)
Text("Loading...")
}
.foregroundColor(.secondary)
} else if viewModel.modelLoaded {
Label("Loaded", systemImage: "checkmark.circle.fill")
.foregroundColor(.green)
} else if let error = viewModel.modelLoadError {
Label("Error", systemImage: "xmark.circle.fill")
.foregroundColor(.red)
.help(error)
} else {
Text("Not loaded")
.foregroundColor(.secondary)
}
}
// MARK: - Server Section
private var serverSection: some View {
Section("Servers") {
HStack {
TextField("Search Server (SearXNG)", text: $viewModel.searchServerURL)
.textContentType(.URL)
.keyboardType(.URL)
.autocapitalization(.none)
if viewModel.isCheckingSearchHealth {
ProgressView()
.scaleEffect(0.8)
} else if let healthy = viewModel.searchServerHealthy {
Image(systemName: healthy ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundColor(healthy ? .green : .red)
}
}
HStack {
TextField("Delegate Server (LLM)", text: $viewModel.delegateServerURL)
.textContentType(.URL)
.keyboardType(.URL)
.autocapitalization(.none)
if viewModel.isCheckingDelegateHealth {
ProgressView()
.scaleEffect(0.8)
} else if let healthy = viewModel.delegateServerHealthy {
Image(systemName: healthy ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundColor(healthy ? .green : .red)
}
}
Text("Leave empty to disable server features. URLs are saved automatically.")
.font(.caption)
.foregroundColor(.secondary)
}
}
// MARK: - TTS Section
private var ttsSection: some View {
Section("Text to Speech") {
Toggle("Enable TTS", isOn: $viewModel.ttsEnabled)
Toggle("Auto-detect mode", isOn: $viewModel.ttsAutoMode)
.disabled(!viewModel.ttsEnabled)
if viewModel.ttsEnabled {
Text("Voice input → speaks response, Text input → silent")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
// MARK: - Device Section
private var deviceSection: some View {
Section("Your Device") {
HStack {
Text("Total RAM")
Spacer()
Text(viewModel.totalRAM)
.foregroundColor(.secondary)
}
HStack {
Text("Available RAM")
Spacer()
Text(viewModel.availableRAM)
.foregroundColor(.secondary)
}
HStack {
Text("Device")
Spacer()
Text(viewModel.deviceModel)
.foregroundColor(.secondary)
}
HStack {
Text("iOS Version")
Spacer()
Text(viewModel.iOSVersion)
.foregroundColor(.secondary)
}
}
}
// MARK: - About Section
private var aboutSection: some View {
Section {
HStack {
Spacer()
VStack(spacing: 4) {
Text("Sleepy Agent")
.font(.headline)
Text("Local LLM inference with Gemma 4")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
}
}
}
// MARK: - Model Variant Row
struct ModelVariantRow: View {
let name: String
let description: String
let isDownloaded: Bool
let isSelected: Bool
let isDownloading: Bool
let onSelect: () -> Void
let onDownload: () -> Void
let onDelete: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(name)
.font(.body)
Text(description)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
if isDownloaded {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
}
}
HStack(spacing: 8) {
Button(action: onSelect) {
Text(isSelected ? "Selected" : "Select")
}
.buttonStyle(.bordered)
.disabled(!isDownloaded || isSelected)
Spacer()
if isDownloaded {
Button(action: onDelete) {
Label("Delete", systemImage: "trash")
}
.buttonStyle(.borderless)
.tint(.red)
} else {
Button(action: onDownload) {
if isDownloading {
ProgressView()
.scaleEffect(0.8)
} else {
Label("Download", systemImage: "arrow.down.circle")
}
}
.buttonStyle(.borderedProminent)
.disabled(isDownloading)
}
}
}
.padding(.vertical, 4)
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
.cornerRadius(8)
}
}
// MARK: - Document Picker
struct DocumentPicker: UIViewControllerRepresentable {
@Binding var selectedURL: URL?
@Environment(\.presentationMode) var presentationMode
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.data, .item])
picker.delegate = context.coordinator
picker.allowsMultipleSelection = false
return picker
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIDocumentPickerDelegate {
let parent: DocumentPicker
init(_ parent: DocumentPicker) {
self.parent = parent
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
parent.selectedURL = urls.first
parent.presentationMode.wrappedValue.dismiss()
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
parent.presentationMode.wrappedValue.dismiss()
}
}
}
// MARK: - Preview
struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
SettingsView()
}
}