Files
PhoneClaw/Agent/Engine/ChatAttachments.swift
KellyvvandClaude Opus 4.6 779be78843 refactor: split MLX service, introduce skill type & runtime budgets
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>
2026-04-08 09:22:20 +08:00

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)
}
}
}