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%
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