import SwiftUI // 1. 資料模型與結構 // 定義對話訊息結構 struct ChatMessage: Identifiable, Equatable { let id = UUID() let content: String let isUser: Bool } // Ollama API 請求結構 struct OllamaRequest: Encodable { let model: String let messages: [OllamaMessage] let stream: Bool } struct OllamaMessage: Codable { let role: String let content: String } // Ollama API 回應結構 struct OllamaResponse: Decodable { let message: OllamaMessage } // 2. 主視圖 (ContentView) struct ContentView: View { // 狀態變數 @State private var inputText: String = "" @State private var messages: [ChatMessage] = [] @State private var isLoading: Bool = false // 設定模型名稱 private let modelName = "gemma3:4b" var body: some View { ZStack { // 背景:深色風格 (Dark Theme) Color(red: 0.1, green: 0.1, blue: 0.12) .ignoresSafeArea() VStack(spacing: 0) { // A. 頂部標題列 HStack { Text("Ollama Chat") .font(.headline) .foregroundColor(.gray) Spacer() // 顯示當前使用的模型 Text(modelName) .font(.caption) .padding(6) .background(Color.purple.opacity(0.3)) .cornerRadius(8) .foregroundColor(.purple) } .padding() .background(Color.black.opacity(0.5)) // B. 訊息顯示捲動區 ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 12) { ForEach(messages) { msg in MessageBubble(message: msg) } // 如果正在讀取顯示思考中動畫 if isLoading { TypingIndicator() } } .padding() } // MARK: FIX - 修正 onChange 語法 (macOS 14+ / iOS 17+) .onChange(of: messages) { _, _ in if let lastId = messages.last?.id { withAnimation { proxy.scrollTo(lastId, anchor: .bottom) } } } } // C. 底部輸入區 HStack(spacing: 10) { TextField("輸入訊息...", text: $inputText) .padding(10) .background(Color(white: 0.2)) .cornerRadius(20) .foregroundColor(.white) // 自定義 Placeholder 顏色 .overlay( HStack { if inputText.isEmpty { Text("Ask Gemma...") .foregroundColor(.gray) .padding(.leading, 12) .allowsHitTesting(false) } Spacer() } ) .onSubmit { sendMessage() } // 發送按鈕 Button(action: sendMessage) { Image(systemName: "paperplane.fill") .font(.system(size: 20)) .foregroundColor(inputText.isEmpty ? .gray : .purple) .padding(10) .background(Color(white: 0.2)) .clipShape(Circle()) } .disabled(inputText.isEmpty || isLoading) } .padding() .background(Color.black.opacity(0.8)) } } // 強制使用深色模式 .preferredColorScheme(.dark) } // 3. 邏輯功能 func sendMessage() { // 移除空白並檢查是否有內容 let cleanInput = inputText.trimmingCharacters(in: .whitespacesAndNewlines) guard !cleanInput.isEmpty else { return } // 1. 顯示使用者訊息 let userMsg = cleanInput inputText = "" // 清空輸入框 messages.append(ChatMessage(content: userMsg, isUser: true)) isLoading = true // 2. 準備 API 請求資料 // 注意!使用 127.0.0.1 替代 localhost 在某些網路環境下較穩定 guard let url = URL(string: "http://127.0.0.1:11434/api/chat") else { return } let apiMessages = [OllamaMessage(role: "user", content: userMsg)] let requestBody = OllamaRequest( model: modelName, messages: apiMessages, stream: false // 暫時關閉串流,一次顯示完整結果 ) var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try? JSONEncoder().encode(requestBody) // 3. 發送請求 URLSession.shared.dataTask(with: request) { data, response, error in DispatchQueue.main.async { isLoading = false // 錯誤處理:網路錯誤 if let error = error { messages.append(ChatMessage(content: "連線錯誤: \(error.localizedDescription)\n(請檢查設定)", isUser: false)) return } // 錯誤處理:資料解析 guard let data = data, let result = try? JSONDecoder().decode(OllamaResponse.self, from: data) else { messages.append(ChatMessage(content: "解析錯誤:無法讀取 Ollama 回傳的資料。", isUser: false)) return } // 成功:顯示 AI 回應 let aiResponse = result.message.content messages.append(ChatMessage(content: aiResponse, isUser: false)) } }.resume() } } // 4. UI 元件 (氣泡與動畫) struct MessageBubble: View { let message: ChatMessage var body: some View { HStack { if message.isUser { Spacer() } Text(message.content) .padding(12) .background(message.isUser ? Color.purple : Color(white: 0.2)) .foregroundColor(.white) .cornerRadius(16) // 限制氣泡最大寬度,並依據傳送者對齊 .frame(maxWidth: 300, alignment: message.isUser ? .trailing : .leading) if !message.isUser { Spacer() } } } } struct TypingIndicator: View { var body: some View { HStack { Text("Gemma is thinking...") .font(.caption) .italic() .foregroundColor(.gray) Spacer() } .padding(.leading) } } // Xcode 預覽區 #Preview { ContentView() }