Swift 100%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// AgentChatView.swift — KA Agent natif : le même assistant IA que sur les3// sites (Claude Haiku via l'API centrale), en chat plein écran, flux token4// par token + indicateurs d'outils.5import SwiftUI67struct AgentMessage: Identifiable, Equatable {8 let id = UUID()9 var role: String // user / assistant / tool10 var content: String11}1213@MainActor14final class AgentChat: ObservableObject {15 @Published var messages: [AgentMessage] = []16 @Published var busy = false17 var site: String = "groupe-ka"1819 func send(_ text: String) {20 let q = text.trimmingCharacters(in: .whitespacesAndNewlines)21 guard !q.isEmpty, !busy else { return }22 messages.append(AgentMessage(role: "user", content: q))23 messages.append(AgentMessage(role: "assistant", content: ""))24 busy = true25 Haptics.rigid()26 let history = messages27 .filter { $0.role == "user" || ($0.role == "assistant" && !$0.content.isEmpty) }28 .suffix(16)29 .map { ["role": $0.role, "content": $0.content] }3031 Task {32 do {33 for try await event in AgentService.stream(site: site, messages: Array(history)) {34 switch event.kind {35 case .delta(let t):36 if let i = messages.lastIndex(where: { $0.role == "assistant" }) {37 messages[i].content += t38 }39 case .tool(let name):40 // insérer la puce outil AVANT la bulle assistante en cours41 if let i = messages.lastIndex(where: { $0.role == "assistant" }) {42 messages.insert(AgentMessage(role: "tool", content: name.replacingOccurrences(of: "_", with: " ")), at: i)43 }44 case .error(let m):45 if let i = messages.lastIndex(where: { $0.role == "assistant" }), messages[i].content.isEmpty {46 messages[i].content = "Désolé, une erreur est survenue (\(m)). Réessayez."47 }48 case .done: break49 }50 }51 } catch {52 if let i = messages.lastIndex(where: { $0.role == "assistant" }), messages[i].content.isEmpty {53 messages[i].content = "Impossible de joindre KA Agent — vérifiez votre connexion."54 }55 }56 busy = false57 Haptics.success()58 }59 }60}6162struct AgentChatView: View {63 @StateObject private var chat = AgentChat()64 @State private var input = ""65 @Environment(\.dismiss) private var dismiss66 @Environment(\.colorScheme) private var scheme67 @FocusState private var focused: Bool6869 var body: some View {70 NavigationStack {71 VStack(spacing: 0) {72 ScrollViewReader { proxy in73 ScrollView {74 LazyVStack(alignment: .leading, spacing: 10) {75 hello76 ForEach(chat.messages) { m in77 bubble(m).id(m.id)78 }79 }80 .padding(14)81 }82 .onChange(of: chat.messages.last?.content) {83 if let last = chat.messages.last { proxy.scrollTo(last.id, anchor: .bottom) }84 }85 }86 inputBar87 }88 .background(KATheme.paper(scheme))89 .navigationTitle("")90 .toolbar {91 ToolbarItem(placement: .topBarLeading) {92 HStack(spacing: 6) {93 Text("KA").font(.system(.headline, design: .rounded).weight(.bold))94 Text("Agent")95 .font(.system(.subheadline, design: .rounded).weight(.bold))96 .foregroundStyle(KATheme.lime)97 .padding(.horizontal, 7).padding(.vertical, 2)98 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 6))99 .rotationEffect(.degrees(-2))100 Text("· IA de l'écosystème").font(.caption2).foregroundStyle(.secondary)101 }102 }103 ToolbarItem(placement: .topBarTrailing) {104 Button { dismiss() } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) }105 .accessibilityLabel("Fermer KA Agent")106 }107 }108 }109 }110111 private var hello: some View {112 Group {113 if chat.messages.isEmpty {114 VStack(alignment: .leading, spacing: 8) {115 Text("👋 Je suis KA Agent.")116 .font(.headline)117 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…")118 .font(.subheadline).foregroundStyle(.secondary)119 FlowSuggestions { s in120 input = s121 chat.send(s); input = ""122 }123 }124 .padding(4)125 }126 }127 }128129 @ViewBuilder130 private func bubble(_ m: AgentMessage) -> some View {131 switch m.role {132 case "user":133 Text(m.content)134 .padding(.horizontal, 13).padding(.vertical, 9)135 .background(KATheme.lime, in: RoundedRectangle(cornerRadius: 13, style: .continuous))136 .foregroundStyle(KATheme.inkLight)137 .frame(maxWidth: .infinity, alignment: .trailing)138 case "tool":139 Label(m.content, systemImage: "magnifyingglass")140 .font(.system(.caption2, design: .monospaced).weight(.bold))141 .textCase(.uppercase)142 .padding(.horizontal, 10).padding(.vertical, 5)143 .overlay(Capsule().strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3])))144 .foregroundStyle(.secondary)145 default:146 Group {147 if m.content.isEmpty {148 ProgressView().padding(10)149 } else {150 Text(LocalizedStringKey(m.content)) // rend **gras** et liens Markdown151 .textSelection(.enabled)152 .padding(.horizontal, 13).padding(.vertical, 9)153 .kaCard()154 }155 }156 .frame(maxWidth: .infinity, alignment: .leading)157 }158 }159160 private var inputBar: some View {161 HStack(spacing: 8) {162 TextField("Posez votre question…", text: $input, axis: .vertical)163 .lineLimit(1...4)164 .padding(.horizontal, 13).padding(.vertical, 10)165 .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 12, style: .continuous))166 .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(.primary.opacity(0.35), lineWidth: 1.2))167 .focused($focused)168 .onSubmit { chat.send(input); input = "" }169 Button {170 chat.send(input); input = ""171 } label: {172 Image(systemName: "arrow.up")173 .font(.headline)174 .frame(width: 44, height: 44)175 .background(KATheme.inkLight, in: Circle())176 .foregroundStyle(KATheme.lime)177 }178 .disabled(chat.busy || input.trimmingCharacters(in: .whitespaces).isEmpty)179 .accessibilityLabel("Envoyer")180 }181 .padding(12)182 .background(.bar)183 }184}185186private struct FlowSuggestions: View {187 let action: (String) -> Void188 private let ideas = ["Combien de logements à louer ?", "Un resto italien à Montréal", "Les sorties gratuites ce week-end", "C'est quoi le Groupe KA ?"]189 var body: some View {190 VStack(alignment: .leading, spacing: 6) {191 ForEach(ideas, id: \.self) { s in192 Button { action(s) } label: {193 Text(s).font(.caption.weight(.semibold))194 .padding(.horizontal, 11).padding(.vertical, 7)195 .background(.quaternary, in: Capsule())196 }197 .buttonStyle(.plain)198 }199 }200 }201}202