[Swift] 32 ChatGPTアプリ製作 その3 指示文のソート

[M1 Mac, Ventura 13.3.1, Xcode 14.3]

指示文が連続する会話形式ではデータをJSONファイルにするなど保存するデータがややこしくなるため、一問一答形式でブラッシュアップしました。

指示文が作成順にうまく並ばないので、EntityのAttributeに生成日時を追加しこれをキーとしてソートさせています。常に先頭の指示文を送信する仕様になっています。

また、指示済かつ回答受信済の内容については送信ボタンを押すとアラートが表示されます。

保存するデータがEntityの形式に縛られるのが何とももどかしいです。JSONファイルとしてiCloudやローカルに出力できないか調べてみます。

これで土台はできあがったので、後はのんびり進めていきます。気が向いたら会話形式にも着手します。

アラート表示
import SwiftUI
import CoreData

struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext

    @FetchRequest(
        entity: Interaction.entity(),
        sortDescriptors: [NSSortDescriptor(key: "creationDate", ascending: false)])
    private var pairs: FetchedResults<Interaction>

    @State var isShowAlert = false

    var body: some View {
        NavigationView {
            VStack{
                List {
                    ForEach(pairs) { pair in
                        NavigationLink{
                            if((pair.instruction?.isEmpty) == false){
                                Draft(text:pair.instruction!, interaction: pair)
                            }
                        }
                        label:{
                            if((pair.instruction?.isEmpty) == false){
                                Text(pair.instruction!)
                            }
                        }
                    }

                    .onDelete(perform: deleteInteraction)
                }
                .navigationTitle("ChatGPTSwift")
                .navigationBarTitleDisplayMode(.inline)
                .toolbar{
                    // 新規作成
                    ToolbarItem(placement:.navigationBarTrailing){
                        NavigationLink{
                            Draft()
                        }label:{
                            Text("+")
                                .font(.system(size: 30))
                        }
                    }
                }

                List {
                    ForEach(pairs, id: \.self) { pair in
                        if let res = pair.res , !res.isEmpty {
                            Text(res)
                            .foregroundColor(.white)
                            .background(Color.blue)
                        }
                    }


                    .onDelete(perform: deleteInteraction)
                }

                Button(action:{
                    if pairs.first != nil{
                        let instruction = pairs.first
                        
                        if instruction!.res == nil{
                            sendRequest(pairs:pairs)
                        }else{
                            isShowAlert = true
                            print("指示文を入力して下さい")
                        }
                        
                    }else{
                        print("pairsは空です")
                        
                    }
                }){
                    Text("送信")
                    .font(.system(size: 24))
                }
                .alert("指示文を入力して下さい", isPresented: $isShowAlert) {
                    Button("OK") {
                    }
                } message: {
                    Text("")
                }
            }
        }
    }
<以下略>
Core Data(Data Model)の内容
creationDateを追加

[Swift] 31 ChatGPTアプリ製作 その2 HTTPRequest

[M1 Mac, Ventura 13.3.1, Xcode 14.3]

GPT-3.5のAPIとやりとりできるようになりました。

ChatGPTの力を借り色々試行錯誤しながらで約半日掛かりました。取りあえず送受信を1往復できるようにしています。

チャットのように交互に表示させるのは難しいため、上下段に振り分けました。

次回以降、複数回の送受信に対応させます。

OpenAIがiOS, iPadOSアプリを5/26にリリースしているので、比較しながら機能を増やしていきたいです。

iOS版
iPadOS版
import SwiftUI
import CoreData

struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext
    @FetchRequest(sortDescriptors:[])
    private var pairs: FetchedResults<Interaction>

    var body: some View {
        NavigationView {
            VStack{
                List {
                    ForEach(pairs) { pair in
                        NavigationLink{
                            if((pair.instruction?.isEmpty) == false){
                                Draft(text:pair.instruction!, interaction: pair)
                            }
                        }
                        label:{
                            if((pair.instruction?.isEmpty) == false){
                                Text(pair.instruction!)
                            }
                        }
                    }

                    .onDelete(perform: deleteInteraction)
                }
                .navigationTitle("ChatGPT リクエスト")
                .navigationBarTitleDisplayMode(.inline)
                .toolbar{
                    // 新規リクエスト作成
                    ToolbarItem(placement:.navigationBarTrailing){
                        NavigationLink{
                            Draft()
                        }label:{
                            Text("+")
                        }
                    }
                }

                List {
                    ForEach(pairs, id: \.self) { pair in
                        if let res = pair.res, !res.isEmpty {
                            Text(res)
                                .foregroundColor(.white)
                                .background(Color.blue)
                        }
                    }


                    .onDelete(perform: deleteInteraction)
                }

                Button(action:{
                    if pairs.last != nil{
                        sendRequest()
                    }else{
                        print("pairsは空です")
                        
                    }
                }){
                    Text("送信")
                    .font(.system(size: 24))
                }
            }
        }
    }

    func deleteInteraction(offsets:IndexSet){
        for offset in offsets{
            viewContext.delete(pairs[offset])
        }
            
        do{
            try viewContext.save()
        }catch{
            fatalError("セーブに失敗")
        }
    }

    func sendRequest(){
        let urlAPI = "https://api.openai.com/v1/chat/completions";
        let apiKey = "API key";
        let model = "gpt-3.5-turbo"
        let systemStr: String = "あなたは根拠が明確に存在することのみ発言するチャットボットです。"

        let authHeader = "Bearer \(apiKey)"
        var headers = [String: String]()
        headers["Authorization"] = authHeader
        headers["Content-Type"] = "application/json"

        guard let url = URL(string: urlAPI) else {
            fatalError("Invalid URL")
        }

        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.allHTTPHeaderFields = headers

        // requestData作成・送受信
        if let lastInteraction = pairs.last {
            if let instruction:String? = lastInteraction.instruction {
                print("instruction:")
                print(instruction)

                let requestData:String = "{\"model\":\"\(model)\", \"messages\":[{\"role\":\"system\",\"content\":\"\(systemStr)\"},{\"role\":\"user\",\"content\":\"\(instruction!)\"}], \"temperature\":0.0}";
                print(requestData)
                
                var responseData: [String: Any] = [:]
                responseData = sendHTTPRequest(url: url, headers: headers, requestData: requestData)
                
                print(responseData)

                if let choices = responseData["choices"] as? [[String: Any]],
                let message = choices.first?["message"] as? [String: Any],
                var content = message["content"] as? String {
                    print(content)
                    lastInteraction.res = content
                }

            } else {
                print("instruction is nil")
                return
            }
        } else {
            print("pairs is empty")
            return
        }
    }

    func sendHTTPRequest(url: URL, headers: [String: String], requestData: String) -> [String: Any] {
        var responseData: [String: Any] = [:]
        var timeoutBool = false

        let semaphore = DispatchSemaphore(value: 0)

        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.allHTTPHeaderFields = headers
        request.httpBody = requestData.data(using: .utf8) // requestDataをData型に変換

        let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
            if let error = error {
                timeoutBool = true
                print("HTTP request failed: \(error)")
                return
            }

            if let data = data {
                do {
                    responseData = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] ?? [:]
                } catch {
                    print("Failed to parse response data: \(error)")
                }
            }

            semaphore.signal()
        }

        task.resume()
        _ = semaphore.wait(timeout: DispatchTime.now() + 90)

        if timeoutBool {
            <中略>
            return [:]
        }

        return responseData
    }

}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
Core Data(Data Model)の内容

[Swift] 30 関数定義にあるアンダースコアの意味

[M1 Mac, Ventura 13.3.1, Xcode 14.3]

前から関数の定義で引数の所にあるアンダースコアが気になっていたので調べてみました。

アンダースコアを付けると関数を使用する際に引数ラベルを省略できるそうです。これには驚きました。普通はアンダースコアなしでも引数ラベルはいらないものですが。

これのおかげでコードの見栄えがどっちにしても悪くなるんですが、Appleにすればどうでもいいようです。

ハードやOSの見た目にはこだわるのに、開発者にだけ見える部分には無頓着ですね。

# 引数にvalueを必ず付ける
func intToString(value: Int) -> String {
    return String(value)
}

print(intToString(value:1))

# 引数にvalueを付けなくてもよい
func intToString2(_ value: Int) -> String {
    return String(value)
}

print(intToString2(1))

出力
----------
1
1
----------

[Swift] 29 ChatGPTアプリ製作 その1 クリップボード貼付

[M1 Mac, Ventura 13.3.1, Xcode 14.3]

ChatGPTアプリiOS版の製作に着手しました。

取りあえずリクエスト文を入力できるようにしました。メモアプリのコードを一部流用しています。

クリップボードからテキストをペーストできます。macOSなどからOSをまたいでのペーストは次回以降対応するつもりです。

次にこれをAPIに送信してレスポンスを表示するようにします。

2023/7/4追記
TextEditorのタップ時にPasteやSelect Allなどが選択できるため、緑字のペーストボタンは削除しました。

アプリ画面
TextEditor画面
import SwiftUI

struct Draft: View {
    @State var text = ""
    @FocusState var nameFieldIsForcused: Bool
    @Environment(\.managedObjectContext)var viewContext
    var interaction: Interaction?

    var body: some View {
        TextEditor(text:$text)
            .frame(minHeight: 0, maxHeight: .infinity) // 行数無制限
            .focused($nameFieldIsForcused)
            .onAppear{
                DispatchQueue.main.asyncAfter(deadline:DispatchTime.now()+0.5){
                    nameFieldIsForcused = true
                }
            }
            .toolbar{
                ToolbarItem(placement:.navigationBarLeading){
                    if(nameFieldIsForcused){
                        Button(action:{
                            pasteText()

                        }){
                            Text("ペースト")
                            .foregroundColor(.green)
                        }
                    }
                }

                ToolbarItem(placement:.navigationBarTrailing){
                    if(nameFieldIsForcused){
                        Button(action:{
                            if interaction != nil{
                                updateContent(interaction: self.interaction!)
                            }else{
                                addContent()
                            }

                        }){
                            Text("完了")
                        }
                    }
                }
            }
    }

    func updateContent(interaction:Interaction){
        interaction.request = text
        
        do{
            try viewContext.save()
        }catch{
            fatalError("セーブに失敗")
        }

        nameFieldIsForcused = false
    }

    func addContent(){
        let newContent = Interaction(context:viewContext)
        newContent.request = text
        
        do{
            try viewContext.save()
            
        }catch{
            fatalError("セーブに失敗")
        }

        nameFieldIsForcused = false

    }

    func pasteText(){
        let pasteboard = UIPasteboard.general

        if let clipboardString = pasteboard.string {
            text += clipboardString
        }
    }
}

struct Draft_Previews: PreviewProvider {
    static var previews: some View {
        Draft()
    }
}

[Swift] 28 メモアプリ製作 その9 Apple Watchで編集 

[M1 Mac, Ventura 13.3.1, Xcode 14.3]

製作中のメモアプリがApple Watchでは読み取りだけだったのを編集できるようにしました。

watchOSではTextEditorは使えないため、代わりにTextFieldを使っています。ただTextfieldでは複数行を扱えないので、全ての行がつながってしまいます。

またApple Watch Series 7/8/Ultra以外はキーボード入力ではなく音声入力になります。

発売から8年が経過しているというのに未だにTextEditorが使えないとは。Vision ProだけではなくwatchOSにも力を注いで欲しいです。今のところ、自分で代替の構造体を作るしかないようです。

GoogleのWearOSがどのような内容なのか気になります。そろそろFlutterでGoogle Pixel Watch用アプリも作りたくなってきました。

AppleWatchに表示
タップすると1行に
つながって表示される
さらにタップして入力
(SE第1世代なので音声入力)
import SwiftUI

struct DraftAppleWatch: View {
    @State var text = ""
    @FocusState var nameFieldIsForcused: Bool
    @Environment(\.managedObjectContext)var viewContext
    var note: Note?

    var body: some View {
        VStack {
            TextField("", text: $text)
                .focused($nameFieldIsForcused)
                .onAppear{
                    DispatchQueue.main.asyncAfter(deadline:DispatchTime.now()+0.5){
                        nameFieldIsForcused = true
                    }
                }

            Button(action:{
                if note != nil{
                    updateContent(note: self.note!) // メモ編集
                }else{
                    addContent() // 新規メモ追加
                }
            }){
                Text("完了")
            }
        }

    }

    func updateContent(note:Note){
        note.content=text
        
        do{
            try viewContext.save()
        }catch{
            fatalError("セーブに失敗")
        }

        nameFieldIsForcused = false
    }

    func addContent(){
        let newContent = Note(context:viewContext)
        newContent.content = text
        
        do{
            try viewContext.save()
            
        }catch{
            fatalError("セーブに失敗")
        }

        nameFieldIsForcused = false

    }


}


struct DraftAppleWatch_Previews: PreviewProvider {
    static var previews: some View {
        DraftAppleWatch()
    }
}

[Swift] 27 iOSアップデート時のWatch Appトラブル

[M1 Mac, Ventura 13.3.1, Xcode 14.3]

iOSとwatchOSをそれぞれ16.5.1、9.5.2にアップデートしたところWatch Appが消えてしまいました。Watch App with iOS Appの方は残っています。

自製Watch Appを全て消して再インストールしようとするとApple Watchを認識できないとか色々難癖を付けてきます。

結局Mac、iPhone、Apple Watchを全て再起動すると直りました。最初からそう言ってほしいものです。

この手のトラブルは以前から発生しているようでApple開発者フォーラムでも非難囂々です。ちなみにMacとiPhoneはUSBハブ経由ではなく直接つなぐ方がいいらしいです。

[Swift] 26 Apple WatchのComplication改良 カレンダー / 元号を追加

[M1 Mac, Ventura 13.3.1, Xcode 14.3]

改良カレンダーをタップした時の表示に元号を追加しました。フォントサイズを大きくすると赤色でも問題なく読めます。

今回は日本固有の内容だったためかChatGPTの回答がかなり怪しく、大幅な修正を加えました。

import SwiftUI

struct ContentView: View {
    let date: Date
    
    var body: some View {
        VStack (spacing: 15){
            Text(getFormattedDate())
            .font(.system(size: 30))
            .foregroundColor(.green)
            
            Text(getFormattedYear())
            .font(.system(size: 30))
            .foregroundColor(.red)
            
            Text(getFormattedWeekday() + "曜日")
            .font(.system(size: 30))
            .foregroundColor(.yellow)
            
            Text(getFormattedTime())
            .font(.system(size: 30))
            .foregroundColor(.blue)

        }
        .padding()
    }
    
    func getFormattedDate() -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy/MM/dd"
        return dateFormatter.string(from: date)
    }
    
    func getFormattedYear() -> String {
        let calendar = Calendar(identifier: .japanese)
        let year = calendar.component(.year, from: date)
        return "令和" + String(year) + "年"
    }

    
    func getFormattedWeekday() -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "E"
        dateFormatter.locale = Locale(identifier: "ja_JP")
        return dateFormatter.string(from: date)
    }
    
    func getFormattedTime() -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "HH:mm:ss"
        return dateFormatter.string(from: date)
    }
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(date: Date())
    }
}

[Swift] 25 Apple WatchのComplication改良 カレンダー / 時計の針で読めない際の対策

[M1 Mac, Ventura 13.3.1, Xcode 14.3]

Complicationの改良カレンダーですが、アナログ時計のため時針や分針が重なると読みにくくなります。

カレンダーが読みにくい

そこでタップすると日時を表示するようにしました。デジタル時計は止まったまま動きません。

タップすると表示
import SwiftUI

struct ContentView: View {
    let date: Date
    
    var body: some View {
        VStack (spacing: 20){
            Text(getFormattedDate())
            .font(.system(size: 30))
            .foregroundColor(.green)
            
            Text(getFormattedWeekday() + "曜日")
            .font(.system(size: 30))
            .foregroundColor(.yellow)
            
            Text(getFormattedTime())
            .font(.system(size: 30))
            .foregroundColor(.blue)

        }
        .padding()
    }
    
    func getFormattedDate() -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy/MM/dd"
        return dateFormatter.string(from: date)
    }
    
    func getFormattedWeekday() -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "E"
        dateFormatter.locale = Locale(identifier: "ja_JP")
        return dateFormatter.string(from: date)
    }
    
    func getFormattedTime() -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "HH:mm:ss"
        return dateFormatter.string(from: date)
    }
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(date: Date())
    }
}

[Swift] 24 Apple WatchのComplication改良 カレンダー / 月表示追加

[M1 Mac, Ventura 13.3.1, Xcode 14.3]

改良カレンダーのフォントサイズを調整して3行にし、月表示を追加しました。

ChatGPTが考えたコードを微修正しています。

struct DateToolComplicationEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        VStack (spacing: -5){
            Text(getWeekday(entry.date))
            .font(.system(size: 18))
            .foregroundColor(.yellow)
             
            Text(getMonth(entry.date))
            .font(.system(size: 18))
            .foregroundColor(.green)

            Text(getDay(entry.date))
            .font(.system(size: 20))
            .foregroundColor(.white)
        }
    }
    
    func getWeekday(_ date: Date) -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "E"
        dateFormatter.locale = Locale(identifier: "ja_JP")
        return dateFormatter.string(from: date)
    }
    
    func getMonth(_ date: Date) -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "M"
        return dateFormatter.string(from: date)
    }

    func getDay(_ date: Date) -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "d"
        return dateFormatter.string(from: date)
    }

}