Initial commit

This commit is contained in:
2026-02-11 22:22:55 +01:00
commit 42f54954c1
58 changed files with 10639 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
//
// ChatView.swift
// oAI
//
// Main chat interface
//
import SwiftUI
struct ChatView: View {
@Environment(ChatViewModel.self) var viewModel
let onModelSelect: () -> Void
let onProviderChange: (Settings.Provider) -> Void
var body: some View {
@Bindable var viewModel = viewModel
VStack(spacing: 0) {
// Header
HeaderView(
provider: viewModel.currentProvider,
model: viewModel.selectedModel,
stats: viewModel.sessionStats,
onlineMode: viewModel.onlineMode,
mcpEnabled: viewModel.mcpEnabled,
mcpStatus: viewModel.mcpStatus,
onModelSelect: onModelSelect,
onProviderChange: onProviderChange
)
// Messages
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(viewModel.messages) { message in
MessageRow(message: message)
.id(message.id)
}
// Invisible bottom anchor for auto-scroll
Color.clear
.frame(height: 1)
.id("bottom")
}
.padding()
}
.background(Color.oaiBackground)
.onChange(of: viewModel.messages.count) {
withAnimation {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
.onChange(of: viewModel.messages.last?.content) {
// Auto-scroll as streaming content arrives
if viewModel.isGenerating {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
}
// Input bar
InputBar(
text: $viewModel.inputText,
isGenerating: viewModel.isGenerating,
mcpStatus: viewModel.mcpStatus,
onlineMode: viewModel.onlineMode,
onSend: viewModel.sendMessage,
onCancel: viewModel.cancelGeneration
)
// Footer
FooterView(stats: viewModel.sessionStats)
}
.background(Color.oaiBackground)
}
}
#Preview {
ChatView(onModelSelect: {}, onProviderChange: { _ in })
.environment(ChatViewModel())
}

View File

@@ -0,0 +1,150 @@
//
// ContentView.swift
// oAI
//
// Root navigation container
//
import SwiftUI
struct ContentView: View {
@Environment(ChatViewModel.self) var chatViewModel
var body: some View {
@Bindable var vm = chatViewModel
NavigationStack {
ChatView(
onModelSelect: { chatViewModel.showModelSelector = true },
onProviderChange: { newProvider in
chatViewModel.changeProvider(newProvider)
}
)
.navigationTitle("")
.toolbar {
#if os(macOS)
macOSToolbar
#endif
}
}
.frame(minWidth: 600, minHeight: 400)
#if os(macOS)
.onKeyPress(.return, phases: .down) { press in
if press.modifiers.contains(.command) {
chatViewModel.sendMessage()
return .handled
}
return .ignored
}
#endif
.sheet(isPresented: $vm.showModelSelector) {
ModelSelectorView(
models: chatViewModel.availableModels,
selectedModel: chatViewModel.selectedModel,
onSelect: { model in
chatViewModel.selectedModel = model
chatViewModel.showModelSelector = false
}
)
.task {
if chatViewModel.availableModels.count <= 10 {
await chatViewModel.loadAvailableModels()
}
}
}
.sheet(isPresented: $vm.showSettings, onDismiss: {
chatViewModel.syncFromSettings()
}) {
SettingsView()
}
.sheet(isPresented: $vm.showStats) {
StatsView(
stats: chatViewModel.sessionStats,
model: chatViewModel.selectedModel,
provider: chatViewModel.currentProvider
)
}
.sheet(isPresented: $vm.showHelp) {
HelpView()
}
.sheet(isPresented: $vm.showCredits) {
CreditsView(provider: chatViewModel.currentProvider)
}
.sheet(isPresented: $vm.showConversations) {
ConversationListView(onLoad: { conversation in
chatViewModel.loadConversation(conversation)
})
}
.sheet(item: $vm.modelInfoTarget) { model in
ModelInfoView(model: model)
}
}
#if os(macOS)
@ToolbarContentBuilder
private var macOSToolbar: some ToolbarContent {
ToolbarItemGroup(placement: .automatic) {
// New conversation
Button(action: { chatViewModel.newConversation() }) {
Label("New Chat", systemImage: "square.and.pencil")
}
.keyboardShortcut("n", modifiers: .command)
.help("New conversation")
Button(action: { chatViewModel.showConversations = true }) {
Label("History", systemImage: "clock.arrow.circlepath")
}
.keyboardShortcut("l", modifiers: .command)
.help("Saved conversations (Cmd+L)")
Spacer()
Button(action: { chatViewModel.showModelSelector = true }) {
Label("Model", systemImage: "cpu")
}
.keyboardShortcut("m", modifiers: .command)
.help("Select AI model (Cmd+M)")
Button(action: {
if let model = chatViewModel.selectedModel {
chatViewModel.modelInfoTarget = model
}
}) {
Label("Model Info", systemImage: "info.circle")
}
.keyboardShortcut("i", modifiers: .command)
.help("Model info (Cmd+I)")
.disabled(chatViewModel.selectedModel == nil)
Button(action: { chatViewModel.showStats = true }) {
Label("Stats", systemImage: "chart.bar")
}
.keyboardShortcut("s", modifiers: .command)
.help("Session statistics (Cmd+S)")
Button(action: { chatViewModel.showCredits = true }) {
Label("Credits", systemImage: "creditcard")
}
.help("Check API credits")
Spacer()
Button(action: { chatViewModel.showSettings = true }) {
Label("Settings", systemImage: "gearshape")
}
.keyboardShortcut(",", modifiers: .command)
.help("Settings (Cmd+,)")
Button(action: { chatViewModel.showHelp = true }) {
Label("Help", systemImage: "questionmark.circle")
}
.keyboardShortcut("/", modifiers: .command)
.help("Help & commands (Cmd+/)")
}
}
#endif
}
#Preview {
ContentView()
.environment(ChatViewModel())
}

View File

@@ -0,0 +1,91 @@
//
// FooterView.swift
// oAI
//
// Footer bar with session summary
//
import SwiftUI
struct FooterView: View {
let stats: SessionStats
var body: some View {
HStack(spacing: 20) {
// Session summary
HStack(spacing: 16) {
FooterItem(
icon: "message",
label: "Messages",
value: "\(stats.messageCount)"
)
FooterItem(
icon: "chart.bar.xaxis",
label: "Tokens",
value: "\(stats.totalTokens) (\(stats.totalInputTokens) in, \(stats.totalOutputTokens) out)"
)
FooterItem(
icon: "dollarsign.circle",
label: "Cost",
value: stats.totalCostDisplay
)
}
Spacer()
// Shortcuts hint
#if os(macOS)
Text("⌘M Model • ⌘K Clear • ⌘S Stats")
.font(.caption2)
.foregroundColor(.oaiSecondary)
#endif
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.ultraThinMaterial)
.overlay(
Rectangle()
.fill(Color.oaiBorder.opacity(0.5))
.frame(height: 1),
alignment: .top
)
}
}
struct FooterItem: View {
let icon: String
let label: String
let value: String
private let guiSize = SettingsService.shared.guiTextSize
var body: some View {
HStack(spacing: 6) {
Image(systemName: icon)
.font(.system(size: guiSize - 2))
.foregroundColor(.oaiSecondary)
Text(label + ":")
.font(.system(size: guiSize - 2))
.foregroundColor(.oaiSecondary)
Text(value)
.font(.system(size: guiSize - 2, weight: .medium))
.foregroundColor(.oaiPrimary)
}
}
}
#Preview {
VStack {
Spacer()
FooterView(stats: SessionStats(
totalInputTokens: 1250,
totalOutputTokens: 3420,
totalCost: 0.0152,
messageCount: 12
))
}
.background(Color.oaiBackground)
}

View File

@@ -0,0 +1,209 @@
//
// HeaderView.swift
// oAI
//
// Header bar with provider, model, and stats
//
import SwiftUI
struct HeaderView: View {
let provider: Settings.Provider
let model: ModelInfo?
let stats: SessionStats
let onlineMode: Bool
let mcpEnabled: Bool
let mcpStatus: String?
let onModelSelect: () -> Void
let onProviderChange: (Settings.Provider) -> Void
private let settings = SettingsService.shared
private let registry = ProviderRegistry.shared
var body: some View {
HStack(spacing: 12) {
// Provider picker dropdown only shows configured providers
Menu {
ForEach(registry.configuredProviders, id: \.self) { p in
Button {
onProviderChange(p)
} label: {
HStack {
Image(systemName: p.iconName)
Text(p.displayName)
if p == provider {
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: provider.iconName)
.font(.system(size: settings.guiTextSize - 2))
Text(provider.displayName)
.font(.system(size: settings.guiTextSize - 2, weight: .medium))
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 8))
.opacity(0.7)
}
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.providerColor(provider))
.cornerRadius(4)
}
.menuStyle(.borderlessButton)
.fixedSize()
.help("Switch provider")
// Model info (clickable model selector)
Button(action: onModelSelect) {
if let model = model {
HStack(spacing: 6) {
Text(model.name)
.font(.system(size: settings.guiTextSize, weight: .medium))
.foregroundColor(.oaiPrimary)
// Capability badges
HStack(spacing: 3) {
if model.capabilities.vision {
Image(systemName: "eye")
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
}
if model.capabilities.tools {
Image(systemName: "wrench")
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
}
if model.capabilities.online {
Image(systemName: "globe")
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
}
if model.capabilities.imageGeneration {
Image(systemName: "paintbrush")
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
}
}
Image(systemName: "chevron.down")
.font(.caption2)
.foregroundColor(.oaiSecondary)
}
} else {
HStack(spacing: 4) {
Text("No model selected")
.font(.system(size: settings.guiTextSize))
.foregroundColor(.oaiSecondary)
Image(systemName: "chevron.down")
.font(.caption2)
.foregroundColor(.oaiSecondary)
}
}
}
.buttonStyle(.plain)
.help("Select model")
Spacer()
// Status indicators
HStack(spacing: 8) {
if model?.capabilities.imageGeneration == true {
StatusPill(icon: "paintbrush", label: "Image", color: .purple)
}
if onlineMode {
StatusPill(icon: "globe", label: "Online", color: .green)
}
if mcpEnabled {
StatusPill(icon: "folder", label: "MCP", color: .blue)
}
}
// Divider between status and stats
if onlineMode || mcpEnabled || model?.capabilities.imageGeneration == true {
Divider()
.frame(height: 16)
.opacity(0.5)
}
// Quick stats
HStack(spacing: 16) {
StatItem(icon: "message", value: "\(stats.messageCount)")
StatItem(icon: "arrow.up.arrow.down", value: stats.totalTokensDisplay)
StatItem(icon: "dollarsign", value: stats.totalCostDisplay)
}
.font(.caption)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.ultraThinMaterial)
.overlay(
Rectangle()
.fill(Color.oaiBorder.opacity(0.5))
.frame(height: 1),
alignment: .bottom
)
}
}
struct StatItem: View {
let icon: String
let value: String
private let settings = SettingsService.shared
var body: some View {
HStack(spacing: 4) {
Image(systemName: icon)
.font(.system(size: settings.guiTextSize - 3))
.foregroundColor(.oaiSecondary)
Text(value)
.font(.system(size: settings.guiTextSize - 1, weight: .medium))
.foregroundColor(.oaiPrimary)
}
}
}
struct StatusPill: View {
let icon: String
let label: String
let color: Color
var body: some View {
HStack(spacing: 3) {
Circle()
.fill(color)
.frame(width: 6, height: 6)
Text(label)
.font(.system(size: 10, weight: .medium))
.foregroundColor(.oaiSecondary)
}
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(color.opacity(0.1), in: Capsule())
}
}
#Preview {
VStack {
HeaderView(
provider: .openrouter,
model: ModelInfo.mockModels.first,
stats: SessionStats(
totalInputTokens: 125,
totalOutputTokens: 434,
totalCost: 0.00111,
messageCount: 4
),
onlineMode: true,
mcpEnabled: true,
mcpStatus: "MCP",
onModelSelect: {},
onProviderChange: { _ in }
)
Spacer()
}
.background(Color.oaiBackground)
}

View File

@@ -0,0 +1,327 @@
//
// InputBar.swift
// oAI
//
// Message input bar with status indicators
//
import SwiftUI
struct InputBar: View {
@Binding var text: String
let isGenerating: Bool
let mcpStatus: String?
let onlineMode: Bool
let onSend: () -> Void
let onCancel: () -> Void
private let settings = SettingsService.shared
@State private var showCommandDropdown = false
@State private var selectedSuggestionIndex: Int = 0
@FocusState private var isInputFocused: Bool
/// Commands that execute immediately without additional arguments
private static let immediateCommands: Set<String> = [
"/help", "/model", "/clear", "/retry", "/stats", "/config",
"/settings", "/credits", "/list", "/load",
"/memory on", "/memory off", "/online on", "/online off",
"/mcp on", "/mcp off", "/mcp status", "/mcp list",
"/mcp write on", "/mcp write off",
"/export md", "/export json",
]
var body: some View {
VStack(spacing: 0) {
// Command dropdown (if showing)
if showCommandDropdown && text.hasPrefix("/") {
CommandSuggestionsView(
searchText: text,
selectedIndex: selectedSuggestionIndex,
onSelect: { command in
selectCommand(command)
}
)
.frame(maxHeight: 200)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
// Input area
HStack(alignment: .bottom, spacing: 12) {
// Status indicators
HStack(spacing: 6) {
if let mcp = mcpStatus {
StatusBadge(text: mcp, color: .blue)
}
if onlineMode {
StatusBadge(text: "🌐", color: .green)
}
}
.frame(width: 80, alignment: .leading)
// Text input
ZStack(alignment: .topLeading) {
if text.isEmpty {
Text("Type a message or / for commands...")
.font(.system(size: settings.inputTextSize))
.foregroundColor(.oaiSecondary)
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
TextEditor(text: $text)
.font(.system(size: settings.inputTextSize))
.foregroundColor(.oaiPrimary)
.scrollContentBackground(.hidden)
.frame(minHeight: 44, maxHeight: 120)
.padding(.horizontal, 8)
.padding(.vertical, 6)
.focused($isInputFocused)
.onChange(of: text) {
showCommandDropdown = text.hasPrefix("/")
selectedSuggestionIndex = 0
}
#if os(macOS)
.onKeyPress(.upArrow) {
guard showCommandDropdown else { return .ignored }
if selectedSuggestionIndex > 0 {
selectedSuggestionIndex -= 1
}
return .handled
}
.onKeyPress(.downArrow) {
guard showCommandDropdown else { return .ignored }
let count = CommandSuggestionsView.filteredCommands(for: text).count
if selectedSuggestionIndex < count - 1 {
selectedSuggestionIndex += 1
}
return .handled
}
.onKeyPress(.escape) {
if showCommandDropdown {
showCommandDropdown = false
return .handled
}
return .ignored
}
.onKeyPress(.return) {
// If command dropdown is showing, select the highlighted command
if showCommandDropdown {
let suggestions = CommandSuggestionsView.filteredCommands(for: text)
if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count {
selectCommand(suggestions[selectedSuggestionIndex].command)
return .handled
}
}
// Plain Return on single line: send
if !text.contains("\n") && !text.isEmpty {
onSend()
return .handled
}
// Otherwise: let system handle (insert newline)
return .ignored
}
#endif
}
.background(Color.oaiSurface)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1)
)
// Action buttons
VStack(spacing: 8) {
#if os(macOS)
// File attach button
Button(action: pickFile) {
Image(systemName: "paperclip")
.font(.title2)
.foregroundColor(.oaiPrimary.opacity(0.7))
}
.buttonStyle(.plain)
.help("Attach file")
#endif
if isGenerating {
Button(action: onCancel) {
Image(systemName: "stop.circle.fill")
.font(.title)
.foregroundColor(.oaiError.opacity(0.9))
}
.buttonStyle(.plain)
.help("Stop generation")
} else {
Button(action: onSend) {
Image(systemName: "arrow.up.circle.fill")
.font(.title)
.foregroundColor(text.isEmpty ? .oaiPrimary.opacity(0.4) : .oaiAccent.opacity(0.9))
}
.buttonStyle(.plain)
.disabled(text.isEmpty)
.help("Send message")
}
}
.frame(width: 40)
}
.padding()
.background(Color.oaiSurface)
}
.onAppear {
isInputFocused = true
}
}
private func selectCommand(_ command: String) {
showCommandDropdown = false
if Self.immediateCommands.contains(command) {
// Execute immediately
text = command
onSend()
} else {
// Put in input for user to complete
text = command + " "
}
}
#if os(macOS)
private func pickFile() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.message = "Select files to attach"
guard panel.runModal() == .OK else { return }
let paths = panel.urls.map { $0.path }
let attachmentText = paths.map { "@\($0)" }.joined(separator: " ")
if text.isEmpty {
text = attachmentText + " "
} else {
text += " " + attachmentText
}
}
#endif
}
struct StatusBadge: View {
let text: String
let color: Color
var body: some View {
Text(text)
.font(.caption)
.foregroundColor(color)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(color.opacity(0.15))
.cornerRadius(4)
}
}
struct CommandSuggestionsView: View {
let searchText: String
let selectedIndex: Int
let onSelect: (String) -> Void
static let allCommands: [(command: String, description: String)] = [
("/help", "Show help and available commands"),
("/model", "Select AI model"),
("/clear", "Clear chat history"),
("/retry", "Retry last message"),
("/memory on", "Enable conversation memory"),
("/memory off", "Disable conversation memory"),
("/online on", "Enable web search"),
("/online off", "Disable web search"),
("/stats", "Show session statistics"),
("/config", "Open settings"),
("/provider", "Switch AI provider"),
("/save", "Save conversation"),
("/load", "Load conversation"),
("/list", "List saved conversations"),
("/export md", "Export as Markdown"),
("/export json", "Export as JSON"),
("/info", "Show model information"),
("/credits", "Check account credits"),
("/mcp on", "Enable MCP (file access)"),
("/mcp off", "Disable MCP"),
("/mcp status", "Show MCP status"),
("/mcp list", "List MCP folders"),
("/mcp add", "Add folder for MCP"),
("/mcp write on", "Enable MCP write permissions"),
("/mcp write off", "Disable MCP write permissions"),
]
static func filteredCommands(for searchText: String) -> [(command: String, description: String)] {
let search = searchText.lowercased()
return allCommands.filter { $0.command.contains(search) || search == "/" }
}
private var suggestions: [(command: String, description: String)] {
Self.filteredCommands(for: searchText)
}
var body: some View {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 0) {
ForEach(Array(suggestions.enumerated()), id: \.element.command) { index, suggestion in
Button(action: { onSelect(suggestion.command) }) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(suggestion.command)
.font(.body)
.foregroundColor(.oaiPrimary)
Text(suggestion.description)
.font(.caption)
.foregroundColor(.oaiSecondary)
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(index == selectedIndex ? Color.oaiAccent.opacity(0.2) : Color.oaiSurface)
}
.buttonStyle(.plain)
.id(suggestion.command)
if index < suggestions.count - 1 {
Divider()
.background(Color.oaiBorder)
}
}
}
}
.onChange(of: selectedIndex) {
if selectedIndex < suggestions.count {
withAnimation {
proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center)
}
}
}
}
.background(Color.oaiSurface)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.oaiBorder, lineWidth: 1)
)
.padding(.horizontal)
}
}
#Preview {
VStack {
Spacer()
InputBar(
text: .constant(""),
isGenerating: false,
mcpStatus: "📁 Files",
onlineMode: true,
onSend: {},
onCancel: {}
)
}
.background(Color.oaiBackground)
}

View File

@@ -0,0 +1,183 @@
//
// MarkdownContentView.swift
// oAI
//
// Renders markdown content with syntax-highlighted code blocks
//
import SwiftUI
#if canImport(AppKit)
import AppKit
#endif
struct MarkdownContentView: View {
let content: String
let fontSize: Double
var body: some View {
let segments = parseSegments(content)
VStack(alignment: .leading, spacing: 8) {
ForEach(segments.indices, id: \.self) { index in
switch segments[index] {
case .text(let text):
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
markdownText(text)
}
case .codeBlock(let language, let code):
CodeBlockView(language: language, code: code, fontSize: fontSize)
}
}
}
}
@ViewBuilder
private func markdownText(_ text: String) -> some View {
if let attrString = try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
Text(attrString)
.font(.system(size: fontSize))
.foregroundColor(.oaiPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
} else {
Text(text)
.font(.system(size: fontSize))
.foregroundColor(.oaiPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
}
// MARK: - Parsing
enum Segment {
case text(String)
case codeBlock(language: String?, code: String)
}
private func parseSegments(_ content: String) -> [Segment] {
var segments: [Segment] = []
let lines = content.components(separatedBy: "\n")
var currentText = ""
var inCodeBlock = false
var codeLanguage: String? = nil
var codeContent = ""
for line in lines {
if !inCodeBlock && line.hasPrefix("```") {
// Start of code block
if !currentText.isEmpty {
segments.append(.text(currentText))
currentText = ""
}
inCodeBlock = true
let lang = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
codeLanguage = lang.isEmpty ? nil : lang
codeContent = ""
} else if inCodeBlock && line.hasPrefix("```") {
// End of code block
// Remove trailing newline from code
if codeContent.hasSuffix("\n") {
codeContent = String(codeContent.dropLast())
}
segments.append(.codeBlock(language: codeLanguage, code: codeContent))
inCodeBlock = false
codeLanguage = nil
codeContent = ""
} else if inCodeBlock {
codeContent += line + "\n"
} else {
currentText += line + "\n"
}
}
// Handle unclosed code block
if inCodeBlock {
if codeContent.hasSuffix("\n") {
codeContent = String(codeContent.dropLast())
}
segments.append(.codeBlock(language: codeLanguage, code: codeContent))
}
// Remaining text
if !currentText.isEmpty {
// Remove trailing newline
if currentText.hasSuffix("\n") {
currentText = String(currentText.dropLast())
}
if !currentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
segments.append(.text(currentText))
}
}
return segments
}
}
// MARK: - Code Block View
struct CodeBlockView: View {
let language: String?
let code: String
let fontSize: Double
@State private var copied = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header bar with language label and copy button
HStack {
if let lang = language, !lang.isEmpty {
Text(lang)
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(Color(hex: "#888888"))
}
Spacer()
Button(action: copyCode) {
HStack(spacing: 4) {
Image(systemName: copied ? "checkmark" : "doc.on.doc")
.font(.system(size: 11))
if copied {
Text("Copied!")
.font(.system(size: 11))
}
}
.foregroundColor(copied ? .green : Color(hex: "#888888"))
}
.buttonStyle(.plain)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(hex: "#2d2d2d"))
// Code content
ScrollView(.horizontal, showsIndicators: true) {
Text(SyntaxHighlighter.highlight(code: code, language: language))
.font(.system(size: fontSize - 1, design: .monospaced))
.textSelection(.enabled)
.padding(12)
}
}
.background(Color(hex: "#1e1e1e"))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color(hex: "#3e3e3e"), lineWidth: 1)
)
}
private func copyCode() {
#if os(macOS)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(code, forType: .string)
#endif
withAnimation {
copied = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation {
copied = false
}
}
}
}

View 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)
}