Initial commit
This commit is contained in:
278
oAI/Views/Main/MessageRow.swift
Normal file
278
oAI/Views/Main/MessageRow.swift
Normal file
@@ -0,0 +1,278 @@
|
||||
//
|
||||
// MessageRow.swift
|
||||
// oAI
|
||||
//
|
||||
// Individual message display
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
struct MessageRow: View {
|
||||
let message: Message
|
||||
private let settings = SettingsService.shared
|
||||
|
||||
#if os(macOS)
|
||||
@State private var isHovering = false
|
||||
@State private var showCopied = false
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
// Role icon
|
||||
roleIcon
|
||||
.frame(square: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Header
|
||||
HStack {
|
||||
Text(message.role.displayName)
|
||||
.font(.headline)
|
||||
.foregroundColor(Color.messageColor(for: message.role))
|
||||
|
||||
Spacer()
|
||||
|
||||
#if os(macOS)
|
||||
// Copy button (assistant messages only, visible on hover)
|
||||
if message.role == .assistant && isHovering && !message.content.isEmpty {
|
||||
Button(action: copyContent) {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: showCopied ? "checkmark" : "doc.on.doc")
|
||||
.font(.system(size: 11))
|
||||
if showCopied {
|
||||
Text("Copied!")
|
||||
.font(.system(size: 11))
|
||||
}
|
||||
}
|
||||
.foregroundColor(showCopied ? .green : .oaiSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.transition(.opacity)
|
||||
}
|
||||
#endif
|
||||
|
||||
Text(message.timestamp, style: .time)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.oaiSecondary)
|
||||
}
|
||||
|
||||
// Content
|
||||
if !message.content.isEmpty {
|
||||
messageContent
|
||||
}
|
||||
|
||||
// Generated images
|
||||
if let images = message.generatedImages, !images.isEmpty {
|
||||
GeneratedImagesView(images: images)
|
||||
}
|
||||
|
||||
// File attachments
|
||||
if let attachments = message.attachments, !attachments.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(attachments.indices, id: \.self) { index in
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "paperclip")
|
||||
.font(.caption)
|
||||
Text(attachments[index].path)
|
||||
.font(.caption)
|
||||
.foregroundColor(.oaiSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
// Token/cost info
|
||||
if let tokens = message.tokens, let cost = message.cost {
|
||||
HStack(spacing: 8) {
|
||||
Label("\(tokens)", systemImage: "chart.bar.xaxis")
|
||||
Text("\u{2022}")
|
||||
Text(String(format: "$%.4f", cost))
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundColor(.oaiSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.messageBackground(for: message.role))
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(messageBorderColor, lineWidth: isErrorMessage ? 2 : 2)
|
||||
)
|
||||
#if os(macOS)
|
||||
.onHover { hovering in
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
isHovering = hovering
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Message Content
|
||||
|
||||
@ViewBuilder
|
||||
private var messageContent: some View {
|
||||
switch message.role {
|
||||
case .assistant:
|
||||
MarkdownContentView(content: message.content, fontSize: settings.dialogTextSize)
|
||||
case .system:
|
||||
if isErrorMessage {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.oaiError)
|
||||
.font(.system(size: settings.dialogTextSize))
|
||||
Text(message.content)
|
||||
.font(.system(size: settings.dialogTextSize))
|
||||
.foregroundColor(.oaiPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
} else {
|
||||
Text(message.content)
|
||||
.font(.system(size: settings.dialogTextSize))
|
||||
.foregroundColor(.oaiPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
case .user:
|
||||
MarkdownContentView(content: message.content, fontSize: settings.dialogTextSize)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Detection
|
||||
|
||||
private var isErrorMessage: Bool {
|
||||
message.role == .system && message.content.hasPrefix("\u{274C}")
|
||||
}
|
||||
|
||||
private var messageBorderColor: Color {
|
||||
if isErrorMessage {
|
||||
return .oaiError.opacity(0.5)
|
||||
}
|
||||
return Color.messageColor(for: message.role).opacity(0.3)
|
||||
}
|
||||
|
||||
// MARK: - Copy
|
||||
|
||||
#if os(macOS)
|
||||
private func copyContent() {
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(message.content, forType: .string)
|
||||
withAnimation {
|
||||
showCopied = true
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
withAnimation {
|
||||
showCopied = false
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private var roleIcon: some View {
|
||||
Image(systemName: message.role.iconName)
|
||||
.font(.title3)
|
||||
.foregroundColor(Color.messageColor(for: message.role))
|
||||
.frame(width: 32, height: 32)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color.messageColor(for: message.role).opacity(0.15))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Generated Images Display
|
||||
|
||||
struct GeneratedImagesView: View {
|
||||
let images: [Data]
|
||||
@State private var savedMessage: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(images.indices, id: \.self) { index in
|
||||
if let nsImage = platformImage(from: images[index]) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
#if os(macOS)
|
||||
Image(nsImage: nsImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxWidth: 512, maxHeight: 512)
|
||||
.cornerRadius(8)
|
||||
.shadow(color: .black.opacity(0.3), radius: 4)
|
||||
.contextMenu {
|
||||
Button("Save to Downloads") {
|
||||
saveImage(data: images[index], index: index)
|
||||
}
|
||||
Button("Copy Image") {
|
||||
copyImage(nsImage)
|
||||
}
|
||||
}
|
||||
#else
|
||||
Image(uiImage: nsImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxWidth: 512, maxHeight: 512)
|
||||
.cornerRadius(8)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let msg = savedMessage {
|
||||
Text(msg)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.green)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func platformImage(from data: Data) -> NSImage? {
|
||||
NSImage(data: data)
|
||||
}
|
||||
|
||||
private func saveImage(data: Data, index: Int) {
|
||||
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.temporaryDirectory
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let filename = "oai_image_\(timestamp)_\(index).png"
|
||||
let fileURL = downloads.appendingPathComponent(filename)
|
||||
|
||||
do {
|
||||
try data.write(to: fileURL)
|
||||
withAnimation { savedMessage = "Saved to \(filename)" }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
withAnimation { savedMessage = nil }
|
||||
}
|
||||
} catch {
|
||||
withAnimation { savedMessage = "Failed to save: \(error.localizedDescription)" }
|
||||
}
|
||||
}
|
||||
|
||||
private func copyImage(_ image: NSImage) {
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.writeObjects([image])
|
||||
}
|
||||
#else
|
||||
private func platformImage(from data: Data) -> UIImage? {
|
||||
UIImage(data: data)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 12) {
|
||||
MessageRow(message: Message.mockUser1)
|
||||
MessageRow(message: Message.mockAssistant1)
|
||||
MessageRow(message: Message.mockSystem)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.oaiBackground)
|
||||
}
|
||||
Reference in New Issue
Block a user