import AppKit import SwiftUI /// Guia "Modelos" — replica a UI de gerenciamento de modelos do Hex: /// cards curados com badge "Recomendado", estrelas de precisão/velocidade, /// estado Instalado/Em uso, barra de progresso de download, e ações /// Download / Selecionar / Mostrar no Finder / Remover. struct ModelDownloadView: View { @State private var catalog: Catalog? @State private var downloading: String? @State private var progress: Double = 0 @State private var isLoading = false @State private var errorMessage: String? @State private var modelsDirText: String = "" @State private var hfTokenText: String = "" @State private var numSpeakersText: String = "" @State private var language: String = "auto" private let languages: [(String, String)] = [ ("auto", "Detectar automaticamente"), ("pt", "Português"), ("en", "Inglês"), ("es", "Espanhol"), ("fr", "Francês"), ("de", "Alemão"), ("it", "Italiano"), ("nl", "Holandês"), ("ja", "Japonês"), ("ko", "Coreano"), ("zh", "Chinês"), ] var body: some View { Form { storageSection diarizationSection if let errorMessage { Section { Label(errorMessage, systemImage: "exclamationmark.triangle.fill") .foregroundStyle(.red) } } Section { if isLoading { ProgressView().controlSize(.small) .frame(maxWidth: .infinity, alignment: .center) } else if let catalog { if catalog.installedCount == 0 { noModelChooser(catalog) } ForEach(catalog.orderedModels) { model in modelRow(model) } } } header: { Text("Modelos de Transcrição") } footer: { Label("Os modelos rodam localmente nesta máquina. Downloads ficam na pasta configurada acima.", systemImage: "lock.shield") .font(.caption) .foregroundStyle(.secondary) } if catalog?.installedCount ?? 0 > 0 { languageSection } } .formStyle(.grouped) .task { await refresh() } } // MARK: - Transcription language /// Idiooma padrão usado em todas as transcrições do programa. Aparece abaixo /// do modelo escolhido, conforme o modelo selecionado na aba Modelos. private var languageSection: some View { Section { Picker("Idioma", selection: $language) { ForEach(languages, id: \.0) { code, label in Text(label).tag(code) } } } header: { Text("Idioma de Transcrição") } footer: { Text("Usado em todas as transcrições do programa. Escolha conforme o modelo selecionado acima.") .font(.caption) .foregroundStyle(.secondary) } .onChange(of: language) { _, newValue in saveLanguage(newValue) } } private func saveLanguage(_ code: String) { PythonBridge.call(command: "set_language", arguments: ["language": code]) { _, _ in } } // MARK: - Diarization private var diarizationSection: some View { Section { VStack(alignment: .leading, spacing: 10) { if let msg = catalog?.diarizationMessage, !msg.isEmpty { Label(msg, systemImage: catalog?.diarization == true ? "checkmark.circle" : "exclamationmark.triangle") .font(.caption) .foregroundStyle(catalog?.diarization == true ? Color.secondary : Color.orange) } SecureField("Token HuggingFace (diarização)", text: $hfTokenText) .textFieldStyle(.roundedBorder) .help("Token com acesso aos modelos gated pyannote (segmentation + speaker-diarization)") HStack { TextField("Nº de participantes (vazio = automático)", text: $numSpeakersText) .textFieldStyle(.roundedBorder) Button("Salvar") { saveDiarization() } .disabled(isLoading) } } } header: { Text("Diarização (participantes)") } footer: { Text("Opcional. Sem token, cada fala é atribuída a um único participante padrão.") .font(.caption) .foregroundStyle(.secondary) } } private func saveDiarization() { var args: [String: Any] = ["num_speakers": numSpeakersText] if !hfTokenText.isEmpty { args["token"] = hfTokenText } PythonBridge.call(command: "set_diarization", arguments: args) { result, _ in DispatchQueue.main.async { hfTokenText = "" numSpeakersText = result?["num_speakers"] as? String ?? numSpeakersText Task { await refresh() } } } } // MARK: - Storage private var storageSection: some View { Section("Local de Armazenamento") { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { TextField("Pasta de modelos", text: $modelsDirText) .textFieldStyle(.roundedBorder) .disabled(true) Button { pickAndSaveFolder() } label: { Image(systemName: "folder") } .help("Selecionar pasta de modelos e salvar") } } } } /// Present an NSOpenPanel to choose a folder, then save it and refresh the /// installed-model list so newly scanned downloads appear immediately. private func pickAndSaveFolder() { let panel = NSOpenPanel() panel.canChooseDirectories = true panel.canChooseFiles = false panel.allowsMultipleSelection = false panel.prompt = "Selecionar" panel.message = "Escolha a pasta onde os modelos serão salvos" if let current = URL(string: modelsDirText), FileManager.default.fileExists(atPath: current.path) { panel.directoryURL = current } if panel.runModal() == .OK, let url = panel.url { modelsDirText = url.path saveDir() } } // MARK: - No model chooser (first run) private func noModelChooser(_ catalog: Catalog) -> some View { let recommended = catalog.orderedModels.filter { $0.isRecommended } return VStack(alignment: .leading, spacing: 10) { HStack(spacing: 12) { Image(systemName: "arrow.down.circle.fill") .font(.title3) .foregroundStyle(.tint) VStack(alignment: .leading, spacing: 3) { Text("Escolha um modelo de transcrição") .font(.body.weight(.medium)) Text("Baixe um modelo local para começar a transcrever.") .font(.caption).foregroundStyle(.secondary) } } if !recommended.isEmpty { HStack(spacing: 10) { ForEach(recommended) { model in noModelCard(model) } } } } .padding(10) .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) } private func noModelCard(_ model: ModelInfo) -> some View { Button { startDownload(model) } label: { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .top, spacing: 8) { Text(model.displayName).font(.body.weight(.medium)) Spacer(minLength: 8) Text("Recomendado") .font(.caption2.weight(.medium)) .foregroundStyle(.tint) .padding(.horizontal, 6).padding(.vertical, 2) .background(Color.accentColor.opacity(0.12), in: Capsule()) } HStack(spacing: 6) { Text(model.language) Text("·") Text(model.storage) Spacer() Image(systemName: "arrow.down.circle") } .font(.caption).foregroundStyle(.secondary) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(Color(nsColor: .windowBackgroundColor).opacity(0.45), in: RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.accentColor.opacity(0.4), lineWidth: 1)) } .buttonStyle(.plain) } // MARK: - Model row private func modelRow(_ model: ModelInfo) -> some View { let isThisDownloading = downloading == model.internalName return HStack(spacing: 0) { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 7) { Text(model.displayName).font(.body.weight(.medium)) if model.isRecommended && !model.isDownloaded { badge("Recomendado", color: .blue) } if model.isDownloaded { badge("Instalado", color: .green) } if model.isSelected { badge("Em uso", color: .purple) } } HStack(spacing: 10) { Text(model.language).font(.caption).foregroundStyle(.secondary) rating("Precisão", filled: model.accuracy) rating("Velocidade", filled: model.speed) Spacer() Text(model.storage).font(.caption).foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, alignment: .leading) trailingControls(for: model, isDownloading: isThisDownloading) } .padding(.vertical, 6) } private func rating(_ label: String, filled: Int) -> some View { HStack(spacing: 4) { Text(label).font(.caption2).foregroundStyle(.secondary) HStack(spacing: 3) { ForEach(0..<5, id: \.self) { i in Image(systemName: i < filled ? "circle.fill" : "circle") .font(.system(size: 7)) .foregroundColor(i < filled ? .blue : .gray.opacity(0.5)) } } } } private func badge(_ text: String, color: Color) -> some View { Text(text) .font(.caption2.weight(.medium)) .foregroundStyle(color) .padding(.horizontal, 6).padding(.vertical, 2) .background(color.opacity(0.12), in: Capsule()) } @ViewBuilder private func trailingControls(for model: ModelInfo, isDownloading: Bool) -> some View { if isDownloading { HStack(spacing: 8) { ProgressView(value: progress).progressViewStyle(.linear).frame(width: 120) Text("\(Int(progress * 100))%").font(.caption).monospacedDigit().foregroundStyle(.secondary) Button("Cancelar", role: .destructive) { cancelDownload(model) } .controlSize(.small) } } else if model.isDownloaded { if model.isSelected { Label("Em uso", systemImage: "checkmark.circle.fill") .foregroundStyle(.green).font(.callout) } else { Button("Selecionar") { select(model) } .buttonStyle(.borderedProminent).controlSize(.small) } Menu { Button { openInFinder(model) } label: { Label("Mostrar no Finder", systemImage: "folder") } Divider() Button(role: .destructive) { delete(model) } label: { Label("Remover Download…", systemImage: "trash") } } label: { Image(systemName: "ellipsis.circle").foregroundStyle(.secondary) } .menuStyle(.borderlessButton).fixedSize() } else { Button("Download") { startDownload(model) } .buttonStyle(.borderedProminent).controlSize(.small) } } // MARK: - Actions private func refresh() async { isLoading = true errorMessage = nil PythonBridge.call(command: "catalog") { result, err in DispatchQueue.main.async { isLoading = false if let result { catalog = Catalog(json: result) modelsDirText = catalog?.modelsDir ?? "" downloading = nil progress = 0 numSpeakersText = catalog?.numSpeakers ?? "" language = catalog?.language ?? "auto" } else { errorMessage = err ?? "Falha ao carregar modelos." } } } } private func startDownload(_ model: ModelInfo) { downloading = model.internalName progress = 0 PythonBridge.run(command: "download", arguments: ["model": model.internalName]) { obj in DispatchQueue.main.async { if let f = obj["fraction"] as? NSNumber { progress = f.doubleValue } if obj["type"] as? String == "error" { errorMessage = obj["message"] as? String } } } completion: { _, _ in DispatchQueue.main.async { Task { await refresh() } } } } private func cancelDownload(_ model: ModelInfo) { PythonBridge.call(command: "cancel", arguments: ["model": model.internalName]) { _, _ in } } private func select(_ model: ModelInfo) { PythonBridge.call(command: "select", arguments: ["model": model.internalName]) { _, _ in DispatchQueue.main.async { Task { await refresh() } } } } private func delete(_ model: ModelInfo) { PythonBridge.call(command: "delete", arguments: ["model": model.internalName]) { _, _ in DispatchQueue.main.async { Task { await refresh() } } } } private func openInFinder(_ model: ModelInfo) { PythonBridge.call(command: "open_finder", arguments: ["model": model.internalName]) { _, _ in } } private func saveDir() { let path = modelsDirText.trimmingCharacters(in: .whitespacesAndNewlines) guard !path.isEmpty else { return } PythonBridge.call(command: "set_models_dir", arguments: ["dir": path]) { result, _ in DispatchQueue.main.async { if let result, result["ok"] as? Bool == true { self.catalog?.modelsDir = result["models_dir"] as? String ?? "" self.modelsDirText = result["models_dir"] as? String ?? path Task { await refresh() } } } } } }