SPB Git forge

spb/ka-macos

Public
3commits 1branches 0releases
6.1 MBsize
maindefault branch
1 mo agolast push
Swift 98.3% Shell 1.7%
8.9 KB · 220 lines swift
Raw Blame History
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