Major restructuring of the agent stack to support content-type skills, data-driven runtime budgets, and the new translate skill. LLM/MLX/ - Split MLXLocalLLMService.swift (1500 → 700 lines) into Core/, Gemma4/, Installation/ subdirectories - New BundledModel: LinearBudgetFormula replaces tier tables, model parameters fully data-driven via ModelRuntimeProfile - New RuntimeBudgets: pure functions for text/thinking/multimodal budgets, zero hardcoded model IDs - Drop adjustedTextOutputTokens prepared-subtraction (chunked prefill made it obsolete and triggered false-positive truncation on long prompts) Skills/ - Add SkillType (.device / .content) metadata, parsed from SKILL.md frontmatter - New translate skill (type: content, no tools) with anaphora handling - Trim translate (4563→1804), device (2357→1680), calendar (2169→1790) - contacts SKILL.md adds multi-turn disambiguation + cancel guidance, semantics owned by body not framework - SkillRegistry validates allowed-tools against ToolRegistry at load time Agent/ - ToolCallParser becomes registry-aware: drops tool_call names not in ToolRegistry or protocol set (load_skill / list_skills); ToolRegistry is now the single white list, validation moves to parser boundary - Router sticky routing uses semantic user-turn boundary instead of fixed suffix(4); correctly handles long agent loops - AgentEngine system prompt template differentiates device vs content skills with explicit invocation rules - SYSPROMPT.md auto-migration: old single-placeholder template backed up to .bak when new dual-placeholder is detected - PromptBuilder.buildLoadedSkillPrompt receives availableTools list, explicitly tells model which tools (or none) are callable Tools/ - ToolRegistry split into Helpers/Stores/AppPermissions/Handlers/* for separation of concerns - Mark Tools/Handlers/* with explicit allowed argument signatures UI/ - Add long-press copy contextMenu on user and assistant bubbles - Enable .textSelection on user bubble Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
201 lines
6.3 KiB
Swift
201 lines
6.3 KiB
Swift
import CoreImage
|
|
import Foundation
|
|
import MLXLMCommon
|
|
import UIKit
|
|
|
|
extension UserInput.Audio {
|
|
static func from(snapshot: AudioCaptureSnapshot) -> UserInput.Audio {
|
|
.pcm(
|
|
.init(
|
|
samples: snapshot.pcm,
|
|
sampleRate: snapshot.sampleRate,
|
|
channelCount: snapshot.channelCount
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
struct ChatImageAttachment: Identifiable, Codable {
|
|
let id: UUID
|
|
let data: Data
|
|
private static let storageMaxDimension: CGFloat = 1_024
|
|
private static let compressionQuality: CGFloat = 0.78
|
|
|
|
init(id: UUID = UUID(), data: Data) {
|
|
self.id = id
|
|
self.data = data
|
|
}
|
|
|
|
init?(image: UIImage) {
|
|
let prepared = Self.preparedImage(image, maxDimension: Self.storageMaxDimension)
|
|
if let jpeg = prepared.jpegData(compressionQuality: Self.compressionQuality) {
|
|
self.id = UUID()
|
|
self.data = jpeg
|
|
} else if let png = prepared.pngData() {
|
|
self.id = UUID()
|
|
self.data = png
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
static func preparedImage(_ image: UIImage, maxDimension: CGFloat = storageMaxDimension) -> UIImage {
|
|
let originalSize = image.size
|
|
let longestSide = max(originalSize.width, originalSize.height)
|
|
guard longestSide > maxDimension, longestSide > 0 else {
|
|
return image
|
|
}
|
|
|
|
let scale = maxDimension / longestSide
|
|
let targetSize = CGSize(
|
|
width: max(1, floor(originalSize.width * scale)),
|
|
height: max(1, floor(originalSize.height * scale))
|
|
)
|
|
|
|
let format = UIGraphicsImageRendererFormat.default()
|
|
format.opaque = false
|
|
format.scale = 1
|
|
let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
|
|
return renderer.image { _ in
|
|
image.draw(in: CGRect(origin: .zero, size: targetSize))
|
|
}
|
|
}
|
|
|
|
var uiImage: UIImage? {
|
|
UIImage(data: data)
|
|
}
|
|
|
|
var ciImage: CIImage? {
|
|
if let image = CIImage(
|
|
data: data,
|
|
options: [.applyOrientationProperty: true]
|
|
) {
|
|
return image
|
|
}
|
|
guard let uiImage else { return nil }
|
|
if let ciImage = uiImage.ciImage {
|
|
return ciImage
|
|
}
|
|
if let cgImage = uiImage.cgImage {
|
|
return CIImage(cgImage: cgImage)
|
|
}
|
|
return CIImage(image: uiImage)
|
|
}
|
|
}
|
|
|
|
struct ChatAudioAttachment: Identifiable, Codable {
|
|
let id: UUID
|
|
let wavData: Data
|
|
let duration: TimeInterval
|
|
let sampleRate: Double
|
|
let waveform: [Float]
|
|
|
|
init(
|
|
id: UUID = UUID(),
|
|
wavData: Data,
|
|
duration: TimeInterval,
|
|
sampleRate: Double,
|
|
waveform: [Float]
|
|
) {
|
|
self.id = id
|
|
self.wavData = wavData
|
|
self.duration = duration
|
|
self.sampleRate = sampleRate
|
|
self.waveform = waveform
|
|
}
|
|
|
|
init?(snapshot: AudioCaptureSnapshot) {
|
|
guard !snapshot.pcm.isEmpty, snapshot.sampleRate > 0 else { return nil }
|
|
self.id = UUID()
|
|
self.wavData = Self.makeWAVData(
|
|
pcm: snapshot.pcm,
|
|
sampleRate: snapshot.sampleRate,
|
|
channelCount: 1
|
|
)
|
|
self.duration = snapshot.duration
|
|
self.sampleRate = snapshot.sampleRate
|
|
self.waveform = Self.makeWaveform(from: snapshot.pcm)
|
|
}
|
|
|
|
var formattedDuration: String {
|
|
let totalSeconds = Int(duration.rounded())
|
|
let minutes = totalSeconds / 60
|
|
let seconds = totalSeconds % 60
|
|
return String(format: "%02d:%02d", minutes, seconds)
|
|
}
|
|
|
|
private static func makeWaveform(from pcm: [Float], bucketCount: Int = 36) -> [Float] {
|
|
guard !pcm.isEmpty else { return Array(repeating: 0.12, count: bucketCount) }
|
|
let samplesPerBucket = max(pcm.count / bucketCount, 1)
|
|
var levels: [Float] = []
|
|
levels.reserveCapacity(bucketCount)
|
|
|
|
var index = 0
|
|
while index < pcm.count {
|
|
let end = min(index + samplesPerBucket, pcm.count)
|
|
let slice = pcm[index..<end]
|
|
let peak = slice.reduce(Float.zero) { current, sample in
|
|
max(current, abs(sample))
|
|
}
|
|
levels.append(peak)
|
|
index = end
|
|
}
|
|
|
|
if levels.count < bucketCount, let last = levels.last {
|
|
levels.append(contentsOf: Array(repeating: last, count: bucketCount - levels.count))
|
|
}
|
|
if levels.count > bucketCount {
|
|
levels = Array(levels.prefix(bucketCount))
|
|
}
|
|
|
|
let maxLevel = max(levels.max() ?? 0, 0.001)
|
|
return levels.map { max($0 / maxLevel, 0.08) }
|
|
}
|
|
|
|
private static func makeWAVData(
|
|
pcm: [Float],
|
|
sampleRate: Double,
|
|
channelCount: Int
|
|
) -> Data {
|
|
let integerSampleRate = max(Int(sampleRate.rounded()), 1)
|
|
let clampedSamples = pcm.map { sample -> Int16 in
|
|
let limited = min(max(sample, -1), 1)
|
|
return Int16((limited * Float(Int16.max)).rounded())
|
|
}
|
|
|
|
let bytesPerSample = MemoryLayout<Int16>.size
|
|
let dataChunkSize = clampedSamples.count * bytesPerSample
|
|
let riffChunkSize = 36 + dataChunkSize
|
|
let byteRate = integerSampleRate * channelCount * bytesPerSample
|
|
let blockAlign = channelCount * bytesPerSample
|
|
|
|
var data = Data()
|
|
data.reserveCapacity(44 + dataChunkSize)
|
|
data.append("RIFF".data(using: .ascii)!)
|
|
append(UInt32(riffChunkSize), to: &data)
|
|
data.append("WAVE".data(using: .ascii)!)
|
|
data.append("fmt ".data(using: .ascii)!)
|
|
append(UInt32(16), to: &data)
|
|
append(UInt16(1), to: &data)
|
|
append(UInt16(channelCount), to: &data)
|
|
append(UInt32(integerSampleRate), to: &data)
|
|
append(UInt32(byteRate), to: &data)
|
|
append(UInt16(blockAlign), to: &data)
|
|
append(UInt16(bytesPerSample * 8), to: &data)
|
|
data.append("data".data(using: .ascii)!)
|
|
append(UInt32(dataChunkSize), to: &data)
|
|
|
|
for sample in clampedSamples {
|
|
append(sample, to: &data)
|
|
}
|
|
return data
|
|
}
|
|
|
|
private static func append<T: FixedWidthInteger>(_ value: T, to data: inout Data) {
|
|
var littleEndian = value.littleEndian
|
|
withUnsafeBytes(of: &littleEndian) { bytes in
|
|
data.append(contentsOf: bytes)
|
|
}
|
|
}
|
|
}
|