spb/poche Public
Agent personnel 100 % on-device — SwiftUI + Apple Foundation Models + EventKit + SwiftData. Aucune API, aucun serveur.
Swift 100%
1//2// ChatViewModel.swift3// Poche4//5// Author: Simon-Pierre Boucher6// Contact: contact@spboucher.ai7//89import Foundation10import Observation1112@MainActor13@Observable14final class ChatViewModel {15 private(set) var turns: [ChatTurn] = []16 var draft = ""1718 private let agent: AgentSession19 private let confirm: ConfirmCenter20 private let store: PocheStore21 private var conversation: ConversationRecord?2223 var isBusy: Bool { agent.isResponding }24 var contextUsage: Double { agent.usageRatio }2526 init(agent: AgentSession, confirm: ConfirmCenter, store: PocheStore) {27 self.agent = agent28 self.confirm = confirm29 self.store = store3031 // The Confirm layer reports back so (a) the user sees a truthful32 // "done" notice only after execution, and (b) the model learns the33 // outcome on its next turn instead of believing its own proposal.34 confirm.onExecuted = { [weak self] summary in35 self?.appendSystemNotice("✓ " + summary)36 self?.agent.noteEvent("Action confirmée et exécutée : \(summary)")37 }38 confirm.onDismissed = { [weak self] summary in39 self?.appendSystemNotice("Proposition annulée — dis-moi ce que tu veux changer.")40 self?.agent.noteEvent("L'utilisateur a annulé la proposition : \(summary)")41 }42 agent.onSummary = { [weak self] summary in43 guard let self, let conversation = self.conversation else { return }44 self.store.updateSummary(of: conversation, to: summary)45 }46 }4748 /// Sends a given text (welcome suggestions, driving hooks).49 func send(_ text: String) async {50 draft = text51 await send()52 }5354 func send() async {55 let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)56 guard !text.isEmpty, !agent.isResponding else { return }57 draft = ""5859 turns.append(ChatTurn(role: .user, text: text, status: .complete))60 persist(role: "user", text: text)6162 turns.append(ChatTurn(role: .assistant, text: "", status: .streaming))63 let index = turns.count - 16465 let outcome = await agent.respond(to: text) { [weak self] partial in66 self?.turns[index].text = partial67 }6869 switch outcome {70 case .completed(let full):71 turns[index].text = full72 turns[index].status = .complete73 persist(role: "assistant", text: full)74 case .refused(let message):75 turns[index].text = message76 turns[index].status = .refused77 case .failed(let message):78 turns[index].text = message79 turns[index].status = .failed80 }81 }8283 #if DEBUG84 /// Visual-verification hook (simulator only): fills the thread with85 /// representative turns without touching the model.86 func injectDemoTurns() {87 turns = [88 ChatTurn(role: .user, text: "Rappelle-moi d'appeler le dentiste demain à 9 h", status: .complete),89 ChatTurn(role: .assistant, text: "Je te propose un rappel « Appeler le dentiste » pour demain à 9 h. Confirme sur la carte ci-dessous.", status: .complete),90 ChatTurn(role: .system, text: "✓ Rappel « Appeler le dentiste » créé", status: .complete),91 ChatTurn(role: .user, text: "Parfait, et qu'est-ce que j'ai cette semaine ?", status: .complete),92 ChatTurn(role: .assistant, text: "", status: .streaming),93 ]94 }95 #endif9697 private func appendSystemNotice(_ text: String) {98 turns.append(ChatTurn(role: .system, text: text, status: .complete))99 persist(role: "system", text: text)100 }101102 private func persist(role: String, text: String) {103 if conversation == nil {104 conversation = store.newConversation()105 }106 if let conversation {107 store.appendTurn(to: conversation, role: role, text: text)108 }109 }110}111