Swift 98.3%
Shell 1.7%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// AgentChatView.swift — KA Agent natif plein volet : le même assistant IA que3// sur les sites (API centrale api-ka, site « groupe-ka »), flux SSE token par4// token + puces d'outils. Adapté de l'app iOS KA (AgentChatView.swift).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 let history = messages26 .filter { $0.role == "user" || ($0.role == "assistant" && !$0.content.isEmpty) }27 .suffix(16)28 .map { ["role": $0.role, "content": $0.content] }2930 Task {31 do {32 for try await event in AgentService.stream(site: site, messages: Array(history)) {33 switch event.kind {34 case .delta(let t):35 if let i = messages.lastIndex(where: { $0.role == "assistant" }) {36 messages[i].content += t37 }38 case .tool(let name):39 // insérer la puce outil AVANT la bulle assistante en cours40 if let i = messages.lastIndex(where: { $0.role == "assistant" }) {41 messages.insert(AgentMessage(role: "tool", content: name.replacingOccurrences(of: "_", with: " ")), at: i)42 }43 case .error(let m):44 if let i = messages.lastIndex(where: { $0.role == "assistant" }), messages[i].content.isEmpty {45 messages[i].content = "Désolé, une erreur est survenue (\(m)). Réessayez."46 }47 case .done: break48 }49 }50 } catch {51 if let i = messages.lastIndex(where: { $0.role == "assistant" }), messages[i].content.isEmpty {52 messages[i].content = "Impossible de joindre KA Agent — vérifiez votre connexion."53 }54 }55 busy = false56 }57 }58}5960struct AgentChatView: View {61 @StateObject private var chat = AgentChat()62 @State private var input = ""63 @Environment(\.colorScheme) private var scheme64 @FocusState private var focused: Bool6566 var body: some View {67 VStack(spacing: 0) {68 header69 Divider().opacity(0.4)70 ScrollViewReader { proxy in71 ScrollView {72 LazyVStack(alignment: .leading, spacing: 10) {73 hello74 ForEach(chat.messages) { m in75 bubble(m).id(m.id)76 }77 }78 .padding(16)79 .frame(maxWidth: 780)80 .frame(maxWidth: .infinity)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 .onAppear { focused = true }90 }9192 private var header: some View {93 HStack(spacing: 6) {94 Text("KA").font(.system(.headline, design: .rounded).weight(.bold))95 Text("Agent")96 .font(.system(.subheadline, design: .rounded).weight(.bold))97 .foregroundStyle(KATheme.lime)98 .padding(.horizontal, 7).padding(.vertical, 2)99 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 6))100 .rotationEffect(.degrees(-2))101 Text("· l'IA de l'écosystème, branchée sur les vraies données")102 .font(.caption).foregroundStyle(.secondary)103 Spacer()104 if !chat.messages.isEmpty {105 Button {106 chat.messages = []107 } label: {108 Label("Nouvelle conversation", systemImage: "square.and.pencil")109 .font(.caption)110 }111 .disabled(chat.busy)112 }113 }114 .padding(.horizontal, 16).padding(.vertical, 10)115 }116117 private var hello: some View {118 Group {119 if chat.messages.isEmpty {120 VStack(alignment: .leading, spacing: 10) {121 Text("👋 Je suis KA Agent.")122 .font(.headline)123 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…")124 .font(.subheadline).foregroundStyle(.secondary)125 FlowSuggestions { s in126 chat.send(s)127 input = ""128 }129 }130 .padding(16)131 .frame(maxWidth: .infinity, alignment: .leading)132 .kaCard()133 }134 }135 }136137 @ViewBuilder138 private func bubble(_ m: AgentMessage) -> some View {139 switch m.role {140 case "user":141 HStack {142 Spacer(minLength: 60)143 Text(m.content)144 .textSelection(.enabled)145 .padding(.horizontal, 13).padding(.vertical, 9)146 .background(KATheme.lime, in: RoundedRectangle(cornerRadius: 12, style: .continuous))147 .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous)148 .strokeBorder(KATheme.inkLight.opacity(0.6), lineWidth: 1))149 .foregroundStyle(KATheme.inkLight)150 }151 case "tool":152 Label(m.content, systemImage: "magnifyingglass")153 .font(.system(.caption2, design: .monospaced).weight(.bold))154 .textCase(.uppercase)155 .padding(.horizontal, 10).padding(.vertical, 5)156 .overlay(Capsule().strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3])))157 .foregroundStyle(.secondary)158 default:159 Group {160 if m.content.isEmpty {161 ProgressView().controlSize(.small).padding(10)162 } else {163 Text(LocalizedStringKey(m.content)) // rend **gras** et liens Markdown164 .textSelection(.enabled)165 .padding(.horizontal, 13).padding(.vertical, 9)166 .kaCard()167 }168 }169 .frame(maxWidth: .infinity, alignment: .leading)170 }171 }172173 private var inputBar: some View {174 HStack(spacing: 8) {175 TextField("Posez votre question…", text: $input, axis: .vertical)176 .textFieldStyle(.plain)177 .lineLimit(1...4)178 .padding(.horizontal, 13).padding(.vertical, 10)179 .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 11, style: .continuous))180 .overlay(RoundedRectangle(cornerRadius: 11, style: .continuous)181 .strokeBorder(.primary.opacity(0.35), lineWidth: 1.2))182 .focused($focused)183 .onSubmit { chat.send(input); input = "" }184 Button {185 chat.send(input); input = ""186 } label: {187 Image(systemName: "arrow.up")188 .font(.headline)189 .frame(width: 38, height: 38)190 .background(KATheme.inkLight, in: Circle())191 .foregroundStyle(KATheme.lime)192 }193 .buttonStyle(.plain)194 .disabled(chat.busy || input.trimmingCharacters(in: .whitespaces).isEmpty)195 .accessibilityLabel("Envoyer")196 }197 .padding(12)198 .frame(maxWidth: 780)199 .frame(maxWidth: .infinity)200 .background(.bar)201 }202}203204private struct FlowSuggestions: View {205 let action: (String) -> Void206 private let ideas = ["Combien de logements à louer ?", "Un resto italien à Montréal", "Les sorties gratuites ce week-end", "C'est quoi le Groupe KA ?"]207 var body: some View {208 VStack(alignment: .leading, spacing: 6) {209 ForEach(ideas, id: \.self) { s in210 Button { action(s) } label: {211 Text(s).font(.caption.weight(.semibold))212 .padding(.horizontal, 11).padding(.vertical, 7)213 .background(.quaternary, in: Capsule())214 }215 .buttonStyle(.plain)216 }217 }218 }219}220