SPB Git

spb/zyquo-local Public MIT

Native macOS AI chat that runs LLMs 100% locally on Apple Silicon with MLX — no cloud, no API keys.

Swift 97.2% Shell 1.8% Makefile 1%
11.3 KB · 315 lines swift
Raw Blame History
1//2//  InputBar.swift3//  Zyquo Local4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import SwiftUI10import UniformTypeIdentifiers1112/// Floating input card (radius 14, soft shadow): attach text files, prompt13/// templates, params quick-toggle, circular emerald send (⌘↩), stop while14/// generating. Supports drag & drop of text files.15struct InputBar: View {16    let conversation: Conversation17    @Environment(AppModel.self) private var app18    @State private var text = ""19    @State private var showParams = false20    @State private var showTemplates = false21    @State private var dropTargeted = false22    @FocusState private var focused: Bool2324    private static let allowedTypes: [UTType] = [.plainText, .utf8PlainText, .sourceCode, .json, .commaSeparatedText, .fileURL]25    private static let allowedExtensions: Set<String> = [26        "txt", "md", "markdown", "swift", "py", "js", "ts", "html", "css", "c",27        "cpp", "h", "m", "rs", "go", "java", "kt", "rb", "sh", "yaml", "yml",28        "toml", "json", "csv", "xml", "sql", "log",29    ]3031    private var modelReady: Bool {32        if case .ready = app.engineState { return true }33        if case .generating = app.engineState { return true }34        return false35    }3637    var body: some View {38        VStack(spacing: 0) {39            HStack(alignment: .bottom, spacing: ZyquoTheme.Spacing.xs) {40                attachButton41                templatesButton4243                TextField(inputHint, text: $text, axis: .vertical)44                    .textFieldStyle(.plain)45                    .font(ZyquoTheme.chatBody)46                    .lineLimit(1...10)47                    .focused($focused)48                    .disabled(!modelReady && !app.store.models.isEmpty)49                    .onSubmit(send)5051                paramsButton52                sendOrStopButton53            }54            .padding(ZyquoTheme.Spacing.s)55            .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.l))56            .overlay(57                RoundedRectangle(cornerRadius: ZyquoTheme.Radius.l)58                    .stroke(59                        dropTargeted ? ZyquoTheme.accent : ZyquoTheme.border,60                        lineWidth: dropTargeted ? 1.5 : ZyquoTheme.hairline61                    )62            )63            .floatingShadow()64            .padding(.horizontal, ZyquoTheme.Spacing.xl)65            .padding(.bottom, ZyquoTheme.Spacing.m)66            .padding(.top, ZyquoTheme.Spacing.xs)67            .frame(maxWidth: ZyquoTheme.messageColumnMaxWidth + ZyquoTheme.Spacing.xl * 2)68        }69        .onDrop(of: Self.allowedTypes, isTargeted: $dropTargeted) { providers in70            handleDrop(providers)71        }72        .onReceive(NotificationCenter.default.publisher(for: .zyquoQuoteReply)) { note in73            if let quoted = note.object as? String {74                text = quoted + text75                focused = true76            }77        }78        .onChange(of: conversation.id) { focused = true }79    }8081    private var inputHint: String {82        if app.store.models.isEmpty { return "Download a model to start chatting" }83        if !modelReady { return "Load a model to start chatting" }84        return "Message \(shortModelName(app.loadedModelID ?? ""))…"85    }8687    private var attachButton: some View {88        Button {89            attachFiles()90        } label: {91            Image(systemName: "paperclip")92                .font(.system(size: 14))93                .foregroundStyle(ZyquoTheme.textSecondary)94        }95        .buttonStyle(.plain)96        .help("Attach text files (txt, md, code, csv, json)")97    }9899    private var templatesButton: some View {100        Button {101            showTemplates.toggle()102        } label: {103            Image(systemName: "text.badge.star")104                .font(.system(size: 14))105                .foregroundStyle(ZyquoTheme.textSecondary)106        }107        .buttonStyle(.plain)108        .popover(isPresented: $showTemplates, arrowEdge: .top) {109            PromptTemplatePicker { rendered in110                text = rendered111                focused = true112            }113        }114        .help("Prompt library")115    }116117    private var paramsButton: some View {118        Button {119            showParams.toggle()120        } label: {121            Image(systemName: "slider.horizontal.3")122                .font(.system(size: 14))123                .foregroundStyle(ZyquoTheme.textSecondary)124        }125        .buttonStyle(.plain)126        .popover(isPresented: $showParams, arrowEdge: .top) {127            ParamsEditor(128                params: Binding(129                    get: { app.selectedConversation?.params ?? conversation.params },130                    set: { newParams in131                        var c = app.selectedConversation ?? conversation132                        c.params = newParams133                        app.update(c, touch: false)134                    }135                )136            )137            .padding(ZyquoTheme.Spacing.m)138            .frame(width: 300)139        }140        .help("Generation parameters")141    }142143    @ViewBuilder144    private var sendOrStopButton: some View {145        if app.chat.isGenerating {146            Button {147                app.chat.stop()148            } label: {149                Image(systemName: "stop.fill")150                    .font(.system(size: 12, weight: .bold))151                    .foregroundStyle(.white)152                    .frame(width: 28, height: 28)153                    .background(ZyquoTheme.danger, in: Circle())154            }155            .buttonStyle(PressableButtonStyle())156            .help("Stop generating")157        } else {158            Button(action: send) {159                Image(systemName: "arrow.up")160                    .font(.system(size: 13, weight: .bold))161                    .foregroundStyle(.white)162                    .frame(width: 28, height: 28)163                    .background(canSend ? ZyquoTheme.accent : ZyquoTheme.textTertiary, in: Circle())164            }165            .buttonStyle(PressableButtonStyle())166            .keyboardShortcut(.return, modifiers: .command)167            .disabled(!canSend)168            .help("Send (⌘↩)")169        }170    }171172    private var canSend: Bool {173        modelReady && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty174            && !app.chat.isGenerating175    }176177    private func send() {178        guard canSend else { return }179        let prompt = text.trimmingCharacters(in: .whitespacesAndNewlines)180        text = ""181        app.chat.send(prompt: prompt, in: conversation)182    }183184    // MARK: - Attachments185186    private func attachFiles() {187        let panel = NSOpenPanel()188        panel.allowsMultipleSelection = true189        panel.canChooseDirectories = false190        panel.allowedContentTypes = [.plainText, .sourceCode, .json, .commaSeparatedText, .text]191        if panel.runModal() == .OK {192            for url in panel.urls { inject(fileURL: url) }193        }194    }195196    private func handleDrop(_ providers: [NSItemProvider]) -> Bool {197        var handled = false198        for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {199            handled = true200            _ = provider.loadObject(ofClass: URL.self) { url, _ in201                if let url {202                    Task { @MainActor in inject(fileURL: url) }203                }204            }205        }206        return handled207    }208209    /// Injects a text file's content into the draft, fenced and labeled.210    private func inject(fileURL: URL) {211        let ext = fileURL.pathExtension.lowercased()212        guard Self.allowedExtensions.contains(ext) || ext.isEmpty else {213            app.lastError = "\(fileURL.lastPathComponent) is not a supported text file."214            return215        }216        guard let data = try? Data(contentsOf: fileURL), data.count <= 2_000_000,217            let content = String(data: data, encoding: .utf8)218        else {219            app.lastError = "Could not read \(fileURL.lastPathComponent) as UTF-8 text (2 MB max)."220            return221        }222        let fence = ext == "md" || ext.isEmpty ? "" : ext223        text += (text.isEmpty ? "" : "\n") + "[\(fileURL.lastPathComponent)]\n```\(fence)\n\(content)\n```\n"224    }225}226227/// Popover for browsing and filling prompt templates.228struct PromptTemplatePicker: View {229    let onUse: (String) -> Void230    @Environment(AppModel.self) private var app231    @Environment(\.dismiss) private var dismiss232    @State private var selected: PromptTemplate?233    @State private var values: [String: String] = [:]234    @State private var filter = ""235236    var body: some View {237        HSplitView {238            list239                .frame(width: 230)240            detail241                .frame(width: 300)242        }243        .frame(height: 340)244    }245246    private var list: some View {247        VStack(spacing: 0) {248            TextField("Filter templates", text: $filter)249                .textFieldStyle(.roundedBorder)250                .padding(ZyquoTheme.Spacing.xs)251            List(selection: $selected) {252                ForEach(app.promptLibrary.categories, id: \.self) { category in253                    let matching = app.promptLibrary.all.filter {254                        $0.category == category255                            && (filter.isEmpty || $0.name.localizedCaseInsensitiveContains(filter))256                    }257                    if !matching.isEmpty {258                        Section(category) {259                            ForEach(matching) { template in260                                Text(template.name)261                                    .font(ZyquoTheme.body)262                                    .tag(template)263                            }264                        }265                    }266                }267            }268            .listStyle(.sidebar)269        }270    }271272    @ViewBuilder273    private var detail: some View {274        if let template = selected {275            VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.s) {276                Text(template.name)277                    .font(ZyquoTheme.bodyEmphasis)278                ScrollView {279                    Text(template.template)280                        .font(ZyquoTheme.caption)281                        .foregroundStyle(ZyquoTheme.textSecondary)282                        .frame(maxWidth: .infinity, alignment: .leading)283                }284                .frame(maxHeight: 110)285                ForEach(template.variables, id: \.self) { variable in286                    TextField(287                        variable,288                        text: Binding(289                            get: { values[variable] ?? "" },290                            set: { values[variable] = $0 }291                        ),292                        axis: .vertical293                    )294                    .textFieldStyle(.roundedBorder)295                    .lineLimit(1...4)296                }297                Spacer()298                Button("Use Template") {299                    onUse(template.render(values: values))300                    dismiss()301                }302                .buttonStyle(.borderedProminent)303                .tint(ZyquoTheme.accent)304                .frame(maxWidth: .infinity)305            }306            .padding(ZyquoTheme.Spacing.m)307        } else {308            Text("Select a template")309                .font(ZyquoTheme.body)310                .foregroundStyle(ZyquoTheme.textTertiary)311                .frame(maxWidth: .infinity, maxHeight: .infinity)312        }313    }314}315