ios: adds file uploading
This commit is contained in:
@@ -19,8 +19,8 @@ targets:
|
|||||||
TARGETED_DEVICE_FAMILY: "1,2"
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
MARKETING_VERSION: 1.2
|
MARKETING_VERSION: 1.3
|
||||||
CURRENT_PROJECT_VERSION: 3
|
CURRENT_PROJECT_VERSION: 4
|
||||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||||
|
|||||||
33
ios/Packages/Sybil/Package.resolved
Normal file
33
ios/Packages/Sybil/Package.resolved
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "a6321e2b291c1094ca66f749c90095f05aac7f8c6b4a6e54e0e77a1bb0e1a79f",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "networkimage",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/gonzalezreal/NetworkImage",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
|
||||||
|
"version" : "6.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-cmark",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swiftlang/swift-cmark",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
|
||||||
|
"version" : "0.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-markdown-ui",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/gonzalezreal/swift-markdown-ui.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
|
||||||
|
"version" : "2.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
222
ios/Packages/Sybil/Sources/Sybil/SybilAttachmentViews.swift
Normal file
222
ios/Packages/Sybil/Sources/Sybil/SybilAttachmentViews.swift
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum SybilAttachmentTone {
|
||||||
|
case composer
|
||||||
|
case user
|
||||||
|
case assistant
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SybilAttachmentListView: View {
|
||||||
|
var attachments: [ChatAttachment]
|
||||||
|
var tone: SybilAttachmentTone
|
||||||
|
var onRemove: ((String) -> Void)? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(attachments) { attachment in
|
||||||
|
Group {
|
||||||
|
if attachment.kind == .image {
|
||||||
|
imageCard(attachment)
|
||||||
|
} else {
|
||||||
|
textCard(attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func imageCard(_ attachment: ChatAttachment) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
if let image = SybilChatAttachmentSupport.image(for: attachment) {
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 180)
|
||||||
|
.clipped()
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(Color.black.opacity(0.18))
|
||||||
|
Image(systemName: "photo")
|
||||||
|
.font(.system(size: 22, weight: .medium))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.frame(height: 140)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Image(systemName: "photo")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(titleColor.opacity(0.92))
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color.white.opacity(0.06))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(Color.white.opacity(0.08), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(attachment.filename)
|
||||||
|
.font(.sybil(.footnote, weight: .medium))
|
||||||
|
.foregroundStyle(titleColor)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(attachment.mimeType)
|
||||||
|
.font(.sybil(.caption2))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
if let onRemove {
|
||||||
|
removeButton(for: attachment.id, onRemove: onRemove)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
}
|
||||||
|
.background(cardBackground)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(cardBorder, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func textCard(_ attachment: ChatAttachment) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Image(systemName: "doc.text")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(titleColor.opacity(0.92))
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color.white.opacity(0.06))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(Color.white.opacity(0.08), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(attachment.filename)
|
||||||
|
.font(.sybil(.footnote, weight: .medium))
|
||||||
|
.foregroundStyle(titleColor)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
attachment.truncated == true
|
||||||
|
? "\(attachment.mimeType) • truncated"
|
||||||
|
: attachment.mimeType
|
||||||
|
)
|
||||||
|
.font(.sybil(.caption2))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
if let onRemove {
|
||||||
|
removeButton(for: attachment.id, onRemove: onRemove)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(SybilChatAttachmentSupport.previewText(for: attachment))
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.foregroundStyle(bodyColor)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(Color.black.opacity(0.16))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(Color.white.opacity(0.05), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(cardBackground)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(cardBorder, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeButton(for attachmentID: String, onRemove: @escaping (String) -> Void) -> some View {
|
||||||
|
Button {
|
||||||
|
onRemove(attachmentID)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 11, weight: .bold))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(Color.white.opacity(0.06))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Remove attachment")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cardBackground: some ShapeStyle {
|
||||||
|
switch tone {
|
||||||
|
case .composer:
|
||||||
|
return AnyShapeStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [SybilTheme.surface.opacity(0.86), SybilTheme.surfaceStrong.opacity(0.78)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
case .user:
|
||||||
|
return AnyShapeStyle(Color.black.opacity(0.14))
|
||||||
|
case .assistant:
|
||||||
|
return AnyShapeStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [SybilTheme.surface.opacity(0.58), SybilTheme.surfaceStrong.opacity(0.42)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cardBorder: Color {
|
||||||
|
switch tone {
|
||||||
|
case .composer:
|
||||||
|
return SybilTheme.border.opacity(0.82)
|
||||||
|
case .user:
|
||||||
|
return Color.white.opacity(0.12)
|
||||||
|
case .assistant:
|
||||||
|
return SybilTheme.border.opacity(0.58)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var titleColor: Color {
|
||||||
|
switch tone {
|
||||||
|
case .composer, .assistant:
|
||||||
|
return SybilTheme.text
|
||||||
|
case .user:
|
||||||
|
return SybilTheme.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bodyColor: Color {
|
||||||
|
switch tone {
|
||||||
|
case .composer, .assistant:
|
||||||
|
return SybilTheme.text.opacity(0.94)
|
||||||
|
case .user:
|
||||||
|
return SybilTheme.text.opacity(0.96)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import Foundation
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum ChatAttachmentError: LocalizedError {
|
||||||
|
case unsupportedType(String)
|
||||||
|
case imageTooLarge(String)
|
||||||
|
case textTooLarge(String)
|
||||||
|
case unreadableFile(String)
|
||||||
|
case unsupportedImageFormat(String)
|
||||||
|
case tooManyAttachments(Int)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case let .unsupportedType(filename):
|
||||||
|
return "Unsupported file type for '\(filename)'. Use PNG/JPEG images or text-based files."
|
||||||
|
case let .imageTooLarge(filename):
|
||||||
|
return "Image '\(filename)' exceeds the 6 MB upload limit."
|
||||||
|
case let .textTooLarge(filename):
|
||||||
|
return "Text file '\(filename)' exceeds the 8 MB upload limit."
|
||||||
|
case let .unreadableFile(filename):
|
||||||
|
return "Could not read '\(filename)'."
|
||||||
|
case let .unsupportedImageFormat(filename):
|
||||||
|
return "Image '\(filename)' could not be converted to PNG or JPEG."
|
||||||
|
case let .tooManyAttachments(limit):
|
||||||
|
return "You can attach up to \(limit) files per message."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SybilChatAttachmentSupport {
|
||||||
|
static let maxAttachmentsPerMessage = 8
|
||||||
|
static let maxImageBytes = 6 * 1024 * 1024
|
||||||
|
static let maxTextBytes = 8 * 1024 * 1024
|
||||||
|
static let maxTextCharacters = 200_000
|
||||||
|
|
||||||
|
private static let supportedTextExtensions: Set<String> = [
|
||||||
|
"txt", "md", "markdown", "csv", "tsv", "json", "jsonl", "xml", "yaml", "yml", "html", "htm",
|
||||||
|
"css", "js", "jsx", "ts", "tsx", "py", "rb", "java", "c", "cc", "cpp", "h", "hpp", "go",
|
||||||
|
"rs", "sh", "sql", "log", "toml", "ini", "cfg", "conf", "swift", "kt", "m", "mm"
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let supportedTextMimeTypes: Set<String> = [
|
||||||
|
"application/json",
|
||||||
|
"application/ld+json",
|
||||||
|
"application/sql",
|
||||||
|
"application/toml",
|
||||||
|
"application/x-httpd-php",
|
||||||
|
"application/x-javascript",
|
||||||
|
"application/x-sh",
|
||||||
|
"application/xml",
|
||||||
|
"application/yaml",
|
||||||
|
"application/x-yaml",
|
||||||
|
"image/svg+xml"
|
||||||
|
]
|
||||||
|
|
||||||
|
static func attachmentSummary(_ attachments: [ChatAttachment]) -> String {
|
||||||
|
guard !attachments.isEmpty else { return "" }
|
||||||
|
let names = attachments.map(\.filename).joined(separator: ", ")
|
||||||
|
return attachments.count == 1 ? names : "Attached: \(names)"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func metadataValue(for attachments: [ChatAttachment]) -> JSONValue? {
|
||||||
|
guard !attachments.isEmpty else { return nil }
|
||||||
|
return .object([
|
||||||
|
"attachments": .array(attachments.map(\.jsonValue))
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
static func buildAttachments(from urls: [URL]) throws -> [ChatAttachment] {
|
||||||
|
try urls.map { try buildAttachment(fromFileURL: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func buildImageAttachment(image: UIImage, filename: String = "pasted-image.jpg") throws -> ChatAttachment {
|
||||||
|
if let pngData = image.pngData(), pngData.count <= maxImageBytes {
|
||||||
|
return try buildImageAttachment(data: pngData, filename: filename, contentType: .png)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let jpegData = image.jpegData(compressionQuality: 0.92) else {
|
||||||
|
throw ChatAttachmentError.unsupportedImageFormat(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try buildImageAttachment(data: jpegData, filename: filename, contentType: .jpeg)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func buildTextAttachment(text: String, filename: String = "pasted-text.txt", mimeType: String = "text/plain") throws -> ChatAttachment {
|
||||||
|
let data = Data(text.utf8)
|
||||||
|
return try buildTextAttachment(data: data, filename: filename, mimeType: mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
static func buildAttachments(from itemProviders: [NSItemProvider]) async throws -> [ChatAttachment] {
|
||||||
|
var attachments: [ChatAttachment] = []
|
||||||
|
|
||||||
|
for provider in itemProviders {
|
||||||
|
if let fileURL = try await loadFileURL(from: provider) {
|
||||||
|
attachments.append(try buildAttachment(fromFileURL: fileURL))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
|
||||||
|
if let attachment = try await loadImageAttachment(from: provider) {
|
||||||
|
attachments.append(attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
static func previewText(for attachment: ChatAttachment) -> String {
|
||||||
|
let normalized = (attachment.text ?? "")
|
||||||
|
.replacingOccurrences(of: "\r", with: "")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
if normalized.isEmpty {
|
||||||
|
return "(empty file)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalized.count <= 280 {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
let endIndex = normalized.index(normalized.startIndex, offsetBy: 280)
|
||||||
|
return normalized[..<endIndex].trimmingCharacters(in: .whitespacesAndNewlines) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
static func image(for attachment: ChatAttachment) -> UIImage? {
|
||||||
|
guard attachment.kind == .image,
|
||||||
|
let dataURL = attachment.dataUrl,
|
||||||
|
let data = decodeDataURL(dataURL)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return UIImage(data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func buildAttachment(fromFileURL url: URL) throws -> ChatAttachment {
|
||||||
|
let accessed = url.startAccessingSecurityScopedResource()
|
||||||
|
defer {
|
||||||
|
if accessed {
|
||||||
|
url.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let filename = url.lastPathComponent.isEmpty ? "attachment" : url.lastPathComponent
|
||||||
|
let resourceValues = try? url.resourceValues(forKeys: [.contentTypeKey])
|
||||||
|
let contentType = resourceValues?.contentType ?? UTType(filenameExtension: url.pathExtension)
|
||||||
|
|
||||||
|
let data: Data
|
||||||
|
do {
|
||||||
|
data = try Data(contentsOf: url)
|
||||||
|
} catch {
|
||||||
|
throw ChatAttachmentError.unreadableFile(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
if contentType?.conforms(to: .image) == true {
|
||||||
|
return try buildImageAttachment(data: data, filename: filename, contentType: contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isTextLike(contentType: contentType, mimeType: contentType?.preferredMIMEType, filename: filename) {
|
||||||
|
return try buildTextAttachment(data: data, filename: filename, mimeType: contentType?.preferredMIMEType ?? "text/plain")
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ChatAttachmentError.unsupportedType(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func buildImageAttachment(data: Data, filename: String, contentType: UTType?) throws -> ChatAttachment {
|
||||||
|
var mimeType = contentType?.preferredMIMEType
|
||||||
|
var payload = data
|
||||||
|
|
||||||
|
if mimeType != "image/png" && mimeType != "image/jpeg" {
|
||||||
|
guard let image = UIImage(data: data) else {
|
||||||
|
throw ChatAttachmentError.unsupportedImageFormat(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let pngData = image.pngData(), pngData.count <= maxImageBytes {
|
||||||
|
payload = pngData
|
||||||
|
mimeType = "image/png"
|
||||||
|
} else if let jpegData = image.jpegData(compressionQuality: 0.92) {
|
||||||
|
payload = jpegData
|
||||||
|
mimeType = "image/jpeg"
|
||||||
|
} else {
|
||||||
|
throw ChatAttachmentError.unsupportedImageFormat(filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.count > maxImageBytes {
|
||||||
|
throw ChatAttachmentError.imageTooLarge(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedMimeType = (mimeType == "image/png") ? "image/png" : "image/jpeg"
|
||||||
|
let dataUrl = "data:\(normalizedMimeType);base64,\(payload.base64EncodedString())"
|
||||||
|
|
||||||
|
return .image(
|
||||||
|
filename: filename,
|
||||||
|
mimeType: normalizedMimeType,
|
||||||
|
sizeBytes: payload.count,
|
||||||
|
dataUrl: dataUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func buildTextAttachment(data: Data, filename: String, mimeType: String) throws -> ChatAttachment {
|
||||||
|
if data.count > maxTextBytes {
|
||||||
|
throw ChatAttachmentError.textTooLarge(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = String(decoding: data, as: UTF8.self)
|
||||||
|
.replacingOccurrences(of: "\r\n", with: "\n")
|
||||||
|
.replacingOccurrences(of: "\u{0000}", with: "")
|
||||||
|
|
||||||
|
let truncated = normalized.count > maxTextCharacters
|
||||||
|
let trimmedText: String
|
||||||
|
if truncated {
|
||||||
|
let endIndex = normalized.index(normalized.startIndex, offsetBy: maxTextCharacters)
|
||||||
|
trimmedText = String(normalized[..<endIndex])
|
||||||
|
} else {
|
||||||
|
trimmedText = normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
return .text(
|
||||||
|
filename: filename,
|
||||||
|
mimeType: mimeType,
|
||||||
|
sizeBytes: data.count,
|
||||||
|
text: trimmedText,
|
||||||
|
truncated: truncated
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isTextLike(contentType: UTType?, mimeType: String?, filename: String) -> Bool {
|
||||||
|
if let contentType {
|
||||||
|
if contentType.conforms(to: .text) || contentType.conforms(to: .plainText) || contentType.conforms(to: .sourceCode) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if contentType.conforms(to: .json) || contentType.conforms(to: .xml) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let mimeType {
|
||||||
|
if mimeType.hasPrefix("text/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if supportedTextMimeTypes.contains(mimeType.lowercased()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ext = URL(fileURLWithPath: filename).pathExtension.lowercased()
|
||||||
|
return supportedTextExtensions.contains(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decodeDataURL(_ value: String) -> Data? {
|
||||||
|
guard let separator = value.firstIndex(of: ",") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let encoded = value[value.index(after: separator)...]
|
||||||
|
return Data(base64Encoded: String(encoded))
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func loadFileURL(from provider: NSItemProvider) async throws -> URL? {
|
||||||
|
guard provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, error in
|
||||||
|
if let error {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let url = item as? URL {
|
||||||
|
continuation.resume(returning: url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let data = item as? Data,
|
||||||
|
let url = URL(dataRepresentation: data, relativeTo: nil) {
|
||||||
|
continuation.resume(returning: url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let string = item as? String,
|
||||||
|
let url = URL(string: string) {
|
||||||
|
continuation.resume(returning: url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.resume(returning: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func loadImageAttachment(from provider: NSItemProvider) async throws -> ChatAttachment? {
|
||||||
|
let preferredImageType: UTType = if provider.hasItemConformingToTypeIdentifier(UTType.png.identifier) {
|
||||||
|
.png
|
||||||
|
} else if provider.hasItemConformingToTypeIdentifier(UTType.jpeg.identifier) {
|
||||||
|
.jpeg
|
||||||
|
} else {
|
||||||
|
.image
|
||||||
|
}
|
||||||
|
|
||||||
|
if let data = try await loadDataRepresentation(from: provider, type: preferredImageType) {
|
||||||
|
let filenameExtension = preferredImageType.preferredFilenameExtension ?? "jpg"
|
||||||
|
let filename = "pasted-image.\(filenameExtension)"
|
||||||
|
return try buildImageAttachment(data: data, filename: filename, contentType: preferredImageType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let image = try await loadUIImage(from: provider),
|
||||||
|
let jpegData = image.jpegData(compressionQuality: 0.92) {
|
||||||
|
return try buildImageAttachment(data: jpegData, filename: "pasted-image.jpg", contentType: .jpeg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func loadDataRepresentation(from provider: NSItemProvider, type: UTType) async throws -> Data? {
|
||||||
|
guard provider.hasItemConformingToTypeIdentifier(type.identifier) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, error in
|
||||||
|
if let error {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continuation.resume(returning: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func loadUIImage(from provider: NSItemProvider) async throws -> UIImage? {
|
||||||
|
guard provider.canLoadObject(ofClass: UIImage.self) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
provider.loadObject(ofClass: UIImage.self) { object, error in
|
||||||
|
if let error {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continuation.resume(returning: object as? UIImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,6 +98,13 @@ private struct MessageBubble: View {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if !message.attachments.isEmpty {
|
||||||
|
SybilAttachmentListView(
|
||||||
|
attachments: message.attachments,
|
||||||
|
tone: isUser ? .user : .assistant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if isPendingAssistant {
|
if isPendingAssistant {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -108,7 +115,7 @@ private struct MessageBubble: View {
|
|||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
} else {
|
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
Markdown(message.content)
|
Markdown(message.content)
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
||||||
|
|||||||
@@ -21,6 +21,132 @@ public enum MessageRole: String, Codable, Hashable, Sendable {
|
|||||||
case tool
|
case tool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct ChatAttachment: Codable, Hashable, Identifiable, Sendable {
|
||||||
|
public enum Kind: String, Codable, Hashable, Sendable {
|
||||||
|
case image
|
||||||
|
case text
|
||||||
|
}
|
||||||
|
|
||||||
|
public var id: String
|
||||||
|
public var kind: Kind
|
||||||
|
public var filename: String
|
||||||
|
public var mimeType: String
|
||||||
|
public var sizeBytes: Int
|
||||||
|
public var dataUrl: String?
|
||||||
|
public var text: String?
|
||||||
|
public var truncated: Bool?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
kind: Kind,
|
||||||
|
filename: String,
|
||||||
|
mimeType: String,
|
||||||
|
sizeBytes: Int,
|
||||||
|
dataUrl: String? = nil,
|
||||||
|
text: String? = nil,
|
||||||
|
truncated: Bool? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.kind = kind
|
||||||
|
self.filename = filename
|
||||||
|
self.mimeType = mimeType
|
||||||
|
self.sizeBytes = sizeBytes
|
||||||
|
self.dataUrl = dataUrl
|
||||||
|
self.text = text
|
||||||
|
self.truncated = truncated
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func image(
|
||||||
|
id: String = UUID().uuidString,
|
||||||
|
filename: String,
|
||||||
|
mimeType: String,
|
||||||
|
sizeBytes: Int,
|
||||||
|
dataUrl: String
|
||||||
|
) -> ChatAttachment {
|
||||||
|
ChatAttachment(
|
||||||
|
id: id,
|
||||||
|
kind: .image,
|
||||||
|
filename: filename,
|
||||||
|
mimeType: mimeType,
|
||||||
|
sizeBytes: sizeBytes,
|
||||||
|
dataUrl: dataUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func text(
|
||||||
|
id: String = UUID().uuidString,
|
||||||
|
filename: String,
|
||||||
|
mimeType: String,
|
||||||
|
sizeBytes: Int,
|
||||||
|
text: String,
|
||||||
|
truncated: Bool
|
||||||
|
) -> ChatAttachment {
|
||||||
|
ChatAttachment(
|
||||||
|
id: id,
|
||||||
|
kind: .text,
|
||||||
|
filename: filename,
|
||||||
|
mimeType: mimeType,
|
||||||
|
sizeBytes: sizeBytes,
|
||||||
|
text: text,
|
||||||
|
truncated: truncated
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var jsonValue: JSONValue {
|
||||||
|
var object: [String: JSONValue] = [
|
||||||
|
"kind": .string(kind.rawValue),
|
||||||
|
"id": .string(id),
|
||||||
|
"filename": .string(filename),
|
||||||
|
"mimeType": .string(mimeType),
|
||||||
|
"sizeBytes": .number(Double(sizeBytes))
|
||||||
|
]
|
||||||
|
|
||||||
|
if let dataUrl {
|
||||||
|
object["dataUrl"] = .string(dataUrl)
|
||||||
|
}
|
||||||
|
if let text {
|
||||||
|
object["text"] = .string(text)
|
||||||
|
}
|
||||||
|
if let truncated {
|
||||||
|
object["truncated"] = .bool(truncated)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .object(object)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func attachments(from metadata: JSONValue?) -> [ChatAttachment] {
|
||||||
|
guard let metadataObject = metadata?.objectValue,
|
||||||
|
let values = metadataObject["attachments"]?.arrayValue
|
||||||
|
else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return values.compactMap { value in
|
||||||
|
guard let object = value.objectValue,
|
||||||
|
let kindRaw = object["kind"]?.stringValue,
|
||||||
|
let kind = Kind(rawValue: kindRaw),
|
||||||
|
let id = object["id"]?.stringValue,
|
||||||
|
let filename = object["filename"]?.stringValue,
|
||||||
|
let mimeType = object["mimeType"]?.stringValue,
|
||||||
|
let sizeNumber = object["sizeBytes"]?.numberValue
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChatAttachment(
|
||||||
|
id: id,
|
||||||
|
kind: kind,
|
||||||
|
filename: filename,
|
||||||
|
mimeType: mimeType,
|
||||||
|
sizeBytes: Int(sizeNumber),
|
||||||
|
dataUrl: object["dataUrl"]?.stringValue,
|
||||||
|
text: object["text"]?.stringValue,
|
||||||
|
truncated: object["truncated"]?.boolValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var title: String?
|
public var title: String?
|
||||||
@@ -48,6 +174,10 @@ public struct Message: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var name: String?
|
public var name: String?
|
||||||
public var metadata: JSONValue? = nil
|
public var metadata: JSONValue? = nil
|
||||||
|
|
||||||
|
public var attachments: [ChatAttachment] {
|
||||||
|
ChatAttachment.attachments(from: metadata)
|
||||||
|
}
|
||||||
|
|
||||||
public var toolCallMetadata: ToolCallMetadata? {
|
public var toolCallMetadata: ToolCallMetadata? {
|
||||||
guard role == .tool,
|
guard role == .tool,
|
||||||
let object = metadata?.objectValue,
|
let object = metadata?.objectValue,
|
||||||
@@ -155,6 +285,20 @@ public enum JSONValue: Codable, Hashable, Sendable {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var arrayValue: [JSONValue]? {
|
||||||
|
if case let .array(value) = self {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public var boolValue: Bool? {
|
||||||
|
if case let .bool(value) = self {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
||||||
@@ -239,11 +383,13 @@ public struct CompletionRequestMessage: Codable, Sendable {
|
|||||||
public var role: MessageRole
|
public var role: MessageRole
|
||||||
public var content: String
|
public var content: String
|
||||||
public var name: String?
|
public var name: String?
|
||||||
|
public var attachments: [ChatAttachment]?
|
||||||
|
|
||||||
public init(role: MessageRole, content: String, name: String? = nil) {
|
public init(role: MessageRole, content: String, name: String? = nil, attachments: [ChatAttachment]? = nil) {
|
||||||
self.role = role
|
self.role = role
|
||||||
self.content = content
|
self.content = content
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self.attachments = attachments
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ final class SybilViewModel {
|
|||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
|
|
||||||
var composer = ""
|
var composer = ""
|
||||||
|
var composerAttachments: [ChatAttachment] = []
|
||||||
var provider: Provider
|
var provider: Provider
|
||||||
var modelCatalog: [Provider: ProviderModelInfo] = [:]
|
var modelCatalog: [Provider: ProviderModelInfo] = [:]
|
||||||
var model: String
|
var model: String
|
||||||
@@ -202,6 +203,19 @@ final class SybilViewModel {
|
|||||||
return draftKind != nil || selectedItem != nil
|
return draftKind != nil || selectedItem != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var canSendComposer: Bool {
|
||||||
|
if isSending {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if isSearchMode {
|
||||||
|
return !content.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
return !content.isEmpty || !composerAttachments.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
var displayedMessages: [Message] {
|
var displayedMessages: [Message] {
|
||||||
let canonical = displayableMessages(selectedChat?.messages ?? [])
|
let canonical = displayableMessages(selectedChat?.messages ?? [])
|
||||||
guard let pending = pendingChatState else {
|
guard let pending = pendingChatState else {
|
||||||
@@ -282,6 +296,7 @@ final class SybilViewModel {
|
|||||||
authError = nil
|
authError = nil
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
pendingChatState = nil
|
pendingChatState = nil
|
||||||
|
composerAttachments = []
|
||||||
settings.persist()
|
settings.persist()
|
||||||
|
|
||||||
SybilLog.info(
|
SybilLog.info(
|
||||||
@@ -358,6 +373,7 @@ final class SybilViewModel {
|
|||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
composer = ""
|
composer = ""
|
||||||
|
composerAttachments = []
|
||||||
}
|
}
|
||||||
|
|
||||||
func startNewSearch() {
|
func startNewSearch() {
|
||||||
@@ -368,6 +384,7 @@ final class SybilViewModel {
|
|||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
composer = ""
|
composer = ""
|
||||||
|
composerAttachments = []
|
||||||
}
|
}
|
||||||
|
|
||||||
func openSettings() {
|
func openSettings() {
|
||||||
@@ -377,6 +394,7 @@ final class SybilViewModel {
|
|||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
composerAttachments = []
|
||||||
}
|
}
|
||||||
|
|
||||||
func select(_ selection: SidebarSelection) {
|
func select(_ selection: SidebarSelection) {
|
||||||
@@ -384,6 +402,9 @@ final class SybilViewModel {
|
|||||||
draftKind = nil
|
draftKind = nil
|
||||||
selectedItem = selection
|
selectedItem = selection
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
if case .search = selection {
|
||||||
|
composerAttachments = []
|
||||||
|
}
|
||||||
|
|
||||||
if case .settings = selection {
|
if case .settings = selection {
|
||||||
selectedChat = nil
|
selectedChat = nil
|
||||||
@@ -430,11 +451,20 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
func sendComposer() async {
|
func sendComposer() async {
|
||||||
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !content.isEmpty, !isSending else {
|
let attachments = composerAttachments
|
||||||
|
|
||||||
|
guard !isSending else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isSearchMode {
|
||||||
|
guard !content.isEmpty else { return }
|
||||||
|
} else if content.isEmpty && attachments.isEmpty {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
composer = ""
|
composer = ""
|
||||||
|
composerAttachments = []
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
isSending = true
|
isSending = true
|
||||||
|
|
||||||
@@ -444,7 +474,7 @@ final class SybilViewModel {
|
|||||||
try await sendSearch(query: content)
|
try await sendSearch(query: content)
|
||||||
} else {
|
} else {
|
||||||
SybilLog.info(SybilLog.ui, "Sending chat prompt")
|
SybilLog.info(SybilLog.ui, "Sending chat prompt")
|
||||||
try await sendChat(content: content)
|
try await sendChat(content: content, attachments: attachments)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = normalizeAPIError(error)
|
errorMessage = normalizeAPIError(error)
|
||||||
@@ -468,12 +498,38 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingChatState = nil
|
if !isSearchMode {
|
||||||
|
composer = content
|
||||||
|
composerAttachments = attachments
|
||||||
|
pendingChatState = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isSending = false
|
isSending = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendComposerAttachments(_ attachments: [ChatAttachment]) throws {
|
||||||
|
guard !attachments.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !isSearchMode else {
|
||||||
|
errorMessage = "Attachments are only available in chat mode."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if composerAttachments.count + attachments.count > SybilChatAttachmentSupport.maxAttachmentsPerMessage {
|
||||||
|
throw ChatAttachmentError.tooManyAttachments(SybilChatAttachmentSupport.maxAttachmentsPerMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
composerAttachments += attachments
|
||||||
|
errorMessage = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeComposerAttachment(id: String) {
|
||||||
|
composerAttachments.removeAll { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
func startChatFromSelectedSearch() async {
|
func startChatFromSelectedSearch() async {
|
||||||
guard let search = selectedSearch, !isCreatingSearchChat, !isSending else {
|
guard let search = selectedSearch, !isCreatingSearchChat, !isSending else {
|
||||||
return
|
return
|
||||||
@@ -488,6 +544,7 @@ final class SybilViewModel {
|
|||||||
draftKind = nil
|
draftKind = nil
|
||||||
pendingChatState = nil
|
pendingChatState = nil
|
||||||
composer = ""
|
composer = ""
|
||||||
|
composerAttachments = []
|
||||||
|
|
||||||
chats.removeAll(where: { $0.id == chat.id })
|
chats.removeAll(where: { $0.id == chat.id })
|
||||||
chats.insert(chat, at: 0)
|
chats.insert(chat, at: 0)
|
||||||
@@ -668,13 +725,14 @@ final class SybilViewModel {
|
|||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendChat(content: String) async throws {
|
private func sendChat(content: String, attachments: [ChatAttachment]) async throws {
|
||||||
let optimisticUser = Message(
|
let optimisticUser = Message(
|
||||||
id: "temp-user-\(UUID().uuidString)",
|
id: "temp-user-\(UUID().uuidString)",
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
role: .user,
|
role: .user,
|
||||||
content: content,
|
content: content,
|
||||||
name: nil
|
name: nil,
|
||||||
|
metadata: SybilChatAttachmentSupport.metadataValue(for: attachments)
|
||||||
)
|
)
|
||||||
|
|
||||||
let optimisticAssistant = Message(
|
let optimisticAssistant = Message(
|
||||||
@@ -740,8 +798,8 @@ final class SybilViewModel {
|
|||||||
baseChat.messages
|
baseChat.messages
|
||||||
.filter { !$0.isToolCallLog }
|
.filter { !$0.isToolCallLog }
|
||||||
.map {
|
.map {
|
||||||
CompletionRequestMessage(role: $0.role, content: $0.content, name: $0.name)
|
CompletionRequestMessage(role: $0.role, content: $0.content, name: $0.name, attachments: $0.attachments.isEmpty ? nil : $0.attachments)
|
||||||
} + [CompletionRequestMessage(role: .user, content: content)]
|
} + [CompletionRequestMessage(role: .user, content: content, attachments: attachments.isEmpty ? nil : attachments)]
|
||||||
|
|
||||||
let streamStatus = CompletionStreamStatus()
|
let streamStatus = CompletionStreamStatus()
|
||||||
|
|
||||||
@@ -749,7 +807,8 @@ final class SybilViewModel {
|
|||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
do {
|
do {
|
||||||
let updated = try await client.suggestChatTitle(chatID: chatID, content: content)
|
let titleSeed = !content.isEmpty ? content : SybilChatAttachmentSupport.attachmentSummary(attachments)
|
||||||
|
let updated = try await client.suggestChatTitle(chatID: chatID, content: titleSeed.isEmpty ? "Uploaded files" : titleSeed)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.chats = self.chats.map { existing in
|
self.chats = self.chats.map { existing in
|
||||||
if existing.id == updated.id {
|
if existing.id == updated.id {
|
||||||
@@ -1019,6 +1078,13 @@ final class SybilViewModel {
|
|||||||
return String(firstUserMessage.prefix(48))
|
return String(firstUserMessage.prefix(48))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let firstUserMessage = messages?.first(where: { $0.role == .user }) {
|
||||||
|
let attachmentSummary = SybilChatAttachmentSupport.attachmentSummary(firstUserMessage.attachments)
|
||||||
|
if !attachmentSummary.isEmpty {
|
||||||
|
return String(attachmentSummary.prefix(48))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return "New chat"
|
return "New chat"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import Observation
|
import Observation
|
||||||
|
import PhotosUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct SybilWorkspaceView: View {
|
struct SybilWorkspaceView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
@FocusState private var composerFocused: Bool
|
@FocusState private var composerFocused: Bool
|
||||||
|
@State private var isShowingAttachmentOptions = false
|
||||||
|
@State private var isShowingFileImporter = false
|
||||||
|
@State private var isShowingPhotoPicker = false
|
||||||
|
@State private var photoPickerItems: [PhotosPickerItem] = []
|
||||||
|
@State private var isComposerDropTargeted = false
|
||||||
|
|
||||||
private var isSettingsSelected: Bool {
|
private var isSettingsSelected: Bool {
|
||||||
if case .settings = viewModel.selectedItem {
|
if case .settings = viewModel.selectedItem {
|
||||||
@@ -148,54 +156,87 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var composerBar: some View {
|
private var composerBar: some View {
|
||||||
HStack(alignment: .bottom, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
TextField(
|
if !viewModel.isSearchMode && !viewModel.composerAttachments.isEmpty {
|
||||||
viewModel.isSearchMode ? "Search the web" : "Message Sybil",
|
SybilAttachmentListView(
|
||||||
text: $viewModel.composer,
|
attachments: viewModel.composerAttachments,
|
||||||
axis: .vertical
|
tone: .composer
|
||||||
)
|
) { attachmentID in
|
||||||
.focused($composerFocused)
|
viewModel.removeComposerAttachment(id: attachmentID)
|
||||||
.textInputAutocapitalization(.sentences)
|
|
||||||
.autocorrectionDisabled(false)
|
|
||||||
.lineLimit(1 ... 6)
|
|
||||||
.submitLabel(.send)
|
|
||||||
.onSubmit {
|
|
||||||
Task {
|
|
||||||
await viewModel.sendComposer()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(SybilTheme.composerGradient)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.stroke(SybilTheme.primary.opacity(0.34), lineWidth: 1)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.foregroundStyle(SybilTheme.text)
|
|
||||||
|
|
||||||
Button {
|
HStack(alignment: .bottom, spacing: 10) {
|
||||||
Task {
|
if !viewModel.isSearchMode {
|
||||||
await viewModel.sendComposer()
|
Button {
|
||||||
}
|
isShowingAttachmentOptions = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
|
Image(systemName: "paperclip")
|
||||||
.font(.system(size: 17, weight: .semibold))
|
.font(.system(size: 17, weight: .semibold))
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: 40, height: 40)
|
||||||
.background(
|
.background(
|
||||||
Circle()
|
Circle()
|
||||||
.fill(
|
.fill(SybilTheme.surface)
|
||||||
viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending
|
|
||||||
? AnyShapeStyle(SybilTheme.surface)
|
|
||||||
: AnyShapeStyle(SybilTheme.primaryGradient)
|
|
||||||
)
|
)
|
||||||
)
|
.overlay(
|
||||||
.foregroundStyle(viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending ? SybilTheme.textMuted : SybilTheme.text)
|
Circle()
|
||||||
|
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.foregroundStyle(viewModel.isSending ? SybilTheme.textMuted : SybilTheme.text)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(viewModel.isSending)
|
||||||
|
.accessibilityLabel("Attach file")
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
viewModel.isSearchMode ? "Search the web" : "Message Sybil",
|
||||||
|
text: $viewModel.composer,
|
||||||
|
axis: .vertical
|
||||||
|
)
|
||||||
|
.focused($composerFocused)
|
||||||
|
.textInputAutocapitalization(.sentences)
|
||||||
|
.autocorrectionDisabled(false)
|
||||||
|
.lineLimit(1 ... 6)
|
||||||
|
.submitLabel(.send)
|
||||||
|
.onSubmit {
|
||||||
|
Task {
|
||||||
|
await viewModel.sendComposer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(SybilTheme.composerGradient)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(SybilTheme.primary.opacity(0.34), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await viewModel.sendComposer()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
viewModel.canSendComposer
|
||||||
|
? AnyShapeStyle(SybilTheme.primaryGradient)
|
||||||
|
: AnyShapeStyle(SybilTheme.surface)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.foregroundStyle(viewModel.canSendComposer ? SybilTheme.text : SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!viewModel.canSendComposer)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
@@ -209,5 +250,151 @@ struct SybilWorkspaceView: View {
|
|||||||
endPoint: .bottom
|
endPoint: .bottom
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.overlay {
|
||||||
|
if isComposerDropTargeted && !viewModel.isSearchMode {
|
||||||
|
RoundedRectangle(cornerRadius: 18)
|
||||||
|
.stroke(SybilTheme.accent.opacity(0.78), style: StrokeStyle(lineWidth: 1.5, dash: [7, 5]))
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDrop(of: [UTType.fileURL.identifier, UTType.image.identifier], isTargeted: $isComposerDropTargeted) { providers in
|
||||||
|
if viewModel.isSearchMode || viewModel.isSending {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await importAttachmentsFromItemProviders(providers)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
.confirmationDialog("Add attachment", isPresented: $isShowingAttachmentOptions, titleVisibility: .visible) {
|
||||||
|
Button("Photo Library") {
|
||||||
|
isShowingPhotoPicker = true
|
||||||
|
}
|
||||||
|
Button("Files") {
|
||||||
|
isShowingFileImporter = true
|
||||||
|
}
|
||||||
|
if canPasteFromClipboard {
|
||||||
|
Button("Paste from Clipboard") {
|
||||||
|
Task {
|
||||||
|
await pasteAttachmentsFromClipboard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
}
|
||||||
|
.photosPicker(
|
||||||
|
isPresented: $isShowingPhotoPicker,
|
||||||
|
selection: $photoPickerItems,
|
||||||
|
maxSelectionCount: max(1, SybilChatAttachmentSupport.maxAttachmentsPerMessage - viewModel.composerAttachments.count),
|
||||||
|
matching: .images
|
||||||
|
)
|
||||||
|
.fileImporter(
|
||||||
|
isPresented: $isShowingFileImporter,
|
||||||
|
allowedContentTypes: [.item],
|
||||||
|
allowsMultipleSelection: true
|
||||||
|
) { result in
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let urls = try result.get()
|
||||||
|
let attachments = try SybilChatAttachmentSupport.buildAttachments(from: urls)
|
||||||
|
try await MainActor.run {
|
||||||
|
try viewModel.appendComposerAttachments(attachments)
|
||||||
|
}
|
||||||
|
composerFocused = true
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
viewModel.errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
SybilLog.error(SybilLog.ui, "File import failed", error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: photoPickerItems) { _, items in
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let attachments = try await loadAttachmentsFromPhotoPickerItems(items)
|
||||||
|
try await MainActor.run {
|
||||||
|
try viewModel.appendComposerAttachments(attachments)
|
||||||
|
photoPickerItems = []
|
||||||
|
}
|
||||||
|
composerFocused = true
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
viewModel.errorMessage = error.localizedDescription
|
||||||
|
photoPickerItems = []
|
||||||
|
}
|
||||||
|
SybilLog.error(SybilLog.ui, "Photo import failed", error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canPasteFromClipboard: Bool {
|
||||||
|
let pasteboard = UIPasteboard.general
|
||||||
|
if pasteboard.hasImages {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if let url = pasteboard.url, url.isFileURL {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if let string = pasteboard.string, !string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func importAttachmentsFromItemProviders(_ providers: [NSItemProvider]) async {
|
||||||
|
do {
|
||||||
|
let attachments = try await SybilChatAttachmentSupport.buildAttachments(from: providers)
|
||||||
|
try viewModel.appendComposerAttachments(attachments)
|
||||||
|
composerFocused = true
|
||||||
|
} catch {
|
||||||
|
viewModel.errorMessage = error.localizedDescription
|
||||||
|
SybilLog.error(SybilLog.ui, "Clipboard/drop attachment import failed", error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadAttachmentsFromPhotoPickerItems(_ items: [PhotosPickerItem]) async throws -> [ChatAttachment] {
|
||||||
|
var attachments: [ChatAttachment] = []
|
||||||
|
|
||||||
|
for item in items {
|
||||||
|
guard let data = try await item.loadTransferable(type: Data.self) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentType = item.supportedContentTypes.first(where: { $0.conforms(to: .image) })
|
||||||
|
let filename = contentType?.preferredFilenameExtension.map { "photo.\($0)" } ?? "photo.jpg"
|
||||||
|
attachments.append(try SybilChatAttachmentSupport.buildImageAttachment(data: data, filename: filename, contentType: contentType))
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func pasteAttachmentsFromClipboard() async {
|
||||||
|
do {
|
||||||
|
let pasteboard = UIPasteboard.general
|
||||||
|
var attachments: [ChatAttachment] = []
|
||||||
|
|
||||||
|
if let image = pasteboard.image {
|
||||||
|
attachments.append(try SybilChatAttachmentSupport.buildImageAttachment(image: image))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let url = pasteboard.url, url.isFileURL {
|
||||||
|
attachments.append(contentsOf: try SybilChatAttachmentSupport.buildAttachments(from: [url]))
|
||||||
|
} else if let text = pasteboard.string?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
|
||||||
|
attachments.append(try SybilChatAttachmentSupport.buildTextAttachment(text: text))
|
||||||
|
}
|
||||||
|
|
||||||
|
try viewModel.appendComposerAttachments(attachments)
|
||||||
|
composerFocused = true
|
||||||
|
} catch {
|
||||||
|
viewModel.errorMessage = error.localizedDescription
|
||||||
|
SybilLog.error(SybilLog.ui, "Clipboard attachment import failed", error: error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user