375 lines
13 KiB
Swift
375 lines
13 KiB
Swift
//
|
|
// MessageRow.swift
|
|
// oAI
|
|
//
|
|
// Individual message display
|
|
//
|
|
|
|
import SwiftUI
|
|
#if canImport(AppKit)
|
|
import AppKit
|
|
#endif
|
|
|
|
struct MessageRow: View {
|
|
let message: Message
|
|
let viewModel: ChatViewModel?
|
|
private let settings = SettingsService.shared
|
|
|
|
#if os(macOS)
|
|
@State private var isHovering = false
|
|
@State private var showCopied = false
|
|
@State private var isStarred = false
|
|
#endif
|
|
|
|
init(message: Message, viewModel: ChatViewModel? = nil) {
|
|
self.message = message
|
|
self.viewModel = viewModel
|
|
}
|
|
|
|
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)
|
|
// Star button (user/assistant messages only, visible on hover)
|
|
if (message.role == .user || message.role == .assistant) && isHovering {
|
|
Button(action: toggleStar) {
|
|
Image(systemName: isStarred ? "star.fill" : "star")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(isStarred ? .yellow : .oaiSecondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.transition(.opacity)
|
|
.help("Star this message to always include it in context")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
.onAppear {
|
|
loadStarredState()
|
|
}
|
|
#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
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadStarredState() {
|
|
if let metadata = try? DatabaseService.shared.getMessageMetadata(messageId: message.id) {
|
|
isStarred = metadata.user_starred == 1
|
|
}
|
|
}
|
|
|
|
private func toggleStar() {
|
|
viewModel?.toggleMessageStar(messageId: message.id)
|
|
isStarred.toggle()
|
|
}
|
|
#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)
|
|
}
|