Add an iPhone client sharing the macOS app's SwiftUI code
This commit is contained in:
+142
-57
@@ -1,8 +1,10 @@
|
||||
import AppKit
|
||||
import ByreAPI
|
||||
import OpenAPIRuntime
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
#if os(iOS)
|
||||
import PhotosUI
|
||||
#endif
|
||||
|
||||
let maxImageBytes = 5 << 20
|
||||
|
||||
@@ -29,7 +31,7 @@ let models = [
|
||||
struct PendingImage: Identifiable {
|
||||
let id = UUID()
|
||||
let data: Data
|
||||
let image: NSImage
|
||||
let image: PlatformImage
|
||||
}
|
||||
|
||||
func imageType(_ data: Data) -> String? {
|
||||
@@ -49,8 +51,12 @@ func imageType(_ data: Data) -> String? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func pngFromTIFF(_ data: Data) -> Data? {
|
||||
func convertImage(_ data: Data) -> Data? {
|
||||
#if os(macOS)
|
||||
NSBitmapImageRep(data: data)?.representation(using: .png, properties: [:])
|
||||
#else
|
||||
UIImage(data: data)?.jpegData(compressionQuality: 0.85)
|
||||
#endif
|
||||
}
|
||||
|
||||
func uploadImages(_ api: API, _ imgs: [PendingImage]) async throws -> [String]? {
|
||||
@@ -116,8 +122,8 @@ final class ImageDrafts {
|
||||
|
||||
@discardableResult
|
||||
func add(_ key: String, _ data: Data, name: String?, _ overlays: Overlays) -> Bool {
|
||||
let file = imageType(data) != nil ? data : pngFromTIFF(data)
|
||||
guard let file, let image = NSImage(data: file) else {
|
||||
let file = imageType(data) != nil ? data : convertImage(data)
|
||||
guard let file, let image = PlatformImage(data: file) else {
|
||||
overlays.showAlert("\(name ?? "That file") isn't a PNG, JPEG, GIF or WebP image.")
|
||||
return false
|
||||
}
|
||||
@@ -129,6 +135,7 @@ final class ImageDrafts {
|
||||
return true
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
func add(_ key: String, pasteboard pb: NSPasteboard, _ overlays: Overlays) -> Bool {
|
||||
if let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL], !urls.isEmpty {
|
||||
let images = urls.filter { (try? $0.resourceValues(forKeys: [.contentTypeKey]).contentType?.conforms(to: .image)) == true }
|
||||
@@ -147,6 +154,7 @@ final class ImageDrafts {
|
||||
}
|
||||
return false
|
||||
}
|
||||
#endif
|
||||
|
||||
func add(_ key: String, providers: [NSItemProvider], _ overlays: Overlays) {
|
||||
for p in providers {
|
||||
@@ -175,6 +183,7 @@ final class ImageDrafts {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
struct ComposerTarget {
|
||||
let paste: @MainActor (NSPasteboard) -> Bool
|
||||
}
|
||||
@@ -221,6 +230,7 @@ struct PasteboardCommands: Commands {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private struct ImageInput: ViewModifier {
|
||||
let key: String
|
||||
@@ -230,7 +240,9 @@ private struct ImageInput: ViewModifier {
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
#if os(macOS)
|
||||
.focusedValue(\.composer, ComposerTarget(paste: { pb in store.images.add(key, pasteboard: pb, overlays) }))
|
||||
#endif
|
||||
.overlay {
|
||||
if targeted {
|
||||
RoundedRectangle(cornerRadius: 6).stroke(Palette.accent)
|
||||
@@ -254,6 +266,59 @@ extension View {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private struct ComposerMenu: View {
|
||||
let key: String
|
||||
let photos: Bool
|
||||
let models: [String]
|
||||
let selected: String
|
||||
let choose: @MainActor @Sendable (String) -> Void
|
||||
@Environment(Store.self) private var store
|
||||
@Environment(Overlays.self) private var overlays
|
||||
@ViewState private var picking = false
|
||||
@ViewState private var picked: [PhotosPickerItem] = []
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
if photos {
|
||||
Button {
|
||||
picking = true
|
||||
} label: {
|
||||
Label("Attach photos", systemImage: "photo")
|
||||
}
|
||||
}
|
||||
if !models.isEmpty {
|
||||
Picker("Model for the next turn", selection: Binding(get: { selected }, set: choose)) {
|
||||
ForEach(models, id: \.self) { option in
|
||||
Text(option).tag(option)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.frame(height: 14)
|
||||
}
|
||||
.buttonStyle(WebButtonStyle())
|
||||
.photosPicker(isPresented: $picking, selection: $picked, matching: .images)
|
||||
.onChange(of: picked) {
|
||||
let items = picked
|
||||
if items.isEmpty {
|
||||
return
|
||||
}
|
||||
picked = []
|
||||
Task {
|
||||
for item in items {
|
||||
if let data = try? await item.loadTransferable(type: Data.self) {
|
||||
store.images.add(key, data, name: nil, overlays)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
struct Thumbnails: View {
|
||||
let key: String
|
||||
@Environment(Store.self) private var store
|
||||
@@ -264,7 +329,7 @@ struct Thumbnails: View {
|
||||
if !imgs.isEmpty {
|
||||
FlowLayout(spacing: 8) {
|
||||
ForEach(imgs) { img in
|
||||
Image(nsImage: img.image)
|
||||
Image(platformImage: img.image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: 120, maxHeight: 90)
|
||||
@@ -325,6 +390,7 @@ struct AgentEditor: View {
|
||||
.scrollContentBackground(.hidden)
|
||||
.writingToolsBehavior(.disabled)
|
||||
.focused(focus)
|
||||
#if os(macOS)
|
||||
.onKeyPress(.return, phases: .down) { press in
|
||||
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
|
||||
if press.modifiers.contains(.shift) || marked {
|
||||
@@ -333,6 +399,7 @@ struct AgentEditor: View {
|
||||
send()
|
||||
return .handled
|
||||
}
|
||||
#endif
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 6)
|
||||
.frame(height: height)
|
||||
@@ -410,9 +477,6 @@ struct ComposerView: View {
|
||||
let m = modelState()
|
||||
let live = m.live
|
||||
let sessionState = live?.state ?? .idle
|
||||
let options = m.options
|
||||
let modelKey = m.modelKey
|
||||
let selected = m.selected
|
||||
let lost = store.drafts?.lostImageCount(draftKey) ?? 0
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -436,53 +500,17 @@ struct ComposerView: View {
|
||||
Thumbnails(key: draftKey)
|
||||
editor
|
||||
}
|
||||
let status = chat.status().map { s in
|
||||
StatusButton(status: s) {
|
||||
chat.showStatus(s)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Button(shown ? "⌄" : "⌃") {
|
||||
if shown {
|
||||
shownOverride = false
|
||||
} else {
|
||||
reveal()
|
||||
}
|
||||
}
|
||||
.buttonStyle(WebButtonStyle())
|
||||
.help(shown ? "Hide the message box" : "Show the message box")
|
||||
if !chat.team {
|
||||
Picker("", selection: Binding(get: { selected }, set: { v in
|
||||
pick = v
|
||||
modelChoices[modelKey] = v
|
||||
})) {
|
||||
ForEach(options, id: \.self) { m in
|
||||
Text(m).tag(m)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
.fixedSize()
|
||||
.help("Model for the next turn")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if let status = chat.status() {
|
||||
StatusButton(status: status) {
|
||||
chat.showStatus(status)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Button("Interrupt") {
|
||||
if let id = live?.id {
|
||||
chat.interrupt(id, overlays)
|
||||
}
|
||||
}
|
||||
.buttonStyle(WebButtonStyle(danger: true))
|
||||
.disabled(sessionState != .running)
|
||||
Button("Send") {
|
||||
submit(modelState())
|
||||
}
|
||||
.buttonStyle(WebButtonStyle())
|
||||
.disabled(!shown)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
controls(m)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
status
|
||||
actions(live?.id, running: sessionState == .running)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
@@ -517,7 +545,7 @@ struct ComposerView: View {
|
||||
.onChange(of: store.images.images(draftKey).count) {
|
||||
store.drafts?.setComposerImages(draftKey, store.images.images(draftKey).count)
|
||||
}
|
||||
.onChange(of: modelKey) {
|
||||
.onChange(of: m.modelKey) {
|
||||
pick = nil
|
||||
}
|
||||
.onChange(of: overlays.leadFocus) {
|
||||
@@ -538,8 +566,65 @@ struct ComposerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func controls(_ m: ModelState) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
if shown {
|
||||
shownOverride = false
|
||||
} else {
|
||||
reveal()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: shown ? "chevron.down" : "chevron.up")
|
||||
.frame(height: 14)
|
||||
}
|
||||
.buttonStyle(WebButtonStyle())
|
||||
.help(shown ? "Hide the message box" : "Show the message box")
|
||||
#if os(iOS)
|
||||
ComposerMenu(key: draftKey, photos: shown, models: chat.team ? [] : m.options, selected: m.selected) { option in
|
||||
pick = option
|
||||
modelChoices[m.modelKey] = option
|
||||
}
|
||||
#else
|
||||
if !chat.team {
|
||||
Picker("", selection: Binding(get: { m.selected }, set: { v in
|
||||
pick = v
|
||||
modelChoices[m.modelKey] = v
|
||||
})) {
|
||||
ForEach(m.options, id: \.self) { option in
|
||||
Text(option).tag(option)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
.fixedSize()
|
||||
.help("Model for the next turn")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func actions(_ sessionId: String?, running: Bool) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Button("Interrupt") {
|
||||
if let sessionId {
|
||||
chat.interrupt(sessionId, overlays)
|
||||
}
|
||||
}
|
||||
.buttonStyle(WebButtonStyle(danger: true))
|
||||
.disabled(!running)
|
||||
.fixedSize()
|
||||
Button("Send") {
|
||||
submit(modelState())
|
||||
}
|
||||
.buttonStyle(WebButtonStyle())
|
||||
.disabled(!shown)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
|
||||
private var editor: some View {
|
||||
AgentEditor(text: $text, placeholder: chat.placeholder, key: draftKey, height: 104,
|
||||
AgentEditor(text: $text, placeholder: chat.placeholder, key: draftKey, height: touchOnly ? 72 : 104,
|
||||
focus: $focused, border: inTask ? accent : focused ? Palette.accent : Palette.border,
|
||||
borderWidth: inTask && focused ? 2 : 1) {
|
||||
submit(modelState())
|
||||
|
||||
Reference in New Issue
Block a user