Files
oai-swift/oAI/Views/Main/MessageRow.swift

342 lines
12 KiB
Swift

//
// 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 {
// Compact layout for system messages (tool calls)
if message.role == .system && !isErrorMessage {
compactSystemMessage
} else {
standardMessageLayout
}
}
@ViewBuilder
private var standardMessageLayout: 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/time info
if message.role == .assistant && (message.tokens != nil || message.cost != nil || message.responseTime != nil || message.wasInterrupted) {
HStack(spacing: 8) {
if let tokens = message.tokens {
Label("\(tokens)", systemImage: "chart.bar.xaxis")
if message.cost != nil || message.responseTime != nil || message.wasInterrupted {
Text("\u{2022}")
}
}
if let cost = message.cost {
Text(String(format: "$%.4f", cost))
if message.responseTime != nil || message.wasInterrupted {
Text("\u{2022}")
}
}
if let responseTime = message.responseTime {
Text(String(format: "%.1fs", responseTime))
if message.wasInterrupted {
Text("\u{2022}")
}
}
if message.wasInterrupted {
Text("⚠️ interrupted")
.foregroundColor(.orange)
}
}
.font(.caption2)
.foregroundColor(.oaiSecondary)
}
}
}
.padding(16)
.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
}
// Close standardMessageLayout - the above closing braces close it
// The body: some View now handles the split between compact and standard
// MARK: - Compact System Message
@ViewBuilder
private var compactSystemMessage: some View {
HStack(spacing: 8) {
Image(systemName: "wrench.and.screwdriver")
.font(.system(size: 11))
.foregroundColor(.secondary)
Text(message.content)
.font(.system(size: 11))
.foregroundColor(.secondary)
Spacer()
Text(message.timestamp, style: .time)
.font(.system(size: 10))
.foregroundColor(.secondary.opacity(0.7))
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.secondary.opacity(0.08))
.cornerRadius(6)
}
// 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:
// User messages: preserve line breaks as-is (plain text, not markdown)
Text(message.content)
.font(.system(size: settings.dialogTextSize))
.foregroundColor(.oaiPrimary)
.lineSpacing(4)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
}
// 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)
}