// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // AgentChatView.swift — KA Agent natif : le même assistant IA que sur les // sites (Claude Haiku via l'API centrale), en chat plein écran, flux token // par token + indicateurs d'outils. import SwiftUI struct AgentMessage: Identifiable, Equatable { let id = UUID() var role: String // user / assistant / tool var content: String } @MainActor final class AgentChat: ObservableObject { @Published var messages: [AgentMessage] = [] @Published var busy = false var site: String = "groupe-ka" func send(_ text: String) { let q = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !q.isEmpty, !busy else { return } messages.append(AgentMessage(role: "user", content: q)) messages.append(AgentMessage(role: "assistant", content: "")) busy = true Haptics.rigid() let history = messages .filter { $0.role == "user" || ($0.role == "assistant" && !$0.content.isEmpty) } .suffix(16) .map { ["role": $0.role, "content": $0.content] } Task { do { for try await event in AgentService.stream(site: site, messages: Array(history)) { switch event.kind { case .delta(let t): if let i = messages.lastIndex(where: { $0.role == "assistant" }) { messages[i].content += t } case .tool(let name): // insérer la puce outil AVANT la bulle assistante en cours if let i = messages.lastIndex(where: { $0.role == "assistant" }) { messages.insert(AgentMessage(role: "tool", content: name.replacingOccurrences(of: "_", with: " ")), at: i) } case .error(let m): if let i = messages.lastIndex(where: { $0.role == "assistant" }), messages[i].content.isEmpty { messages[i].content = "Désolé, une erreur est survenue (\(m)). Réessayez." } case .done: break } } } catch { if let i = messages.lastIndex(where: { $0.role == "assistant" }), messages[i].content.isEmpty { messages[i].content = "Impossible de joindre KA Agent — vérifiez votre connexion." } } busy = false Haptics.success() } } } struct AgentChatView: View { @StateObject private var chat = AgentChat() @State private var input = "" @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var scheme @FocusState private var focused: Bool var body: some View { NavigationStack { VStack(spacing: 0) { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 10) { hello ForEach(chat.messages) { m in bubble(m).id(m.id) } } .padding(14) } .onChange(of: chat.messages.last?.content) { if let last = chat.messages.last { proxy.scrollTo(last.id, anchor: .bottom) } } } inputBar } .background(KATheme.paper(scheme)) .navigationTitle("") .toolbar { ToolbarItem(placement: .topBarLeading) { HStack(spacing: 6) { Text("KA").font(.system(.headline, design: .rounded).weight(.bold)) Text("Agent") .font(.system(.subheadline, design: .rounded).weight(.bold)) .foregroundStyle(KATheme.lime) .padding(.horizontal, 7).padding(.vertical, 2) .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 6)) .rotationEffect(.degrees(-2)) Text("· IA de l'écosystème").font(.caption2).foregroundStyle(.secondary) } } ToolbarItem(placement: .topBarTrailing) { Button { dismiss() } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) } .accessibilityLabel("Fermer KA Agent") } } } } private var hello: some View { Group { if chat.messages.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("👋 Je suis KA Agent.") .font(.headline) Text("Posez-moi n'importe quelle question sur l'écosystème Groupe KA et ses données : logements, propriétés, autos, emplois, prix d'épicerie, restos, sorties, créateurs, statistiques…") .font(.subheadline).foregroundStyle(.secondary) FlowSuggestions { s in input = s chat.send(s); input = "" } } .padding(4) } } } @ViewBuilder private func bubble(_ m: AgentMessage) -> some View { switch m.role { case "user": Text(m.content) .padding(.horizontal, 13).padding(.vertical, 9) .background(KATheme.lime, in: RoundedRectangle(cornerRadius: 13, style: .continuous)) .foregroundStyle(KATheme.inkLight) .frame(maxWidth: .infinity, alignment: .trailing) case "tool": Label(m.content, systemImage: "magnifyingglass") .font(.system(.caption2, design: .monospaced).weight(.bold)) .textCase(.uppercase) .padding(.horizontal, 10).padding(.vertical, 5) .overlay(Capsule().strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3]))) .foregroundStyle(.secondary) default: Group { if m.content.isEmpty { ProgressView().padding(10) } else { Text(LocalizedStringKey(m.content)) // rend **gras** et liens Markdown .textSelection(.enabled) .padding(.horizontal, 13).padding(.vertical, 9) .kaCard() } } .frame(maxWidth: .infinity, alignment: .leading) } } private var inputBar: some View { HStack(spacing: 8) { TextField("Posez votre question…", text: $input, axis: .vertical) .lineLimit(1...4) .padding(.horizontal, 13).padding(.vertical, 10) .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(.primary.opacity(0.35), lineWidth: 1.2)) .focused($focused) .onSubmit { chat.send(input); input = "" } Button { chat.send(input); input = "" } label: { Image(systemName: "arrow.up") .font(.headline) .frame(width: 44, height: 44) .background(KATheme.inkLight, in: Circle()) .foregroundStyle(KATheme.lime) } .disabled(chat.busy || input.trimmingCharacters(in: .whitespaces).isEmpty) .accessibilityLabel("Envoyer") } .padding(12) .background(.bar) } } private struct FlowSuggestions: View { let action: (String) -> Void private let ideas = ["Combien de logements à louer ?", "Un resto italien à Montréal", "Les sorties gratuites ce week-end", "C'est quoi le Groupe KA ?"] var body: some View { VStack(alignment: .leading, spacing: 6) { ForEach(ideas, id: \.self) { s in Button { action(s) } label: { Text(s).font(.caption.weight(.semibold)) .padding(.horizontal, 11).padding(.vertical, 7) .background(.quaternary, in: Capsule()) } .buttonStyle(.plain) } } } }