import SwiftUI import AVFoundation // 用於語音合成 import Combine // 用於 ObservableObject // 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 var isMuted: Bool = false func speak(_ text: String) { if synthesizer.isSpeaking { synthesizer.stopSpeaking(at: .immediate) } if isMuted { return } let utterance = AVSpeechUtterance(string: text) utterance.rate = 0.5 let allVoices = AVSpeechSynthesisVoice.speechVoices() if let maleVoice = allVoices.first(where: { $0.language == "zh-TW" && $0.gender == .female }) { utterance.voice = maleVoice } else { 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" // MARK: 新增 - 預設系統提示詞 private let systemPrompt = "你是一個專業的專業助理,請全程使用繁體中文(台灣)。【絕對禁止】嚴格禁止使用任何表情符號(Emoji)、顏文字或圖示。回答時僅輸出純文字內容。" 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) .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 guard let url = URL(string: "http://127.0.0.1:11434/api/chat") else { return } // MARK: 修改處 - 組合系統提示詞與使用者訊息 // 將系統提示放在第一句,Ollama 會將其視為最高指導原則 let apiMessages = [ OllamaMessage(role: "system", content: systemPrompt), 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 { messages.append(ChatMessage(content: "連線錯誤: \(error.localizedDescription)", 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)) 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() }