[Swift] 40 ChatGPTアプリ製作 その4 Apple Watchで使用

[M1 Mac, Ventura 13.3.1, Xcode 14.3]

Apple WatchでもChatGPTを使えるようにしました。一問一答形式です。

ただしiCloudにデータを上手く保存できず、それぞれのデバイスのローカルに保存されます。

CoreData: error: CoreData+CloudKit: -[NSCloudKitMirroringDelegate recoverFromError:](2224): <NSCloudKitMirroringDelegate: 0x600003d780e0> - Attempting recovery from error: <CKError 0x600000a14630: 
"Partial Failure" (2/1011); "Failed to modify some record zones"; 
"Server Rejected Request"

[Swift] 39 Apple WatchのComplication改良 複数のComplication作成

[M1 Mac, Ventura 13.3.1, Xcode 14.3]

自製カレンダーアプリのComplicationを純正アプリと同様にラベル有りとラベル無しの2種類作成しました。

TargetとしてWidget Extensionを増やすだけなので特に問題なくできますが、作成時に”Include Configuration Intent”にチェックを入れないようにします。チェックを入れるとそのままではコマンドの重複が発生しビルドエラーになります。

Complication選択画面
@main
struct DateToolComplicationLabel: Widget {
    let kind: String = "DateToolComplicationLabel"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            DateToolComplicationLabelEntryView(entry: entry)
        }
        .configurationDisplayName("Date ラベル付き")
        .description("")
        .supportedFamilies([.accessoryCircular,.accessoryCorner,.accessoryRectangular,.accessoryInline])
     }

}

[Swift] 38 メモアプリ製作 その11 Apple Watchで新規メモ作成

[M1 Mac, Ventura 13.3.1, Xcode 14.3]

メモアプリのwatchOS版では既存のメモを編集できるだけでした。さらに新規メモを作成できるようにしました。TextFieldなので1行の文字列として入力することになります。

後でiPhoneなどで改行を入れ整形する必要がありますが、とりあえずメモしたい時に便利です。

ToolbarItemのplacementを.automaticにして正常に配置できました。本当はプラスボタンをbottomに置きたいのですが、watchOS 9では不可のようです。watchOS 10で新機能として追加されるという情報を目にしました。本当だとしたらありがたいです。

これで大体完成と言ったところでしょうか。

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

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

    var body: some View {
        NavigationView{
            List{
                ForEach(contents){content in
                    NavigationLink{
                        if((content.content?.isEmpty) == false){
                            DraftAppleWatch(text:content.content!, note: content)
                        }
                    }label:{
                        if((content.content?.isEmpty) == false){
                            Text(content.content!)
                        }
                    }
                }
                .onDelete(perform:deleteContent)
            }
            .navigationTitle("メモリスト")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar{
                ToolbarItem(placement:.automatic){
                    NavigationLink{
                        DraftAppleWatch()
                    }label:{
                        Text("+")
                        .font(.system(size: 24))
                    }
                }
            }
        }
        .accentColor(.blue)
    }
<以下略>

[Swift] 37 Apple WatchのComplication改良 しばらく経つと消える 原因判明

[M1 Mac, Ventura 13.3.1, Xcode 14.3]

自製カレンダーアプリのComplicationがしばらく経ってアプリごと消えてしまう原因が判明しました。

どうやらXcodeのバグのようです。

iOSのWatchアプリを開いて”APPLE WATCH上にインストール済み”が表示された瞬間にそこからカレンダーアプリが消えていくのを目撃しました。再現性も確認しています。

この画面になった瞬間にアプリが消えていった

アプリそのものに関する設定に問題があるのではと考え、調べていくとBundle Identiferが不完全な表記になっているのが分かりました。これはwatchOSアプリのプロジェクト作成時に正しく設定しても反映されていないことを意味します。

最初にiOSアプリのプロジェクトを作成し、watchOSアプリ、Widget Extensionと順にターゲットを増やしていくとこのような問題は起こりません。メモアプリはこの手順だったためトラブルにはなりませんでした。

さすがにこれは酷すぎるのでAppleにバグ報告します。

正直こんなことで振り回されたくない。Flutterでこの種の不毛な作業をせずに済むのであれば本気で移行したいです。

アプリのカテゴリーは異なりますが、VSCodeでこのような不具合はあり得ないです。Xcodeへの信頼を著しく損なう事になり残念至極。

プロジェクト作成時はBundle Identiferに特に問題はない
後でBundle Identiferを確認すると.watchkitappに勝手に変わっている
これでは固有IDとして機能しない

[Swift] 36 Apple WatchのComplication改良 しばらく経つと消える

[M1 Mac, Ventura 13.3.1, Xcode 14.3]

カレンダーアプリのComplicationがしばらく経つとアプリごと消えてしまいます。同様にComplication設定しているメモアプリは無事です。

“インフォグラフ”文字盤の中央上部は純正カレンダー以外のアプリを受け付けないのかもしれません。

中央上部は純正カレンダー、右下は自製カレンダーに設定して様子を見ます。

これで消えたら以下の対策を順次試してみます。
1.プロジェクトから作り直し
2.CloudKitを導入しDateなどを適当に保存
3.文字盤を作成
4.正式にアプリ登録する(非公開)

中央上部と右下に設定
しばらくすると消える
(経過時間は不定)
この設定で様子を見る
struct ComplicationCircular : View {
    @Environment(\.showsWidgetLabel) var showsWidgetLabel
    var entry: Provider.Entry
    
    var body: some View {
        VStack (spacing: -6){
            if showsWidgetLabel {
                Text(getWeekday(entry.date))
                .font(.system(size: 18))
                .foregroundColor(.yellow)
                .widgetLabel {
                    Text(getFormattedDate() + getFormattedWeekday() + getFormattedYear())
                    .foregroundColor(.blue) // 中央上部は色設定不可
                }
                
                Text(getMonth(entry.date))
                .font(.system(size: 18))
                .foregroundColor(.green)
                
                Text(getDay(entry.date))
                .font(.system(size: 18))
                .foregroundColor(.white)
                
            } else {
                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: 18))
                .foregroundColor(.white)
            }
        }
    }
<以下略>

[Swift] 35 Apple WatchのComplication改良 accessoryCorner

[M1 Mac, Ventura 13.3.1, Xcode 14.3]

ComplicationのaccessoryCornerにも日付を表示できるようにしました。

ただし現時点では文字盤に沿って円弧状に表示できるのはラベル[23/07/08(土)]だけで本体[令5]は水平のままです。

詳しくは書けませんが、次期watchOSで何らかの進化があるようです。

中央上部と右下に表示
struct ComplicationCorner : View {
    var entry: Provider.Entry
    
    var body: some View {
        Text(getFormattedYear())
        .font(.system(size: 22))
            .foregroundColor(.green)
            .widgetLabel {
                Text(getFormattedDate() + getFormattedWeekday())
                .foregroundColor(.yellow)
            }
     }
    
    func getFormattedDate() -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yy/MM/dd"
        return dateFormatter.string(from: entry.date)
    }
    
    func getFormattedWeekday() -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "E"
        dateFormatter.locale = Locale(identifier: "ja_JP")
        return "(" + dateFormatter.string(from: entry.date) + ")"
    }
    
    func getFormattedYear() -> String {
        let calendar = Calendar(identifier: .japanese)
        let year = calendar.component(.year, from: entry.date)
        return "令" + String(year)
    }
    
}

struct DateToolComplicationEntryView : View {
    @Environment(\.widgetFamily) var widgetFamily
    var entry: Provider.Entry
    
    var body: some View {
        switch widgetFamily {
            case .accessoryCorner:
                ComplicationCorner(entry: entry)
            case .accessoryCircular:
                ComplicationCircular(entry: entry)
            case .accessoryInline:
                ComplicationInline()
            case .accessoryRectangular:
                ComplicationRectangular()
            @unknown default:
                Text("Not an implemented widget yet")
        }
    }
}

[Swift] 34 メモアプリ製作 その10 NSMergeConflict対策

[M1 Mac, Ventura 13.3.1, Xcode 14.3]

メモアプリのメモを追加し、アプリを再起動させてから削除しようとすると1回目は必ずエラーになっていました。

エラーメッセージではNSMergeConflictとなっていてCore Dataの不一致が起こっているようでした。ネットでは類似の情報は見当たらず、試しに削除後すぐにviewContextをリフレッシュしてから保存するようにコードを書き換えると上手くいきました。

他言語ではありますが、UIに関するコードをこれまで散々書いてきた経験が活きた格好です。深みにハマらなくて助かりました。

func deleteContent(offsets:IndexSet){
        for offset in offsets{
            print("offset:\(offset)")
            viewContext.delete(contents[offset])
        }

        // Core Data(Data Model)を更新
        viewContext.refreshAllObjects() // この行を追加して解決

        do {
            try viewContext.save()
        } catch let error as NSError {
            fatalError("セーブ失敗: \(error), \(error.userInfo)")
        }
    }

[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)の内容