Initial commit
This commit is contained in:
80
oAI/Views/Main/ChatView.swift
Normal file
80
oAI/Views/Main/ChatView.swift
Normal 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())
|
||||
}
|
||||
150
oAI/Views/Main/ContentView.swift
Normal file
150
oAI/Views/Main/ContentView.swift
Normal 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())
|
||||
}
|
||||
91
oAI/Views/Main/FooterView.swift
Normal file
91
oAI/Views/Main/FooterView.swift
Normal 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)
|
||||
}
|
||||
209
oAI/Views/Main/HeaderView.swift
Normal file
209
oAI/Views/Main/HeaderView.swift
Normal 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)
|
||||
}
|
||||
327
oAI/Views/Main/InputBar.swift
Normal file
327
oAI/Views/Main/InputBar.swift
Normal 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)
|
||||
}
|
||||
183
oAI/Views/Main/MarkdownContentView.swift
Normal file
183
oAI/Views/Main/MarkdownContentView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
72
oAI/Views/Screens/AboutView.swift
Normal file
72
oAI/Views/Screens/AboutView.swift
Normal file
@@ -0,0 +1,72 @@
|
||||
//
|
||||
// AboutView.swift
|
||||
// oAI
|
||||
//
|
||||
// About modal with app icon and version info
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AboutView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
private var appVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
|
||||
}
|
||||
|
||||
private var buildNumber: String {
|
||||
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Spacer().frame(height: 8)
|
||||
|
||||
Image("AppLogo")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 128, height: 128)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24))
|
||||
.shadow(color: .cyan.opacity(0.3), radius: 12)
|
||||
|
||||
Text("oAI")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
|
||||
Text("Version \(appVersion) (\(buildNumber))")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("Multi-provider AI chat client")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Divider()
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text("© 2026 [Rune Olsen](https://blog.rune.pm)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("Built with SwiftUI")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
Spacer().frame(height: 4)
|
||||
|
||||
Button("OK") { dismiss() }
|
||||
.keyboardShortcut(.return, modifiers: [])
|
||||
.keyboardShortcut(.escape, modifiers: [])
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.regular)
|
||||
|
||||
Spacer().frame(height: 20)
|
||||
}
|
||||
.frame(width: 320, height: 370)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AboutView()
|
||||
}
|
||||
189
oAI/Views/Screens/ConversationListView.swift
Normal file
189
oAI/Views/Screens/ConversationListView.swift
Normal file
@@ -0,0 +1,189 @@
|
||||
//
|
||||
// ConversationListView.swift
|
||||
// oAI
|
||||
//
|
||||
// Saved conversations list
|
||||
//
|
||||
|
||||
import os
|
||||
import SwiftUI
|
||||
|
||||
struct ConversationListView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var searchText = ""
|
||||
@State private var conversations: [Conversation] = []
|
||||
var onLoad: ((Conversation) -> Void)?
|
||||
|
||||
private var filteredConversations: [Conversation] {
|
||||
if searchText.isEmpty {
|
||||
return conversations
|
||||
}
|
||||
return conversations.filter {
|
||||
$0.name.lowercased().contains(searchText.lowercased())
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("Conversations")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
Spacer()
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut(.escape, modifiers: [])
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
// Search bar
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Search conversations...", text: $searchText)
|
||||
.textFieldStyle(.plain)
|
||||
if !searchText.isEmpty {
|
||||
Button { searchText = "" } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
Divider()
|
||||
|
||||
// Content
|
||||
if filteredConversations.isEmpty {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: searchText.isEmpty ? "tray" : "magnifyingglass")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(searchText.isEmpty ? "No Saved Conversations" : "No Matches")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(searchText.isEmpty ? "Conversations you save will appear here" : "Try a different search term")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Spacer()
|
||||
} else {
|
||||
List {
|
||||
ForEach(filteredConversations) { conversation in
|
||||
ConversationRow(conversation: conversation)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onLoad?(conversation)
|
||||
dismiss()
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
deleteConversation(conversation)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
|
||||
Button {
|
||||
exportConversation(conversation)
|
||||
} label: {
|
||||
Label("Export", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Bottom bar
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }
|
||||
.keyboardShortcut(.return, modifiers: [])
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.regular)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.onAppear {
|
||||
loadConversations()
|
||||
}
|
||||
.frame(minWidth: 500, minHeight: 400)
|
||||
}
|
||||
|
||||
private func loadConversations() {
|
||||
do {
|
||||
conversations = try DatabaseService.shared.listConversations()
|
||||
} catch {
|
||||
Log.db.error("Failed to load conversations: \(error.localizedDescription)")
|
||||
conversations = []
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteConversation(_ conversation: Conversation) {
|
||||
do {
|
||||
let _ = try DatabaseService.shared.deleteConversation(id: conversation.id)
|
||||
withAnimation {
|
||||
conversations.removeAll { $0.id == conversation.id }
|
||||
}
|
||||
} catch {
|
||||
Log.db.error("Failed to delete conversation: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func exportConversation(_ conversation: Conversation) {
|
||||
guard let (_, loadedMessages) = try? DatabaseService.shared.loadConversation(id: conversation.id),
|
||||
!loadedMessages.isEmpty else {
|
||||
return
|
||||
}
|
||||
let content = loadedMessages.map { msg in
|
||||
let header = msg.role == .user ? "**User**" : "**Assistant**"
|
||||
return "\(header)\n\n\(msg.content)"
|
||||
}.joined(separator: "\n\n---\n\n")
|
||||
|
||||
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.temporaryDirectory
|
||||
let filename = conversation.name.replacingOccurrences(of: " ", with: "_") + ".md"
|
||||
let fileURL = downloads.appendingPathComponent(filename)
|
||||
try? content.write(to: fileURL, atomically: true, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
struct ConversationRow: View {
|
||||
let conversation: Conversation
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(conversation.name)
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Label("\(conversation.messageCount)", systemImage: "message")
|
||||
Text("\u{2022}")
|
||||
Text(conversation.updatedAt, style: .relative)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ConversationListView()
|
||||
}
|
||||
160
oAI/Views/Screens/CreditsView.swift
Normal file
160
oAI/Views/Screens/CreditsView.swift
Normal file
@@ -0,0 +1,160 @@
|
||||
//
|
||||
// CreditsView.swift
|
||||
// oAI
|
||||
//
|
||||
// Account credits and balance
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CreditsView: View {
|
||||
let provider: Settings.Provider
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var credits: Credits?
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 24) {
|
||||
// Provider icon
|
||||
Image(systemName: provider.iconName)
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(Color.providerColor(provider))
|
||||
|
||||
Text(provider.displayName)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Divider()
|
||||
|
||||
// Credits info based on provider
|
||||
VStack(spacing: 16) {
|
||||
switch provider {
|
||||
case .openrouter:
|
||||
openRouterCreditsView
|
||||
|
||||
case .anthropic:
|
||||
Text("Anthropic Balance")
|
||||
.font(.headline)
|
||||
Text("Check your balance at:")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Link("console.anthropic.com", destination: URL(string: "https://console.anthropic.com")!)
|
||||
.font(.body)
|
||||
|
||||
case .openai:
|
||||
Text("OpenAI Balance")
|
||||
.font(.headline)
|
||||
Text("Check your usage at:")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Link("platform.openai.com", destination: URL(string: "https://platform.openai.com/usage")!)
|
||||
.font(.body)
|
||||
|
||||
case .ollama:
|
||||
Text("Ollama (Local)")
|
||||
.font(.headline)
|
||||
Text("Running locally — no credits needed!")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.green)
|
||||
.padding(.top)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Credits")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await fetchCredits()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OpenRouter Credits
|
||||
|
||||
@ViewBuilder
|
||||
private var openRouterCreditsView: some View {
|
||||
Text("OpenRouter Credits")
|
||||
.font(.headline)
|
||||
|
||||
if isLoading {
|
||||
ProgressView("Loading...")
|
||||
.padding()
|
||||
} else if let error = errorMessage {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
.padding()
|
||||
Button("Retry") {
|
||||
Task { await fetchCredits() }
|
||||
}
|
||||
} else if let credits = credits {
|
||||
VStack(spacing: 12) {
|
||||
CreditRow(label: "Remaining", value: credits.balanceDisplay, highlight: true)
|
||||
Divider()
|
||||
if let limit = credits.limit {
|
||||
CreditRow(label: "Total Credits", value: String(format: "$%.2f", limit))
|
||||
}
|
||||
if let usage = credits.usage {
|
||||
CreditRow(label: "Used", value: String(format: "$%.2f", usage))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("No credit data available")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchCredits() async {
|
||||
guard provider == .openrouter else { return }
|
||||
guard let apiProvider = ProviderRegistry.shared.getCurrentProvider() else {
|
||||
errorMessage = "No API key configured"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
credits = try await apiProvider.getCredits()
|
||||
isLoading = false
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CreditRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
var highlight: Bool = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.foregroundColor(highlight ? .primary : .secondary)
|
||||
.fontWeight(highlight ? .semibold : .regular)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(highlight ? .title2.monospacedDigit() : .body.monospacedDigit())
|
||||
.fontWeight(highlight ? .bold : .medium)
|
||||
.foregroundColor(highlight ? .green : .primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CreditsView(provider: .openrouter)
|
||||
}
|
||||
456
oAI/Views/Screens/HelpView.swift
Normal file
456
oAI/Views/Screens/HelpView.swift
Normal file
@@ -0,0 +1,456 @@
|
||||
//
|
||||
// HelpView.swift
|
||||
// oAI
|
||||
//
|
||||
// Help and commands reference with expandable detail and search
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Data Model
|
||||
|
||||
struct CommandDetail: Identifiable {
|
||||
let id = UUID()
|
||||
let command: String
|
||||
let brief: String
|
||||
let detail: String
|
||||
let examples: [String]
|
||||
}
|
||||
|
||||
struct CommandCategory: Identifiable {
|
||||
let id = UUID()
|
||||
let name: String
|
||||
let icon: String
|
||||
let commands: [CommandDetail]
|
||||
}
|
||||
|
||||
// MARK: - Help Data
|
||||
|
||||
private let helpCategories: [CommandCategory] = [
|
||||
CommandCategory(name: "Chat", icon: "bubble.left.and.bubble.right", commands: [
|
||||
CommandDetail(
|
||||
command: "/clear",
|
||||
brief: "Clear chat history",
|
||||
detail: "Removes all messages from the current session and resets the conversation. This does not delete saved conversations.",
|
||||
examples: ["/clear"]
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/retry",
|
||||
brief: "Retry last message",
|
||||
detail: "Resends your last message to the AI. Useful when you get an unsatisfactory response or encounter an error.",
|
||||
examples: ["/retry"]
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/memory on|off",
|
||||
brief: "Toggle conversation memory",
|
||||
detail: "When enabled, the AI remembers all previous messages in the session. When disabled, each message is treated independently — only your latest message is sent.",
|
||||
examples: ["/memory on", "/memory off"]
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/online on|off",
|
||||
brief: "Toggle web search",
|
||||
detail: "Enables or disables online mode. When on, the AI can search the web to find current information before responding.",
|
||||
examples: ["/online on", "/online off"]
|
||||
),
|
||||
]),
|
||||
CommandCategory(name: "Model & Provider", icon: "cpu", commands: [
|
||||
CommandDetail(
|
||||
command: "/model",
|
||||
brief: "Select AI model",
|
||||
detail: "Opens the model selector to browse and choose from available models. The list depends on your active provider and API key.",
|
||||
examples: ["/model"]
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/provider [name]",
|
||||
brief: "Switch AI provider",
|
||||
detail: "Without arguments, shows the current provider. With a provider name, switches to that provider. Available providers: openrouter, anthropic, openai, ollama.",
|
||||
examples: ["/provider", "/provider anthropic", "/provider openai"]
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/info [model]",
|
||||
brief: "Show model information",
|
||||
detail: "Displays details about the currently selected model, or a specific model if provided. Shows context length, pricing, and capabilities.",
|
||||
examples: ["/info", "/info anthropic/claude-sonnet-4"]
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/credits",
|
||||
brief: "Check account credits",
|
||||
detail: "Shows your current balance and usage for the active provider (where supported, e.g. OpenRouter).",
|
||||
examples: ["/credits"]
|
||||
),
|
||||
]),
|
||||
CommandCategory(name: "Conversations", icon: "tray.full", commands: [
|
||||
CommandDetail(
|
||||
command: "/save <name>",
|
||||
brief: "Save current conversation",
|
||||
detail: "Saves all messages in the current session under the given name. You can load it later to continue the conversation.",
|
||||
examples: ["/save my-project-chat", "/save debug session"]
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/load",
|
||||
brief: "Load saved conversation",
|
||||
detail: "Opens the conversation list to browse and load a previously saved conversation. Replaces the current chat.",
|
||||
examples: ["/load"]
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/list",
|
||||
brief: "List saved conversations",
|
||||
detail: "Opens the conversation list showing all saved conversations with their dates and message counts.",
|
||||
examples: ["/list"]
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/delete <name>",
|
||||
brief: "Delete a saved conversation",
|
||||
detail: "Permanently deletes a saved conversation by name. This cannot be undone.",
|
||||
examples: ["/delete old-chat", "/delete test"]
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/export md|json",
|
||||
brief: "Export conversation",
|
||||
detail: "Exports the current conversation to a file. Supports Markdown (.md) and JSON (.json) formats. Optionally provide a custom filename.",
|
||||
examples: ["/export md", "/export json", "/export md my-chat.md"]
|
||||
),
|
||||
]),
|
||||
CommandCategory(name: "MCP (File Access)", icon: "folder.badge.gearshape", commands: [
|
||||
CommandDetail(
|
||||
command: "/mcp on|off",
|
||||
brief: "Toggle file access",
|
||||
detail: "Enables or disables MCP (Model Context Protocol), which gives the AI access to read (and optionally write) files in your allowed folders.",
|
||||
examples: ["/mcp on", "/mcp off"]
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/mcp add <path>",
|
||||
brief: "Add folder for access",
|
||||
detail: "Grants the AI access to a folder on your filesystem. The AI can then read, list, and search files within it. Use absolute paths or ~.",
|
||||
examples: ["/mcp add ~/Projects/myapp", "/mcp add /Users/me/Documents"]
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/mcp remove <index|path>",
|
||||
brief: "Remove an allowed folder",
|
||||
detail: "Revokes AI access to a folder. Specify by index number (from /mcp list) or by path.",
|
||||
examples: ["/mcp remove 0", "/mcp remove ~/Projects/myapp"]
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/mcp list",
|
||||
brief: "List allowed folders",
|
||||
detail: "Shows all folders the AI currently has access to, with their index numbers.",
|
||||
examples: ["/mcp list"]
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/mcp status",
|
||||
brief: "Show MCP status",
|
||||
detail: "Displays whether MCP is enabled, the number of registered folders, active permissions (read/write/delete/move), and gitignore setting.",
|
||||
examples: ["/mcp status"]
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/mcp write on|off",
|
||||
brief: "Toggle write permissions",
|
||||
detail: "Quickly enables or disables all write permissions (write, edit, delete, create directories, move, copy). Fine-grained control is available in Settings > MCP.",
|
||||
examples: ["/mcp write on", "/mcp write off"]
|
||||
),
|
||||
]),
|
||||
CommandCategory(name: "Settings & Stats", icon: "gearshape", commands: [
|
||||
CommandDetail(
|
||||
command: "/config",
|
||||
brief: "Open settings",
|
||||
detail: "Opens the settings panel where you can configure providers, API keys, MCP permissions, appearance, and more. Also available via /settings.",
|
||||
examples: ["/config", "/settings"]
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/stats",
|
||||
brief: "Show session statistics",
|
||||
detail: "Displays statistics for the current session: message count, total tokens used, estimated cost, and session duration.",
|
||||
examples: ["/stats"]
|
||||
),
|
||||
]),
|
||||
]
|
||||
|
||||
private let keyboardShortcuts: [(key: String, description: String)] = [
|
||||
("Return", "Send message"),
|
||||
("Shift + Return", "New line"),
|
||||
("\u{2318}M", "Model Selector"),
|
||||
("\u{2318}K", "Clear Chat"),
|
||||
("\u{21E7}\u{2318}S", "Statistics"),
|
||||
("\u{2318},", "Settings"),
|
||||
("\u{2318}/", "Help"),
|
||||
("\u{2318}L", "Conversations"),
|
||||
]
|
||||
|
||||
// MARK: - HelpView
|
||||
|
||||
struct HelpView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var searchText = ""
|
||||
@State private var expandedCommandID: UUID?
|
||||
|
||||
private var filteredCategories: [CommandCategory] {
|
||||
if searchText.isEmpty { return helpCategories }
|
||||
let q = searchText.lowercased()
|
||||
return helpCategories.compactMap { cat in
|
||||
let matched = cat.commands.filter {
|
||||
$0.command.lowercased().contains(q) ||
|
||||
$0.brief.lowercased().contains(q) ||
|
||||
$0.detail.lowercased().contains(q) ||
|
||||
$0.examples.contains { $0.lowercased().contains(q) }
|
||||
}
|
||||
return matched.isEmpty ? nil : CommandCategory(name: cat.name, icon: cat.icon, commands: matched)
|
||||
}
|
||||
}
|
||||
|
||||
private var matchingShortcuts: [(key: String, description: String)] {
|
||||
if searchText.isEmpty { return keyboardShortcuts }
|
||||
let q = searchText.lowercased()
|
||||
return keyboardShortcuts.filter {
|
||||
$0.key.lowercased().contains(q) || $0.description.lowercased().contains(q)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("Help")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
Spacer()
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut(.escape, modifiers: [])
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
// Search bar
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Search commands, shortcuts, features...", text: $searchText)
|
||||
.textFieldStyle(.plain)
|
||||
if !searchText.isEmpty {
|
||||
Button {
|
||||
searchText = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
Divider()
|
||||
|
||||
// Content
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// Quick tip (only when not searching)
|
||||
if searchText.isEmpty {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "lightbulb.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
.font(.title3)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Type / in the input to see command suggestions")
|
||||
.font(.callout)
|
||||
Text("Use @filename to attach files to your message")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.blue.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
// Command categories
|
||||
ForEach(filteredCategories) { category in
|
||||
CategorySection(
|
||||
category: category,
|
||||
expandedCommandID: $expandedCommandID
|
||||
)
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
#if os(macOS)
|
||||
if !matchingShortcuts.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Keyboard Shortcuts", systemImage: "keyboard")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(matchingShortcuts.enumerated()), id: \.offset) { idx, shortcut in
|
||||
HStack {
|
||||
Text(shortcut.key)
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 5))
|
||||
Spacer()
|
||||
Text(shortcut.description)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
|
||||
if idx < matchingShortcuts.count - 1 {
|
||||
Divider().padding(.leading, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(.background, in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.quaternary))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// No results
|
||||
if filteredCategories.isEmpty && matchingShortcuts.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("No results for \"\(searchText)\"")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Bottom bar
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }
|
||||
.keyboardShortcut(.return, modifiers: [])
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.regular)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.frame(minWidth: 520, idealWidth: 600, minHeight: 480, idealHeight: 700)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Section
|
||||
|
||||
private struct CategorySection: View {
|
||||
let category: CommandCategory
|
||||
@Binding var expandedCommandID: UUID?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label(category.name, systemImage: category.icon)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(category.commands.enumerated()), id: \.element.id) { idx, cmd in
|
||||
CommandRow(
|
||||
command: cmd,
|
||||
isExpanded: expandedCommandID == cmd.id,
|
||||
onTap: {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
expandedCommandID = expandedCommandID == cmd.id ? nil : cmd.id
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if idx < category.commands.count - 1 {
|
||||
Divider().padding(.leading, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(.background, in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.quaternary))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Command Row
|
||||
|
||||
private struct CommandRow: View {
|
||||
let command: CommandDetail
|
||||
let isExpanded: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Header (always visible)
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 10) {
|
||||
Text(command.command)
|
||||
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(command.brief)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Expanded detail
|
||||
if isExpanded {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(command.detail)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if !command.examples.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(command.examples.count == 1 ? "Example" : "Examples")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fontWeight(.medium)
|
||||
|
||||
ForEach(command.examples, id: \.self) { example in
|
||||
Text(example)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.blue.opacity(0.06), in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 12)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HelpView()
|
||||
}
|
||||
223
oAI/Views/Screens/ModelInfoView.swift
Normal file
223
oAI/Views/Screens/ModelInfoView.swift
Normal file
@@ -0,0 +1,223 @@
|
||||
//
|
||||
// ModelInfoView.swift
|
||||
// oAI
|
||||
//
|
||||
// Rich model information modal
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ModelInfoView: View {
|
||||
let model: ModelInfo
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("Model Info")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
Spacer()
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut(.escape, modifiers: [])
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// Overview
|
||||
sectionHeader("Overview")
|
||||
infoRow("Name", model.name)
|
||||
infoRow("ID", model.id)
|
||||
if let provider = model.topProvider {
|
||||
infoRow("Provider", provider)
|
||||
}
|
||||
if let desc = model.description {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Description")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.secondary)
|
||||
Text(desc)
|
||||
.font(.body)
|
||||
.foregroundColor(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Pricing
|
||||
sectionHeader("Pricing")
|
||||
infoRow("Input", model.promptPriceDisplay + " / 1M tokens")
|
||||
infoRow("Output", model.completionPriceDisplay + " / 1M tokens")
|
||||
|
||||
if model.pricing.prompt > 0 {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Cost Examples")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 16) {
|
||||
costExample(label: "1K tokens", inputTokens: 1_000)
|
||||
costExample(label: "10K tokens", inputTokens: 10_000)
|
||||
costExample(label: "100K tokens", inputTokens: 100_000)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Context Window
|
||||
sectionHeader("Context Window")
|
||||
infoRow("Max Tokens", model.contextLength.formatted())
|
||||
|
||||
if model.contextLength > 0 {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
let maxContext = 2_000_000.0
|
||||
let fraction = min(Double(model.contextLength) / maxContext, 1.0)
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.gray.opacity(0.2))
|
||||
.frame(height: 16)
|
||||
GeometryReader { geo in
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.blue)
|
||||
.frame(width: geo.size.width * fraction, height: 16)
|
||||
}
|
||||
.frame(height: 16)
|
||||
}
|
||||
Text(model.contextLengthDisplay + " tokens")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Capabilities
|
||||
sectionHeader("Capabilities")
|
||||
HStack(spacing: 12) {
|
||||
capabilityBadge(icon: "eye.fill", label: "Vision", active: model.capabilities.vision)
|
||||
capabilityBadge(icon: "wrench.fill", label: "Tools", active: model.capabilities.tools)
|
||||
capabilityBadge(icon: "globe", label: "Online", active: model.capabilities.online)
|
||||
capabilityBadge(icon: "photo.fill", label: "Image Gen", active: model.capabilities.imageGeneration)
|
||||
}
|
||||
|
||||
// Architecture (if available)
|
||||
if let arch = model.architecture {
|
||||
Divider()
|
||||
sectionHeader("Architecture")
|
||||
if let modality = arch.modality {
|
||||
infoRow("Modality", modality)
|
||||
}
|
||||
if let tokenizer = arch.tokenizer {
|
||||
infoRow("Tokenizer", tokenizer)
|
||||
}
|
||||
if let instructType = arch.instructType {
|
||||
infoRow("Instruct Type", instructType)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Bottom bar
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }
|
||||
.keyboardShortcut(.return, modifiers: [])
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.regular)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.frame(minWidth: 550, idealWidth: 650, minHeight: 550, idealHeight: 750)
|
||||
}
|
||||
|
||||
// MARK: - Layout Helpers
|
||||
|
||||
private func sectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
|
||||
private func infoRow(_ label: String, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.body)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func costExample(label: String, inputTokens: Int) -> some View {
|
||||
let cost = (Double(inputTokens) * model.pricing.prompt / 1_000_000) +
|
||||
(Double(inputTokens) * model.pricing.completion / 1_000_000)
|
||||
VStack(spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text(String(format: "$%.4f", cost))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func capabilityBadge(icon: String, label: String, active: Bool) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.title3)
|
||||
.foregroundColor(active ? .blue : .gray.opacity(0.4))
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundColor(active ? .primary : .secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(active ? Color.blue.opacity(0.1) : Color.gray.opacity(0.05))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ModelInfoView(model: ModelInfo(
|
||||
id: "anthropic/claude-sonnet-4",
|
||||
name: "Claude Sonnet 4",
|
||||
description: "Balanced intelligence and speed. This is a longer description to test how the modal handles multi-line text that wraps across several lines in the description field.",
|
||||
contextLength: 200_000,
|
||||
pricing: .init(prompt: 3.0, completion: 15.0),
|
||||
capabilities: .init(vision: true, tools: true, online: false),
|
||||
architecture: .init(tokenizer: "claude", instructType: "claude", modality: "text+image->text"),
|
||||
topProvider: "anthropic"
|
||||
))
|
||||
}
|
||||
222
oAI/Views/Screens/ModelSelectorView.swift
Normal file
222
oAI/Views/Screens/ModelSelectorView.swift
Normal file
@@ -0,0 +1,222 @@
|
||||
//
|
||||
// ModelSelectorView.swift
|
||||
// oAI
|
||||
//
|
||||
// Model selection screen
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ModelSelectorView: View {
|
||||
let models: [ModelInfo]
|
||||
let selectedModel: ModelInfo?
|
||||
let onSelect: (ModelInfo) -> Void
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var searchText = ""
|
||||
@State private var filterVision = false
|
||||
@State private var filterTools = false
|
||||
@State private var filterOnline = false
|
||||
@State private var filterImageGen = false
|
||||
@State private var keyboardIndex: Int = -1
|
||||
|
||||
private var filteredModels: [ModelInfo] {
|
||||
models.filter { model in
|
||||
let matchesSearch = searchText.isEmpty ||
|
||||
model.name.lowercased().contains(searchText.lowercased()) ||
|
||||
model.id.lowercased().contains(searchText.lowercased())
|
||||
|
||||
let matchesVision = !filterVision || model.capabilities.vision
|
||||
let matchesTools = !filterTools || model.capabilities.tools
|
||||
let matchesOnline = !filterOnline || model.capabilities.online
|
||||
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
|
||||
|
||||
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Search bar
|
||||
TextField("Search models...", text: $searchText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.padding()
|
||||
.onChange(of: searchText) {
|
||||
// Reset keyboard index when search changes
|
||||
keyboardIndex = -1
|
||||
}
|
||||
|
||||
// Filters
|
||||
HStack(spacing: 12) {
|
||||
FilterToggle(isOn: $filterVision, icon: "\u{1F441}\u{FE0F}", label: "Vision")
|
||||
FilterToggle(isOn: $filterTools, icon: "\u{1F527}", label: "Tools")
|
||||
FilterToggle(isOn: $filterOnline, icon: "\u{1F310}", label: "Online")
|
||||
FilterToggle(isOn: $filterImageGen, icon: "\u{1F3A8}", label: "Image Gen")
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
Divider()
|
||||
|
||||
// Model list
|
||||
if filteredModels.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Models Found",
|
||||
systemImage: "magnifyingglass",
|
||||
description: Text("Try adjusting your search or filters")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollViewReader { proxy in
|
||||
List(Array(filteredModels.enumerated()), id: \.element.id) { index, model in
|
||||
ModelRowView(
|
||||
model: model,
|
||||
isSelected: model.id == selectedModel?.id,
|
||||
isKeyboardHighlighted: index == keyboardIndex
|
||||
)
|
||||
.id(model.id)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onSelect(model)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.onChange(of: keyboardIndex) { _, newIndex in
|
||||
if newIndex >= 0 && newIndex < filteredModels.count {
|
||||
withAnimation {
|
||||
proxy.scrollTo(filteredModels[newIndex].id, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 500)
|
||||
.navigationTitle("Select Model")
|
||||
#if os(macOS)
|
||||
.onKeyPress(.downArrow) {
|
||||
if keyboardIndex < filteredModels.count - 1 {
|
||||
keyboardIndex += 1
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
.onKeyPress(.upArrow) {
|
||||
if keyboardIndex > 0 {
|
||||
keyboardIndex -= 1
|
||||
} else if keyboardIndex == -1 && !filteredModels.isEmpty {
|
||||
keyboardIndex = 0
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
.onKeyPress(.return) {
|
||||
if keyboardIndex >= 0 && keyboardIndex < filteredModels.count {
|
||||
onSelect(filteredModels[keyboardIndex])
|
||||
return .handled
|
||||
}
|
||||
return .ignored
|
||||
}
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Initialize keyboard index to current selection
|
||||
if let selected = selectedModel,
|
||||
let index = filteredModels.firstIndex(where: { $0.id == selected.id }) {
|
||||
keyboardIndex = index
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FilterToggle: View {
|
||||
@Binding var isOn: Bool
|
||||
let icon: String
|
||||
let label: String
|
||||
|
||||
var body: some View {
|
||||
Button(action: { isOn.toggle() }) {
|
||||
HStack(spacing: 4) {
|
||||
Text(icon)
|
||||
Text(label)
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(isOn ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1))
|
||||
.foregroundColor(isOn ? .blue : .secondary)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct ModelRowView: View {
|
||||
let model: ModelInfo
|
||||
let isSelected: Bool
|
||||
var isKeyboardHighlighted: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(model.name)
|
||||
.font(.headline)
|
||||
.foregroundColor(isSelected ? .blue : .primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Capabilities
|
||||
HStack(spacing: 4) {
|
||||
if model.capabilities.vision { Text("\u{1F441}\u{FE0F}").font(.caption) }
|
||||
if model.capabilities.tools { Text("\u{1F527}").font(.caption) }
|
||||
if model.capabilities.online { Text("\u{1F310}").font(.caption) }
|
||||
if model.capabilities.imageGeneration { Text("\u{1F3A8}").font(.caption) }
|
||||
}
|
||||
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
|
||||
Text(model.id)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if let description = model.description {
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Label(model.contextLengthDisplay, systemImage: "text.alignleft")
|
||||
Label(model.promptPriceDisplay + "/1M", systemImage: "dollarsign.circle")
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, isKeyboardHighlighted ? 4 : 0)
|
||||
.background(
|
||||
isKeyboardHighlighted
|
||||
? RoundedRectangle(cornerRadius: 6).fill(Color.accentColor.opacity(0.15))
|
||||
: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ModelSelectorView(
|
||||
models: ModelInfo.mockModels,
|
||||
selectedModel: ModelInfo.mockModels.first,
|
||||
onSelect: { _ in }
|
||||
)
|
||||
}
|
||||
500
oAI/Views/Screens/SettingsView.swift
Normal file
500
oAI/Views/Screens/SettingsView.swift
Normal file
@@ -0,0 +1,500 @@
|
||||
//
|
||||
// SettingsView.swift
|
||||
// oAI
|
||||
//
|
||||
// Settings and configuration screen
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Bindable private var settingsService = SettingsService.shared
|
||||
private var mcpService = MCPService.shared
|
||||
|
||||
@State private var openrouterKey = ""
|
||||
@State private var anthropicKey = ""
|
||||
@State private var openaiKey = ""
|
||||
@State private var googleKey = ""
|
||||
@State private var googleEngineID = ""
|
||||
@State private var showFolderPicker = false
|
||||
@State private var selectedTab = 0
|
||||
|
||||
// OAuth state
|
||||
@State private var oauthCode = ""
|
||||
@State private var oauthError: String?
|
||||
@State private var showOAuthCodeField = false
|
||||
private var oauthService = AnthropicOAuthService.shared
|
||||
|
||||
private let labelWidth: CGFloat = 140
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Title
|
||||
Text("Settings")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
// Tab picker
|
||||
Picker("", selection: $selectedTab) {
|
||||
Text("General").tag(0)
|
||||
Text("MCP").tag(1)
|
||||
Text("Appearance").tag(2)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
switch selectedTab {
|
||||
case 0:
|
||||
generalTab
|
||||
case 1:
|
||||
mcpTab
|
||||
case 2:
|
||||
appearanceTab
|
||||
default:
|
||||
generalTab
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Bottom bar
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }
|
||||
.keyboardShortcut(.return, modifiers: [])
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.regular)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.frame(minWidth: 550, idealWidth: 600, minHeight: 500, idealHeight: 650)
|
||||
}
|
||||
|
||||
// MARK: - General Tab
|
||||
|
||||
@ViewBuilder
|
||||
private var generalTab: some View {
|
||||
// Provider
|
||||
sectionHeader("Provider")
|
||||
row("Default Provider") {
|
||||
Picker("", selection: $settingsService.defaultProvider) {
|
||||
ForEach(ProviderRegistry.shared.configuredProviders, id: \.self) { provider in
|
||||
Text(provider.displayName).tag(provider)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
}
|
||||
|
||||
divider()
|
||||
|
||||
// API Keys
|
||||
sectionHeader("API Keys")
|
||||
row("OpenRouter") {
|
||||
SecureField("sk-or-...", text: $openrouterKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onAppear { openrouterKey = settingsService.openrouterAPIKey ?? "" }
|
||||
.onChange(of: openrouterKey) {
|
||||
settingsService.openrouterAPIKey = openrouterKey.isEmpty ? nil : openrouterKey
|
||||
ProviderRegistry.shared.clearCache()
|
||||
}
|
||||
}
|
||||
// Anthropic: OAuth or API key
|
||||
row("Anthropic") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if oauthService.isAuthenticated {
|
||||
// Logged in via OAuth
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text("Logged in via Claude Pro/Max")
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Button("Logout") {
|
||||
oauthService.logout()
|
||||
ProviderRegistry.shared.clearCache()
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
} else if showOAuthCodeField {
|
||||
// Waiting for code paste
|
||||
HStack(spacing: 8) {
|
||||
TextField("Paste authorization code...", text: $oauthCode)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Submit") {
|
||||
Task { await submitOAuthCode() }
|
||||
}
|
||||
.disabled(oauthCode.isEmpty || oauthService.isLoggingIn)
|
||||
Button("Cancel") {
|
||||
showOAuthCodeField = false
|
||||
oauthCode = ""
|
||||
oauthError = nil
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
if let error = oauthError {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
} else {
|
||||
// Login button + API key field
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
startOAuthLogin()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "person.circle")
|
||||
Text("Login with Claude Pro/Max")
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
|
||||
Text("or")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
SecureField("sk-ant-... (API key)", text: $anthropicKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onAppear { anthropicKey = settingsService.anthropicAPIKey ?? "" }
|
||||
.onChange(of: anthropicKey) {
|
||||
settingsService.anthropicAPIKey = anthropicKey.isEmpty ? nil : anthropicKey
|
||||
ProviderRegistry.shared.clearCache()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
row("OpenAI") {
|
||||
SecureField("sk-...", text: $openaiKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onAppear { openaiKey = settingsService.openaiAPIKey ?? "" }
|
||||
.onChange(of: openaiKey) {
|
||||
settingsService.openaiAPIKey = openaiKey.isEmpty ? nil : openaiKey
|
||||
ProviderRegistry.shared.clearCache()
|
||||
}
|
||||
}
|
||||
row("Ollama URL") {
|
||||
TextField("http://localhost:11434", text: $settingsService.ollamaBaseURL)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.help("Enter your Ollama server URL to enable the Ollama provider")
|
||||
}
|
||||
|
||||
divider()
|
||||
|
||||
// Features
|
||||
sectionHeader("Features")
|
||||
row("") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Toggle("Online Mode (Web Search)", isOn: $settingsService.onlineMode)
|
||||
Toggle("Conversation Memory", isOn: $settingsService.memoryEnabled)
|
||||
Toggle("MCP (File Access)", isOn: $settingsService.mcpEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
divider()
|
||||
|
||||
// Web Search
|
||||
sectionHeader("Web Search")
|
||||
row("Search Provider") {
|
||||
Picker("", selection: $settingsService.searchProvider) {
|
||||
ForEach(Settings.SearchProvider.allCases, id: \.self) { provider in
|
||||
Text(provider.displayName).tag(provider)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
}
|
||||
if settingsService.searchProvider == .google {
|
||||
row("Google API Key") {
|
||||
SecureField("", text: $googleKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onAppear { googleKey = settingsService.googleAPIKey ?? "" }
|
||||
.onChange(of: googleKey) {
|
||||
settingsService.googleAPIKey = googleKey.isEmpty ? nil : googleKey
|
||||
}
|
||||
}
|
||||
row("Search Engine ID") {
|
||||
TextField("", text: $googleEngineID)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onAppear { googleEngineID = settingsService.googleSearchEngineID ?? "" }
|
||||
.onChange(of: googleEngineID) {
|
||||
settingsService.googleSearchEngineID = googleEngineID.isEmpty ? nil : googleEngineID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
divider()
|
||||
|
||||
// Model Settings
|
||||
sectionHeader("Model Settings")
|
||||
row("Default Model ID") {
|
||||
TextField("e.g. anthropic/claude-sonnet-4", text: Binding(
|
||||
get: { settingsService.defaultModel ?? "" },
|
||||
set: { settingsService.defaultModel = $0.isEmpty ? nil : $0 }
|
||||
))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
row("") {
|
||||
Toggle("Streaming Responses", isOn: $settingsService.streamEnabled)
|
||||
}
|
||||
|
||||
divider()
|
||||
|
||||
// Logging
|
||||
sectionHeader("Logging")
|
||||
row("Log Level") {
|
||||
Picker("", selection: Binding(
|
||||
get: { FileLogger.shared.minimumLevel },
|
||||
set: { FileLogger.shared.minimumLevel = $0 }
|
||||
)) {
|
||||
ForEach(LogLevel.allCases, id: \.self) { level in
|
||||
Text(level.displayName).tag(level)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
}
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("Controls which messages are written to ~/Library/Logs/oAI.log")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MCP Tab
|
||||
|
||||
@ViewBuilder
|
||||
private var mcpTab: some View {
|
||||
// Enable toggle
|
||||
sectionHeader("MCP")
|
||||
row("") {
|
||||
Toggle("MCP Enabled (File Access)", isOn: $settingsService.mcpEnabled)
|
||||
}
|
||||
|
||||
if settingsService.mcpEnabled {
|
||||
divider()
|
||||
|
||||
// Folders
|
||||
sectionHeader("Allowed Folders")
|
||||
|
||||
if mcpService.allowedFolders.isEmpty {
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("No folders added")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
}
|
||||
} else {
|
||||
ForEach(Array(mcpService.allowedFolders.enumerated()), id: \.offset) { index, folder in
|
||||
HStack(spacing: 0) {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Image(systemName: "folder.fill")
|
||||
.foregroundStyle(.blue)
|
||||
.frame(width: 20)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text((folder as NSString).lastPathComponent)
|
||||
.font(.body)
|
||||
Text(abbreviatePath(folder))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.leading, 6)
|
||||
Spacer()
|
||||
Button {
|
||||
withAnimation { _ = mcpService.removeFolder(at: index) }
|
||||
} label: {
|
||||
Image(systemName: "trash.fill")
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 0) {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Button {
|
||||
showFolderPicker = true
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "plus")
|
||||
Text("Add Folder...")
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
Spacer()
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showFolderPicker,
|
||||
allowedContentTypes: [.folder],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
if case .success(let urls) = result, let url = urls.first {
|
||||
if url.startAccessingSecurityScopedResource() {
|
||||
withAnimation { _ = mcpService.addFolder(url.path) }
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
divider()
|
||||
|
||||
// Permissions
|
||||
sectionHeader("Permissions")
|
||||
row("") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Toggle("Write & Edit Files", isOn: $settingsService.mcpCanWriteFiles)
|
||||
Toggle("Delete Files", isOn: $settingsService.mcpCanDeleteFiles)
|
||||
Toggle("Create Directories", isOn: $settingsService.mcpCanCreateDirectories)
|
||||
Toggle("Move & Copy Files", isOn: $settingsService.mcpCanMoveFiles)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("Read access is always available when MCP is enabled. Write permissions must be explicitly enabled.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
divider()
|
||||
|
||||
// Filtering
|
||||
sectionHeader("Filtering")
|
||||
row("") {
|
||||
Toggle("Respect .gitignore", isOn: Binding(
|
||||
get: { settingsService.mcpRespectGitignore },
|
||||
set: { newValue in
|
||||
settingsService.mcpRespectGitignore = newValue
|
||||
mcpService.reloadGitignores()
|
||||
}
|
||||
))
|
||||
}
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("When enabled, listing and searching skip gitignored files. Write operations always ignore .gitignore.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Appearance Tab
|
||||
|
||||
@ViewBuilder
|
||||
private var appearanceTab: some View {
|
||||
sectionHeader("Text Sizes")
|
||||
row("GUI Text") {
|
||||
HStack(spacing: 8) {
|
||||
Slider(value: $settingsService.guiTextSize, in: 10...20, step: 1)
|
||||
.frame(maxWidth: 200)
|
||||
Text("\(Int(settingsService.guiTextSize)) pt")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 40)
|
||||
}
|
||||
}
|
||||
row("Dialog Text") {
|
||||
HStack(spacing: 8) {
|
||||
Slider(value: $settingsService.dialogTextSize, in: 10...24, step: 1)
|
||||
.frame(maxWidth: 200)
|
||||
Text("\(Int(settingsService.dialogTextSize)) pt")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 40)
|
||||
}
|
||||
}
|
||||
row("Input Text") {
|
||||
HStack(spacing: 8) {
|
||||
Slider(value: $settingsService.inputTextSize, in: 10...24, step: 1)
|
||||
.frame(maxWidth: 200)
|
||||
Text("\(Int(settingsService.inputTextSize)) pt")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout Helpers
|
||||
|
||||
private func sectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
|
||||
private func row<Content: View>(_ label: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Text(label)
|
||||
.font(.body)
|
||||
.frame(width: labelWidth, alignment: .trailing)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
private func divider() -> some View {
|
||||
Divider().padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private func abbreviatePath(_ path: String) -> String {
|
||||
let home = NSHomeDirectory()
|
||||
if path.hasPrefix(home) {
|
||||
return "~" + path.dropFirst(home.count)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// MARK: - OAuth Helpers
|
||||
|
||||
private func startOAuthLogin() {
|
||||
let url = oauthService.generateAuthorizationURL()
|
||||
#if os(macOS)
|
||||
NSWorkspace.shared.open(url)
|
||||
#endif
|
||||
showOAuthCodeField = true
|
||||
oauthError = nil
|
||||
oauthCode = ""
|
||||
}
|
||||
|
||||
private func submitOAuthCode() async {
|
||||
oauthService.isLoggingIn = true
|
||||
oauthError = nil
|
||||
do {
|
||||
try await oauthService.exchangeCode(oauthCode)
|
||||
showOAuthCodeField = false
|
||||
oauthCode = ""
|
||||
ProviderRegistry.shared.clearCache()
|
||||
} catch {
|
||||
oauthError = error.localizedDescription
|
||||
}
|
||||
oauthService.isLoggingIn = false
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SettingsView()
|
||||
}
|
||||
148
oAI/Views/Screens/StatsView.swift
Normal file
148
oAI/Views/Screens/StatsView.swift
Normal file
@@ -0,0 +1,148 @@
|
||||
//
|
||||
// StatsView.swift
|
||||
// oAI
|
||||
//
|
||||
// Session statistics screen
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct StatsView: View {
|
||||
let stats: SessionStats
|
||||
let model: ModelInfo?
|
||||
let provider: Settings.Provider
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section("Session Info") {
|
||||
StatRow(label: "Provider", value: provider.displayName)
|
||||
StatRow(label: "Model", value: model?.name ?? "None selected")
|
||||
StatRow(label: "Messages", value: "\(stats.messageCount)")
|
||||
}
|
||||
|
||||
Section("Token Usage") {
|
||||
StatRow(label: "Input Tokens", value: stats.totalInputTokens.formatted())
|
||||
StatRow(label: "Output Tokens", value: stats.totalOutputTokens.formatted())
|
||||
StatRow(label: "Total Tokens", value: stats.totalTokens.formatted())
|
||||
|
||||
if stats.totalTokens > 0 {
|
||||
HStack {
|
||||
Text("Token Distribution")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
GeometryReader { geo in
|
||||
HStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(Color.blue)
|
||||
.frame(width: geo.size.width * CGFloat(stats.totalInputTokens) / CGFloat(stats.totalTokens))
|
||||
Rectangle()
|
||||
.fill(Color.green)
|
||||
.frame(width: geo.size.width * CGFloat(stats.totalOutputTokens) / CGFloat(stats.totalTokens))
|
||||
}
|
||||
}
|
||||
.frame(height: 20)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Costs") {
|
||||
StatRow(label: "Total Cost", value: stats.totalCostDisplay)
|
||||
if stats.messageCount > 0 {
|
||||
StatRow(label: "Avg per Message", value: String(format: "$%.4f", stats.averageCostPerMessage))
|
||||
}
|
||||
}
|
||||
|
||||
if let model = model {
|
||||
Section("Model Details") {
|
||||
StatRow(label: "Context Length", value: model.contextLengthDisplay)
|
||||
StatRow(label: "Prompt Price", value: model.promptPriceDisplay + "/1M tokens")
|
||||
StatRow(label: "Completion Price", value: model.completionPriceDisplay + "/1M tokens")
|
||||
|
||||
HStack {
|
||||
Text("Capabilities")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
HStack(spacing: 8) {
|
||||
if model.capabilities.vision {
|
||||
CapabilityBadge(icon: "👁️", label: "Vision")
|
||||
}
|
||||
if model.capabilities.tools {
|
||||
CapabilityBadge(icon: "🔧", label: "Tools")
|
||||
}
|
||||
if model.capabilities.online {
|
||||
CapabilityBadge(icon: "🌐", label: "Online")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
#else
|
||||
.listStyle(.sidebar)
|
||||
#endif
|
||||
.navigationTitle("Statistics")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 500, idealWidth: 550, minHeight: 450, idealHeight: 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StatRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.body)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.body.monospacedDigit())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CapabilityBadge: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 2) {
|
||||
Text(icon)
|
||||
Text(label)
|
||||
}
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
StatsView(
|
||||
stats: SessionStats(
|
||||
totalInputTokens: 1250,
|
||||
totalOutputTokens: 3420,
|
||||
totalCost: 0.0152,
|
||||
messageCount: 12
|
||||
),
|
||||
model: ModelInfo.mockModels.first,
|
||||
provider: .openrouter
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user