Files
oai-swift/oAI/Views/Main/MessageRow.swift
2026-03-04 10:19:16 +01:00

577 lines
21 KiB
Swift

//
// MessageRow.swift
// oAI
//
// Individual message display
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
import SwiftUI
#if canImport(AppKit)
import AppKit
#endif
struct MessageRow: View {
let message: Message
let viewModel: ChatViewModel?
private let settings = SettingsService.shared
@State private var isExpanded = false
@State private var isThinkingExpanded = true // auto-expand while streaming, collapse after
#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 (user + assistant messages, visible on hover)
if (message.role == .assistant || message.role == .user) && 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)
}
// Thinking / reasoning block (collapsible)
if let thinking = message.thinkingContent, !thinking.isEmpty {
thinkingBlock(thinking)
.onChange(of: message.content) { _, newContent in
// Auto-collapse when response content starts arriving
if !newContent.isEmpty && isThinkingExpanded && !message.isStreaming {
withAnimation(.easeInOut(duration: 0.2)) {
isThinkingExpanded = false
}
}
}
.onChange(of: message.isStreaming) { _, streaming in
// Collapse when streaming finishes
if !streaming && !message.content.isEmpty {
withAnimation(.easeInOut(duration: 0.3)) {
isThinkingExpanded = false
}
}
}
}
// 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: - Thinking Block
@ViewBuilder
private func thinkingBlock(_ thinking: String) -> some View {
VStack(alignment: .leading, spacing: 0) {
// Header button
Button(action: {
withAnimation(.easeInOut(duration: 0.18)) { isThinkingExpanded.toggle() }
}) {
HStack(spacing: 6) {
if message.isStreaming && message.content.isEmpty {
ProgressView()
.scaleEffect(0.5)
.frame(width: 12, height: 12)
Text("Thinking…")
.font(.system(size: 11))
.foregroundStyle(.secondary)
} else {
Image(systemName: "brain")
.font(.system(size: 11))
.foregroundStyle(.secondary)
Text("Reasoning")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: isThinkingExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.secondary.opacity(0.5))
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if isThinkingExpanded {
Divider()
.padding(.horizontal, 6)
ScrollView {
Text(thinking)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
}
.frame(maxHeight: 220)
}
}
.background(Color.secondary.opacity(0.07))
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color.secondary.opacity(0.15), lineWidth: 1)
)
}
// MARK: - Compact System Message
@ViewBuilder
private var compactSystemMessage: some View {
let expandable = message.toolCalls != nil
VStack(alignment: .leading, spacing: 0) {
Button(action: {
if expandable {
withAnimation(.easeInOut(duration: 0.18)) { isExpanded.toggle() }
}
}) {
HStack(spacing: 8) {
Image(systemName: "wrench.and.screwdriver")
.font(.system(size: 11))
.foregroundColor(.secondary)
Text(message.content)
.font(.system(size: 11))
.foregroundColor(.secondary)
Spacer()
if expandable {
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 9, weight: .medium))
.foregroundColor(.secondary.opacity(0.5))
}
Text(message.timestamp, style: .time)
.font(.system(size: 10))
.foregroundColor(.secondary.opacity(0.7))
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if isExpanded, let calls = message.toolCalls {
Divider()
.padding(.horizontal, 8)
toolCallsDetailView(calls)
}
}
.background(Color.secondary.opacity(0.08))
.cornerRadius(6)
}
@ViewBuilder
private func toolCallsDetailView(_ calls: [ToolCallDetail]) -> some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(calls.indices, id: \.self) { i in
let call = calls[i]
VStack(alignment: .leading, spacing: 6) {
// Tool name + status
HStack(spacing: 6) {
Image(systemName: "function")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.secondary)
Text(call.name)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.secondary)
Spacer()
if call.result == nil {
ProgressView()
.scaleEffect(0.5)
.frame(width: 12, height: 12)
} else {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 10))
.foregroundColor(.green.opacity(0.8))
}
}
// Input
if !call.input.isEmpty && call.input != "{}" {
toolDetailSection(label: "Input", text: prettyJSON(call.input), maxHeight: 100)
}
// Result
if let result = call.result {
toolDetailSection(label: "Result", text: prettyJSON(result), maxHeight: 180)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
if i < calls.count - 1 {
Divider().padding(.horizontal, 12)
}
}
}
}
@ViewBuilder
private func toolDetailSection(label: String, text: String, maxHeight: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label.uppercased())
.font(.system(size: 9, weight: .semibold))
.foregroundColor(.secondary.opacity(0.6))
ScrollView([.vertical, .horizontal]) {
Text(text)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxHeight: maxHeight)
.background(Color.secondary.opacity(0.06))
.cornerRadius(4)
}
}
private func prettyJSON(_ raw: String) -> String {
guard let data = raw.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data),
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]),
let str = String(data: pretty, encoding: .utf8) else {
return raw
}
return str
}
// 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
} else {
// Unsaved message use in-memory flag on the Message struct
isStarred = message.isStarred
}
}
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)
}