// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // AgentChatView.swift — KA Agent natif plein volet : le même assistant IA que // sur les sites (API centrale api-ka, site « groupe-ka »), flux SSE token par // token + puces d'outils. Adapté de l'app iOS KA (AgentChatView.swift). 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 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 } } } struct AgentChatView: View { @StateObject private var chat = AgentChat() @State private var input = "" @Environment(\.colorScheme) private var scheme @FocusState private var focused: Bool var body: some View { VStack(spacing: 0) { header Divider().opacity(0.4) ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 10) { hello ForEach(chat.messages) { m in bubble(m).id(m.id) } } .padding(16) .frame(maxWidth: 780) .frame(maxWidth: .infinity) } .onChange(of: chat.messages.last?.content) { if let last = chat.messages.last { proxy.scrollTo(last.id, anchor: .bottom) } } } inputBar } .background(KATheme.paper(scheme)) .onAppear { focused = true } } private var header: some View { 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("· l'IA de l'écosystème, branchée sur les vraies données") .font(.caption).foregroundStyle(.secondary) Spacer() if !chat.messages.isEmpty { Button { chat.messages = [] } label: { Label("Nouvelle conversation", systemImage: "square.and.pencil") .font(.caption) } .disabled(chat.busy) } } .padding(.horizontal, 16).padding(.vertical, 10) } private var hello: some View { Group { if chat.messages.isEmpty { VStack(alignment: .leading, spacing: 10) { 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 chat.send(s) input = "" } } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .kaCard() } } } @ViewBuilder private func bubble(_ m: AgentMessage) -> some View { switch m.role { case "user": HStack { Spacer(minLength: 60) Text(m.content) .textSelection(.enabled) .padding(.horizontal, 13).padding(.vertical, 9) .background(KATheme.lime, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(KATheme.inkLight.opacity(0.6), lineWidth: 1)) .foregroundStyle(KATheme.inkLight) } 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().controlSize(.small).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) .textFieldStyle(.plain) .lineLimit(1...4) .padding(.horizontal, 13).padding(.vertical, 10) .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 11, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 11, style: .continuous) .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: 38, height: 38) .background(KATheme.inkLight, in: Circle()) .foregroundStyle(KATheme.lime) } .buttonStyle(.plain) .disabled(chat.busy || input.trimmingCharacters(in: .whitespaces).isEmpty) .accessibilityLabel("Envoyer") } .padding(12) .frame(maxWidth: 780) .frame(maxWidth: .infinity) .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) } } } }