import SwiftUI import AVFoundation // 用於語音合成 import Combine // 匯入此框架才能使用 ObservableObject 與 @Published // MARK: - 1. 資料模型與結構 struct ChatMessage: Identifiable, Equatable { let id = UUID() let content: String let isUser: Bool } struct OllamaRequest: Encodable { let model: String let messages: [OllamaMessage] let stream: Bool } struct OllamaMessage: Codable { let role: String let content: String } struct OllamaResponse: Decodable { let message: OllamaMessage } // MARK: - 2. 語音管理器 class SpeechManager: ObservableObject { private let synthesizer = AVSpeechSynthesizer() // @Published 需要 Combine 框架支援 @Published var isMuted: Bool = false func speak(_ text: String) { // 如果正在說話,先停止 if synthesizer.isSpeaking { synthesizer.stopSpeaking(at: .immediate) } if isMuted { return } let utterance = AVSpeechUtterance(string: text) // 設定語速 (0.0 ~ 1.0),0.5 標準,稍微調慢一點比較自然 utterance.rate = 0.5 // 設定語系:嘗試使用台灣繁體中文,若系統不支援會使用預設,如果希望唸英文比較標準,可以改為 "en-US" utterance.voice = AVSpeechSynthesisVoice(language: "zh-TW") synthesizer.speak(utterance) } func stop() { synthesizer.stopSpeaking(at: .immediate) } } // MARK: - 3. 主視圖 (ContentView) struct ContentView: View { @State private var inputText: String = "" @State private var messages: [ChatMessage] = [] @State private var isLoading: Bool = false // 初始化語音管理器 @StateObject private var speechManager = SpeechManager() private let modelName = "gemma3:4b" var body: some View { ZStack { // 背景:深色風格 Color(red: 0.1, green: 0.1, blue: 0.12) .ignoresSafeArea() VStack(spacing: 0) { // A. 頂部標題列 HStack { Text("Ollama Chat") .font(.headline) .foregroundColor(.gray) Spacer() // 靜音按鈕開關 Button(action: { speechManager.isMuted.toggle() if speechManager.isMuted { speechManager.stop() } }) { Image(systemName: speechManager.isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") .foregroundColor(speechManager.isMuted ? .gray : .purple) .padding(8) .background(Color.black.opacity(0.3)) .clipShape(Circle()) } .help(speechManager.isMuted ? "開啟語音" : "靜音") // 模型標籤 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() } .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) .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) } // MARK: - 4. 邏輯功能 func sendMessage() { let cleanInput = inputText.trimmingCharacters(in: .whitespacesAndNewlines) guard !cleanInput.isEmpty else { return } // 停止上一句語音 speechManager.stop() let userMsg = cleanInput inputText = "" messages.append(ChatMessage(content: userMsg, isUser: true)) isLoading = true // 準備 API 請求 // 注意:iOS 模擬器請用 127.0.0.1,macOS App 若有開 Sandbox 也要設權限 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) URLSession.shared.dataTask(with: request) { data, response, error in DispatchQueue.main.async { isLoading = false if let error = error { let errMsg = "連線錯誤: \(error.localizedDescription)" messages.append(ChatMessage(content: errMsg, isUser: false)) return } guard let data = data, let result = try? JSONDecoder().decode(OllamaResponse.self, from: data) else { messages.append(ChatMessage(content: "解析錯誤", isUser: false)) return } let aiResponse = result.message.content messages.append(ChatMessage(content: aiResponse, isUser: false)) // MARK: 觸發語音朗讀 speechManager.speak(aiResponse) } }.resume() } } // MARK: - 5. 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) .textSelection(.enabled) if !message.isUser { Spacer() } } } } struct TypingIndicator: View { var body: some View { HStack { Text("Gemma is thinking...") .font(.caption) .italic() .foregroundColor(.gray) Spacer() } .padding(.leading) } } #Preview { ContentView() }