SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%
8.4 KB · 202 lines swift
Raw Blame History
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