Bug gixes, features added, GUI updates and more
This commit is contained in:
@@ -60,6 +60,8 @@ struct ChatView: View {
|
||||
// Input bar
|
||||
InputBar(
|
||||
text: $viewModel.inputText,
|
||||
commandHistory: $viewModel.commandHistory,
|
||||
historyIndex: $viewModel.historyIndex,
|
||||
isGenerating: viewModel.isGenerating,
|
||||
mcpStatus: viewModel.mcpStatus,
|
||||
onlineMode: viewModel.onlineMode,
|
||||
|
||||
@@ -42,6 +42,7 @@ struct ContentView: View {
|
||||
selectedModel: chatViewModel.selectedModel,
|
||||
onSelect: { model in
|
||||
chatViewModel.selectedModel = model
|
||||
SettingsService.shared.defaultModel = model.id
|
||||
chatViewModel.showModelSelector = false
|
||||
}
|
||||
)
|
||||
@@ -77,6 +78,11 @@ struct ContentView: View {
|
||||
.sheet(item: $vm.modelInfoTarget) { model in
|
||||
ModelInfoView(model: model)
|
||||
}
|
||||
.sheet(isPresented: $vm.showHistory) {
|
||||
HistoryView(onSelect: { input in
|
||||
chatViewModel.inputText = input
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
@@ -91,11 +97,17 @@ struct ContentView: View {
|
||||
.help("New conversation")
|
||||
|
||||
Button(action: { chatViewModel.showConversations = true }) {
|
||||
Label("History", systemImage: "clock.arrow.circlepath")
|
||||
Label("Conversations", systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
.keyboardShortcut("l", modifiers: .command)
|
||||
.help("Saved conversations (Cmd+L)")
|
||||
|
||||
Button(action: { chatViewModel.showHistory = true }) {
|
||||
Label("History", systemImage: "list.bullet")
|
||||
}
|
||||
.keyboardShortcut("h", modifiers: .command)
|
||||
.help("Command history (Cmd+H)")
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { chatViewModel.showModelSelector = true }) {
|
||||
|
||||
@@ -9,6 +9,8 @@ import SwiftUI
|
||||
|
||||
struct InputBar: View {
|
||||
@Binding var text: String
|
||||
@Binding var commandHistory: [String]
|
||||
@Binding var historyIndex: Int
|
||||
let isGenerating: Bool
|
||||
let mcpStatus: String?
|
||||
let onlineMode: Bool
|
||||
@@ -22,7 +24,7 @@ struct InputBar: View {
|
||||
|
||||
/// Commands that execute immediately without additional arguments
|
||||
private static let immediateCommands: Set<String> = [
|
||||
"/help", "/model", "/clear", "/retry", "/stats", "/config",
|
||||
"/help", "/history", "/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",
|
||||
@@ -79,28 +81,56 @@ struct InputBar: View {
|
||||
.onChange(of: text) {
|
||||
showCommandDropdown = text.hasPrefix("/")
|
||||
selectedSuggestionIndex = 0
|
||||
// Reset history index when user types
|
||||
historyIndex = commandHistory.count
|
||||
}
|
||||
#if os(macOS)
|
||||
.onKeyPress(.upArrow) {
|
||||
guard showCommandDropdown else { return .ignored }
|
||||
if selectedSuggestionIndex > 0 {
|
||||
selectedSuggestionIndex -= 1
|
||||
// If command dropdown is showing, navigate dropdown
|
||||
if showCommandDropdown {
|
||||
if selectedSuggestionIndex > 0 {
|
||||
selectedSuggestionIndex -= 1
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
// Otherwise, navigate command history
|
||||
if historyIndex > 0 {
|
||||
historyIndex -= 1
|
||||
text = commandHistory[historyIndex]
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
.onKeyPress(.downArrow) {
|
||||
guard showCommandDropdown else { return .ignored }
|
||||
let count = CommandSuggestionsView.filteredCommands(for: text).count
|
||||
if selectedSuggestionIndex < count - 1 {
|
||||
selectedSuggestionIndex += 1
|
||||
// If command dropdown is showing, navigate dropdown
|
||||
if showCommandDropdown {
|
||||
let count = CommandSuggestionsView.filteredCommands(for: text).count
|
||||
if selectedSuggestionIndex < count - 1 {
|
||||
selectedSuggestionIndex += 1
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
// Otherwise, navigate command history
|
||||
if historyIndex < commandHistory.count - 1 {
|
||||
historyIndex += 1
|
||||
text = commandHistory[historyIndex]
|
||||
} else if historyIndex == commandHistory.count - 1 {
|
||||
// At the end of history, clear text and move to "new" position
|
||||
historyIndex = commandHistory.count
|
||||
text = ""
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
.onKeyPress(.escape) {
|
||||
// If command dropdown is showing, close it
|
||||
if showCommandDropdown {
|
||||
showCommandDropdown = false
|
||||
return .handled
|
||||
}
|
||||
// If model is generating, cancel it
|
||||
if isGenerating {
|
||||
onCancel()
|
||||
return .handled
|
||||
}
|
||||
return .ignored
|
||||
}
|
||||
.onKeyPress(.return) {
|
||||
@@ -227,6 +257,7 @@ struct CommandSuggestionsView: View {
|
||||
|
||||
static let allCommands: [(command: String, description: String)] = [
|
||||
("/help", "Show help and available commands"),
|
||||
("/history", "View command history"),
|
||||
("/model", "Select AI model"),
|
||||
("/clear", "Clear chat history"),
|
||||
("/retry", "Retry last message"),
|
||||
@@ -316,6 +347,8 @@ struct CommandSuggestionsView: View {
|
||||
Spacer()
|
||||
InputBar(
|
||||
text: .constant(""),
|
||||
commandHistory: .constant([]),
|
||||
historyIndex: .constant(0),
|
||||
isGenerating: false,
|
||||
mcpStatus: "📁 Files",
|
||||
onlineMode: true,
|
||||
|
||||
@@ -16,7 +16,7 @@ struct MarkdownContentView: View {
|
||||
|
||||
var body: some View {
|
||||
let segments = parseSegments(content)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(segments.indices, id: \.self) { index in
|
||||
switch segments[index] {
|
||||
case .text(let text):
|
||||
@@ -25,6 +25,8 @@ struct MarkdownContentView: View {
|
||||
}
|
||||
case .codeBlock(let language, let code):
|
||||
CodeBlockView(language: language, code: code, fontSize: fontSize)
|
||||
case .table(let tableText):
|
||||
TableView(content: tableText, fontSize: fontSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,16 +34,18 @@ struct MarkdownContentView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func markdownText(_ text: String) -> some View {
|
||||
if let attrString = try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
|
||||
if let attrString = try? AttributedString(markdown: text, options: .init(interpretedSyntax: .full)) {
|
||||
Text(attrString)
|
||||
.font(.system(size: fontSize))
|
||||
.foregroundColor(.oaiPrimary)
|
||||
.lineSpacing(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
} else {
|
||||
Text(text)
|
||||
.font(.system(size: fontSize))
|
||||
.foregroundColor(.oaiPrimary)
|
||||
.lineSpacing(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
@@ -52,6 +56,7 @@ struct MarkdownContentView: View {
|
||||
enum Segment {
|
||||
case text(String)
|
||||
case codeBlock(language: String?, code: String)
|
||||
case table(String)
|
||||
}
|
||||
|
||||
private func parseSegments(_ content: String) -> [Segment] {
|
||||
@@ -61,10 +66,17 @@ struct MarkdownContentView: View {
|
||||
var inCodeBlock = false
|
||||
var codeLanguage: String? = nil
|
||||
var codeContent = ""
|
||||
var inTable = false
|
||||
var tableLines: [String] = []
|
||||
|
||||
for line in lines {
|
||||
if !inCodeBlock && line.hasPrefix("```") {
|
||||
// Start of code block
|
||||
if inTable {
|
||||
segments.append(.table(tableLines.joined(separator: "\n")))
|
||||
tableLines = []
|
||||
inTable = false
|
||||
}
|
||||
if !currentText.isEmpty {
|
||||
segments.append(.text(currentText))
|
||||
currentText = ""
|
||||
@@ -75,7 +87,6 @@ struct MarkdownContentView: View {
|
||||
codeContent = ""
|
||||
} else if inCodeBlock && line.hasPrefix("```") {
|
||||
// End of code block
|
||||
// Remove trailing newline from code
|
||||
if codeContent.hasSuffix("\n") {
|
||||
codeContent = String(codeContent.dropLast())
|
||||
}
|
||||
@@ -85,7 +96,25 @@ struct MarkdownContentView: View {
|
||||
codeContent = ""
|
||||
} else if inCodeBlock {
|
||||
codeContent += line + "\n"
|
||||
} else if line.contains("|") && (line.hasPrefix("|") || line.filter({ $0 == "|" }).count >= 2) {
|
||||
// Markdown table line
|
||||
if !inTable {
|
||||
// Start of table
|
||||
if !currentText.isEmpty {
|
||||
segments.append(.text(currentText))
|
||||
currentText = ""
|
||||
}
|
||||
inTable = true
|
||||
}
|
||||
tableLines.append(line)
|
||||
} else {
|
||||
// Regular text
|
||||
if inTable {
|
||||
// End of table
|
||||
segments.append(.table(tableLines.joined(separator: "\n")))
|
||||
tableLines = []
|
||||
inTable = false
|
||||
}
|
||||
currentText += line + "\n"
|
||||
}
|
||||
}
|
||||
@@ -98,9 +127,13 @@ struct MarkdownContentView: View {
|
||||
segments.append(.codeBlock(language: codeLanguage, code: codeContent))
|
||||
}
|
||||
|
||||
// Handle unclosed table
|
||||
if inTable {
|
||||
segments.append(.table(tableLines.joined(separator: "\n")))
|
||||
}
|
||||
|
||||
// Remaining text
|
||||
if !currentText.isEmpty {
|
||||
// Remove trailing newline
|
||||
if currentText.hasSuffix("\n") {
|
||||
currentText = String(currentText.dropLast())
|
||||
}
|
||||
@@ -113,6 +146,136 @@ struct MarkdownContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Table View
|
||||
|
||||
struct TableView: View {
|
||||
let content: String
|
||||
let fontSize: Double
|
||||
|
||||
private struct TableData {
|
||||
let headers: [String]
|
||||
let alignments: [TextAlignment]
|
||||
let rows: [[String]]
|
||||
}
|
||||
|
||||
private var tableData: TableData {
|
||||
parseTable(content)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let data = tableData
|
||||
|
||||
guard !data.headers.isEmpty else {
|
||||
return AnyView(EmptyView())
|
||||
}
|
||||
|
||||
return AnyView(
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Headers
|
||||
HStack(spacing: 0) {
|
||||
ForEach(data.headers.indices, id: \.self) { index in
|
||||
if index < data.headers.count {
|
||||
Text(data.headers[index].trimmingCharacters(in: .whitespaces))
|
||||
.font(.system(size: fontSize, weight: .semibold))
|
||||
.foregroundColor(.oaiPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: alignmentFor(
|
||||
index < data.alignments.count ? data.alignments[index] : .leading
|
||||
))
|
||||
.padding(8)
|
||||
.background(Color.oaiSecondary.opacity(0.1))
|
||||
|
||||
if index < data.headers.count - 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.stroke(Color.oaiSecondary.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
|
||||
// Rows
|
||||
ForEach(data.rows.indices, id: \.self) { rowIndex in
|
||||
if rowIndex < data.rows.count {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(0..<data.headers.count, id: \.self) { colIndex in
|
||||
let cellContent = colIndex < data.rows[rowIndex].count ? data.rows[rowIndex][colIndex] : ""
|
||||
let alignment = colIndex < data.alignments.count ? data.alignments[colIndex] : .leading
|
||||
|
||||
Text(cellContent.trimmingCharacters(in: .whitespaces))
|
||||
.font(.system(size: fontSize))
|
||||
.foregroundColor(.oaiPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: alignmentFor(alignment))
|
||||
.padding(8)
|
||||
|
||||
if colIndex < data.headers.count - 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(rowIndex % 2 == 0 ? Color.clear : Color.oaiSecondary.opacity(0.05))
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.stroke(Color.oaiSecondary.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(Color.oaiSecondary.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func alignmentFor(_ textAlignment: TextAlignment) -> Alignment {
|
||||
switch textAlignment {
|
||||
case .leading: return .leading
|
||||
case .center: return .center
|
||||
case .trailing: return .trailing
|
||||
}
|
||||
}
|
||||
|
||||
private func parseTable(_ content: String) -> TableData {
|
||||
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
guard lines.count >= 2 else {
|
||||
return TableData(headers: [], alignments: [], rows: [])
|
||||
}
|
||||
|
||||
// Parse headers (first line)
|
||||
let headers = lines[0].components(separatedBy: "|")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
// Parse alignment row (second line with dashes)
|
||||
let alignmentLine = lines[1]
|
||||
let alignments = alignmentLine.components(separatedBy: "|")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
.map { cell -> TextAlignment in
|
||||
let trimmed = cell.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.hasPrefix(":") && trimmed.hasSuffix(":") {
|
||||
return .center
|
||||
} else if trimmed.hasSuffix(":") {
|
||||
return .trailing
|
||||
} else {
|
||||
return .leading
|
||||
}
|
||||
}
|
||||
|
||||
// Parse data rows (remaining lines)
|
||||
let rows = lines.dropFirst(2).map { line in
|
||||
line.components(separatedBy: "|")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
return TableData(headers: headers, alignments: alignments, rows: rows)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Code Block View
|
||||
|
||||
struct CodeBlockView: View {
|
||||
|
||||
@@ -84,19 +84,38 @@ struct MessageRow: View {
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
// Token/cost info
|
||||
if let tokens = message.tokens, let cost = message.cost {
|
||||
// Token/cost/time info
|
||||
if message.role == .assistant && (message.tokens != nil || message.cost != nil || message.responseTime != nil || message.wasInterrupted) {
|
||||
HStack(spacing: 8) {
|
||||
Label("\(tokens)", systemImage: "chart.bar.xaxis")
|
||||
Text("\u{2022}")
|
||||
Text(String(format: "$%.4f", cost))
|
||||
if let tokens = message.tokens {
|
||||
Label("\(tokens)", systemImage: "chart.bar.xaxis")
|
||||
if message.cost != nil || message.responseTime != nil || message.wasInterrupted {
|
||||
Text("\u{2022}")
|
||||
}
|
||||
}
|
||||
if let cost = message.cost {
|
||||
Text(String(format: "$%.4f", cost))
|
||||
if message.responseTime != nil || message.wasInterrupted {
|
||||
Text("\u{2022}")
|
||||
}
|
||||
}
|
||||
if let responseTime = message.responseTime {
|
||||
Text(String(format: "%.1fs", responseTime))
|
||||
if message.wasInterrupted {
|
||||
Text("\u{2022}")
|
||||
}
|
||||
}
|
||||
if message.wasInterrupted {
|
||||
Text("⚠️ interrupted")
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundColor(.oaiSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.padding(16)
|
||||
.background(Color.messageBackground(for: message.role))
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
|
||||
@@ -12,6 +12,8 @@ struct ConversationListView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var searchText = ""
|
||||
@State private var conversations: [Conversation] = []
|
||||
@State private var selectedConversations: Set<UUID> = []
|
||||
@State private var isSelecting = false
|
||||
var onLoad: ((Conversation) -> Void)?
|
||||
|
||||
private var filteredConversations: [Conversation] {
|
||||
@@ -30,13 +32,42 @@ struct ConversationListView: View {
|
||||
Text("Conversations")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
Spacer()
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if isSelecting {
|
||||
Button("Cancel") {
|
||||
isSelecting = false
|
||||
selectedConversations.removeAll()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if !selectedConversations.isEmpty {
|
||||
Button(role: .destructive) {
|
||||
deleteSelected()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "trash")
|
||||
Text("Delete (\(selectedConversations.count))")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
} else {
|
||||
if !conversations.isEmpty {
|
||||
Button("Select") {
|
||||
isSelecting = true
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut(.escape, modifiers: [])
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut(.escape, modifiers: [])
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 20)
|
||||
@@ -81,26 +112,57 @@ struct ConversationListView: View {
|
||||
} 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)
|
||||
HStack(spacing: 12) {
|
||||
if isSelecting {
|
||||
Button {
|
||||
toggleSelection(conversation.id)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
Image(systemName: selectedConversations.contains(conversation.id) ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(selectedConversations.contains(conversation.id) ? .blue : .secondary)
|
||||
.font(.title2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
ConversationRow(conversation: conversation)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if isSelecting {
|
||||
toggleSelection(conversation.id)
|
||||
} else {
|
||||
onLoad?(conversation)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if !isSelecting {
|
||||
Button {
|
||||
exportConversation(conversation)
|
||||
deleteConversation(conversation)
|
||||
} label: {
|
||||
Label("Export", systemImage: "square.and.arrow.up")
|
||||
Image(systemName: "trash")
|
||||
.foregroundStyle(.red)
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.tint(.blue)
|
||||
.buttonStyle(.plain)
|
||||
.help("Delete conversation")
|
||||
}
|
||||
}
|
||||
.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)
|
||||
@@ -123,7 +185,7 @@ struct ConversationListView: View {
|
||||
.onAppear {
|
||||
loadConversations()
|
||||
}
|
||||
.frame(minWidth: 500, minHeight: 400)
|
||||
.frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600)
|
||||
}
|
||||
|
||||
private func loadConversations() {
|
||||
@@ -135,6 +197,29 @@ struct ConversationListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleSelection(_ id: UUID) {
|
||||
if selectedConversations.contains(id) {
|
||||
selectedConversations.remove(id)
|
||||
} else {
|
||||
selectedConversations.insert(id)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteSelected() {
|
||||
for id in selectedConversations {
|
||||
do {
|
||||
let _ = try DatabaseService.shared.deleteConversation(id: id)
|
||||
} catch {
|
||||
Log.db.error("Failed to delete conversation: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
withAnimation {
|
||||
conversations.removeAll { selectedConversations.contains($0.id) }
|
||||
selectedConversations.removeAll()
|
||||
isSelecting = false
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteConversation(_ conversation: Conversation) {
|
||||
do {
|
||||
let _ = try DatabaseService.shared.deleteConversation(id: conversation.id)
|
||||
@@ -167,20 +252,28 @@ struct ConversationListView: View {
|
||||
struct ConversationRow: View {
|
||||
let conversation: Conversation
|
||||
|
||||
private var formattedDate: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "dd.MM.yyyy HH:mm:ss"
|
||||
return formatter.string(from: conversation.updatedAt)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(conversation.name)
|
||||
.font(.headline)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Label("\(conversation.messageCount)", systemImage: "message")
|
||||
.font(.system(size: 13))
|
||||
Text("\u{2022}")
|
||||
Text(conversation.updatedAt, style: .relative)
|
||||
.font(.system(size: 13))
|
||||
Text(formattedDate)
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ struct CommandCategory: Identifiable {
|
||||
|
||||
private let helpCategories: [CommandCategory] = [
|
||||
CommandCategory(name: "Chat", icon: "bubble.left.and.bubble.right", commands: [
|
||||
CommandDetail(
|
||||
command: "/history",
|
||||
brief: "View command history",
|
||||
detail: "Opens a searchable modal showing all your previous messages with timestamps in European format (dd.MM.yyyy HH:mm:ss). Search by text content or date to find specific messages. Click any entry to reuse it.",
|
||||
examples: ["/history"]
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/clear",
|
||||
brief: "Clear chat history",
|
||||
@@ -170,10 +176,11 @@ private let keyboardShortcuts: [(key: String, description: String)] = [
|
||||
("Shift + Return", "New line"),
|
||||
("\u{2318}M", "Model Selector"),
|
||||
("\u{2318}K", "Clear Chat"),
|
||||
("\u{2318}H", "Command History"),
|
||||
("\u{2318}L", "Conversations"),
|
||||
("\u{21E7}\u{2318}S", "Statistics"),
|
||||
("\u{2318},", "Settings"),
|
||||
("\u{2318}/", "Help"),
|
||||
("\u{2318}L", "Conversations"),
|
||||
]
|
||||
|
||||
// MARK: - HelpView
|
||||
|
||||
144
oAI/Views/Screens/HistoryView.swift
Normal file
144
oAI/Views/Screens/HistoryView.swift
Normal file
@@ -0,0 +1,144 @@
|
||||
//
|
||||
// HistoryView.swift
|
||||
// oAI
|
||||
//
|
||||
// Command history viewer with search
|
||||
//
|
||||
|
||||
import os
|
||||
import SwiftUI
|
||||
|
||||
struct HistoryView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var searchText = ""
|
||||
@State private var historyEntries: [HistoryEntry] = []
|
||||
var onSelect: ((String) -> Void)?
|
||||
|
||||
private var filteredHistory: [HistoryEntry] {
|
||||
if searchText.isEmpty {
|
||||
return historyEntries
|
||||
}
|
||||
return historyEntries.filter {
|
||||
$0.input.lowercased().contains(searchText.lowercased()) ||
|
||||
$0.formattedDate.contains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("Command History")
|
||||
.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 by text or date (dd.mm.yyyy)...", 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 filteredHistory.isEmpty {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: searchText.isEmpty ? "list.bullet" : "magnifyingglass")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(searchText.isEmpty ? "No Command History" : "No Matches")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(searchText.isEmpty ? "Your command history will appear here" : "Try a different search term or date")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Spacer()
|
||||
} else {
|
||||
List {
|
||||
ForEach(filteredHistory) { entry in
|
||||
HistoryRow(entry: entry)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onSelect?(entry.input)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 400)
|
||||
.background(Color.oaiBackground)
|
||||
.task {
|
||||
loadHistory()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadHistory() {
|
||||
do {
|
||||
let records = try DatabaseService.shared.loadCommandHistory()
|
||||
historyEntries = records.map { HistoryEntry(input: $0.input, timestamp: $0.timestamp) }
|
||||
} catch {
|
||||
Log.db.error("Failed to load command history: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HistoryRow: View {
|
||||
let entry: HistoryEntry
|
||||
private let settings = SettingsService.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// Input text
|
||||
Text(entry.input)
|
||||
.font(.system(size: settings.dialogTextSize))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(3)
|
||||
|
||||
// Timestamp
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "clock")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(entry.formattedDate)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HistoryView { input in
|
||||
print("Selected: \(input)")
|
||||
}
|
||||
}
|
||||
@@ -27,13 +27,30 @@ struct SettingsView: View {
|
||||
@State private var showOAuthCodeField = false
|
||||
private var oauthService = AnthropicOAuthService.shared
|
||||
|
||||
private let labelWidth: CGFloat = 140
|
||||
private let labelWidth: CGFloat = 160
|
||||
|
||||
// Default system prompt
|
||||
private let defaultSystemPrompt = """
|
||||
You are a helpful AI assistant. Follow these guidelines:
|
||||
|
||||
1. **Accuracy First**: Never invent information or make assumptions. If you're unsure about something, say so clearly.
|
||||
|
||||
2. **Ask for Clarification**: When a request is ambiguous or lacks necessary details, ask clarifying questions before proceeding.
|
||||
|
||||
3. **Be Honest About Limitations**: If you cannot or should not help with a request, explain why clearly and politely. Don't attempt tasks outside your capabilities.
|
||||
|
||||
4. **Stay Grounded**: Base responses on facts and known information. Clearly distinguish between facts, informed opinions, and speculation.
|
||||
|
||||
5. **Be Direct**: Provide concise, relevant answers. Avoid unnecessary preambles or apologies.
|
||||
|
||||
Remember: It's better to ask questions or admit uncertainty than to provide incorrect or fabricated information.
|
||||
"""
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Title
|
||||
Text("Settings")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
@@ -42,6 +59,7 @@ struct SettingsView: View {
|
||||
Text("General").tag(0)
|
||||
Text("MCP").tag(1)
|
||||
Text("Appearance").tag(2)
|
||||
Text("Advanced").tag(3)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal, 24)
|
||||
@@ -58,6 +76,8 @@ struct SettingsView: View {
|
||||
mcpTab
|
||||
case 2:
|
||||
appearanceTab
|
||||
case 3:
|
||||
advancedTab
|
||||
default:
|
||||
generalTab
|
||||
}
|
||||
@@ -80,7 +100,7 @@ struct SettingsView: View {
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.frame(minWidth: 550, idealWidth: 600, minHeight: 500, idealHeight: 650)
|
||||
.frame(minWidth: 700, idealWidth: 800, minHeight: 600, idealHeight: 750)
|
||||
}
|
||||
|
||||
// MARK: - General Tab
|
||||
@@ -106,6 +126,7 @@ struct SettingsView: View {
|
||||
row("OpenRouter") {
|
||||
SecureField("sk-or-...", text: $openrouterKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(size: 13))
|
||||
.onAppear { openrouterKey = settingsService.openrouterAPIKey ?? "" }
|
||||
.onChange(of: openrouterKey) {
|
||||
settingsService.openrouterAPIKey = openrouterKey.isEmpty ? nil : openrouterKey
|
||||
@@ -121,13 +142,13 @@ struct SettingsView: View {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text("Logged in via Claude Pro/Max")
|
||||
.font(.subheadline)
|
||||
.font(.system(size: 14))
|
||||
Spacer()
|
||||
Button("Logout") {
|
||||
oauthService.logout()
|
||||
ProviderRegistry.shared.clearCache()
|
||||
}
|
||||
.font(.subheadline)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
} else if showOAuthCodeField {
|
||||
@@ -144,11 +165,11 @@ struct SettingsView: View {
|
||||
oauthCode = ""
|
||||
oauthError = nil
|
||||
}
|
||||
.font(.subheadline)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
if let error = oauthError {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
} else {
|
||||
@@ -161,13 +182,13 @@ struct SettingsView: View {
|
||||
Image(systemName: "person.circle")
|
||||
Text("Login with Claude Pro/Max")
|
||||
}
|
||||
.font(.subheadline)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
|
||||
Text("or")
|
||||
.font(.caption)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -251,9 +272,6 @@ struct SettingsView: View {
|
||||
))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
row("") {
|
||||
Toggle("Streaming Responses", isOn: $settingsService.streamEnabled)
|
||||
}
|
||||
|
||||
divider()
|
||||
|
||||
@@ -274,7 +292,7 @@ struct SettingsView: View {
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("Controls which messages are written to ~/Library/Logs/oAI.log")
|
||||
.font(.caption)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
@@ -284,10 +302,41 @@ struct SettingsView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var mcpTab: some View {
|
||||
// Enable toggle
|
||||
sectionHeader("MCP")
|
||||
// Description header
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "folder.badge.gearshape")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.blue)
|
||||
Text("Model Context Protocol")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
}
|
||||
Text("MCP gives the AI controlled access to read and optionally write files on your computer. The AI can search, read, and analyze files in allowed folders to help with coding, analysis, and other tasks.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
divider()
|
||||
|
||||
// Enable toggle with status
|
||||
sectionHeader("Status")
|
||||
row("") {
|
||||
Toggle("MCP Enabled (File Access)", isOn: $settingsService.mcpEnabled)
|
||||
Toggle("Enable MCP", isOn: $settingsService.mcpEnabled)
|
||||
}
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: settingsService.mcpEnabled ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(settingsService.mcpEnabled ? .green : .secondary)
|
||||
.font(.system(size: 13))
|
||||
Text(settingsService.mcpEnabled ? "Active - AI can access allowed folders" : "Disabled - No file access")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if settingsService.mcpEnabled {
|
||||
@@ -297,12 +346,22 @@ struct SettingsView: View {
|
||||
sectionHeader("Allowed Folders")
|
||||
|
||||
if mcpService.allowedFolders.isEmpty {
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("No folders added")
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "folder.badge.plus")
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("No folders added yet")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
Text("Click 'Add Folder' below to grant AI access to a folder")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 24)
|
||||
.background(Color.gray.opacity(0.05))
|
||||
.cornerRadius(8)
|
||||
.padding(.horizontal, labelWidth + 24)
|
||||
} else {
|
||||
ForEach(Array(mcpService.allowedFolders.enumerated()), id: \.offset) { index, folder in
|
||||
HStack(spacing: 0) {
|
||||
@@ -314,7 +373,7 @@ struct SettingsView: View {
|
||||
Text((folder as NSString).lastPathComponent)
|
||||
.font(.body)
|
||||
Text(abbreviatePath(folder))
|
||||
.font(.caption)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.leading, 6)
|
||||
@@ -324,7 +383,7 @@ struct SettingsView: View {
|
||||
} label: {
|
||||
Image(systemName: "trash.fill")
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -340,7 +399,7 @@ struct SettingsView: View {
|
||||
Image(systemName: "plus")
|
||||
Text("Add Folder...")
|
||||
}
|
||||
.font(.subheadline)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
Spacer()
|
||||
@@ -362,20 +421,46 @@ struct SettingsView: View {
|
||||
|
||||
// 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)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.system(size: 12))
|
||||
Text("Read access (always enabled)")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("The AI can read and search files in allowed folders")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.leading, 18)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.bottom, 12)
|
||||
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("Write Permissions (optional)")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
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)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
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()
|
||||
@@ -394,7 +479,7 @@ struct SettingsView: View {
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("When enabled, listing and searching skip gitignored files. Write operations always ignore .gitignore.")
|
||||
.font(.caption)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
@@ -411,7 +496,7 @@ struct SettingsView: View {
|
||||
Slider(value: $settingsService.guiTextSize, in: 10...20, step: 1)
|
||||
.frame(maxWidth: 200)
|
||||
Text("\(Int(settingsService.guiTextSize)) pt")
|
||||
.font(.caption)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 40)
|
||||
}
|
||||
@@ -421,7 +506,7 @@ struct SettingsView: View {
|
||||
Slider(value: $settingsService.dialogTextSize, in: 10...24, step: 1)
|
||||
.frame(maxWidth: 200)
|
||||
Text("\(Int(settingsService.dialogTextSize)) pt")
|
||||
.font(.caption)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 40)
|
||||
}
|
||||
@@ -431,18 +516,188 @@ struct SettingsView: View {
|
||||
Slider(value: $settingsService.inputTextSize, in: 10...24, step: 1)
|
||||
.frame(maxWidth: 200)
|
||||
Text("\(Int(settingsService.inputTextSize)) pt")
|
||||
.font(.caption)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Advanced Tab
|
||||
|
||||
@ViewBuilder
|
||||
private var advancedTab: some View {
|
||||
sectionHeader("Response Generation")
|
||||
row("") {
|
||||
Toggle("Enable Streaming Responses", isOn: $settingsService.streamEnabled)
|
||||
}
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("Stream responses as they're generated. Disable for single, complete responses.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
divider()
|
||||
|
||||
sectionHeader("Model Parameters")
|
||||
|
||||
// Max Tokens
|
||||
row("Max Tokens") {
|
||||
HStack(spacing: 8) {
|
||||
Slider(value: Binding(
|
||||
get: { Double(settingsService.maxTokens) },
|
||||
set: { settingsService.maxTokens = Int($0) }
|
||||
), in: 0...32000, step: 256)
|
||||
.frame(maxWidth: 250)
|
||||
Text(settingsService.maxTokens == 0 ? "Default" : "\(settingsService.maxTokens)")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 70, alignment: .leading)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("Maximum tokens in the response. Set to 0 to use model default. Higher values allow longer responses but cost more.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
// Temperature
|
||||
row("Temperature") {
|
||||
HStack(spacing: 8) {
|
||||
Slider(value: $settingsService.temperature, in: 0...2, step: 0.1)
|
||||
.frame(maxWidth: 250)
|
||||
Text(settingsService.temperature == 0.0 ? "Default" : String(format: "%.1f", settingsService.temperature))
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 70, alignment: .leading)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Controls randomness. Set to 0 to use model default.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("• Lower (0.0-0.7): More focused, deterministic")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("• Higher (0.8-2.0): More creative, random")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
divider()
|
||||
|
||||
sectionHeader("System Prompts")
|
||||
|
||||
// Default prompt (read-only)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
HStack(spacing: 4) {
|
||||
Text("Default Prompt")
|
||||
.font(.system(size: 14))
|
||||
.fontWeight(.medium)
|
||||
Text("(always used)")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
ScrollView {
|
||||
Text(defaultSystemPrompt)
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
}
|
||||
.frame(height: 160)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.cornerRadius(6)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("This default prompt is always included to ensure accurate, helpful responses.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Custom prompt (editable)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
HStack(spacing: 4) {
|
||||
Text("Your Custom Prompt")
|
||||
.font(.system(size: 14))
|
||||
.fontWeight(.medium)
|
||||
Text("(optional)")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
TextEditor(text: Binding(
|
||||
get: { settingsService.systemPrompt ?? "" },
|
||||
set: { settingsService.systemPrompt = $0.isEmpty ? nil : $0 }
|
||||
))
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.frame(height: 120)
|
||||
.padding(8)
|
||||
.background(Color(NSColor.textBackgroundColor))
|
||||
.cornerRadius(6)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
Text("Add additional instructions here. This will be appended to the default prompt. Leave empty if you don't need custom instructions.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
divider()
|
||||
|
||||
sectionHeader("Info")
|
||||
HStack {
|
||||
Spacer().frame(width: labelWidth + 12)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("⚠️ These are advanced settings")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.orange)
|
||||
.fontWeight(.medium)
|
||||
Text("Changing these values affects how the AI generates responses. The defaults work well for most use cases. Only adjust if you know what you're doing.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout Helpers
|
||||
|
||||
private func sectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
@@ -450,7 +705,7 @@ struct SettingsView: View {
|
||||
private func row<Content: View>(_ label: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Text(label)
|
||||
.font(.body)
|
||||
.font(.system(size: 14))
|
||||
.frame(width: labelWidth, alignment: .trailing)
|
||||
content()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user