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" @State private var acousticsAvailable: Bool? @State private var acousticsMessage: String = "" @State private var isInstallingAcoustics = false @State private var acousticsInstallLog: String = "" @State private var acousticsInstallError: String? 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 acousticsSection 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(); checkAcoustics() } } // 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: - Acoustic analysis (librosa) /// A ênfase de voz (pitch/energia) precisa do `librosa`, que é uma /// dependência opcional — sem ela `layers.acoustics` vem `false` na /// análise e a decisão de zoom fica sem base real. Antes disso só dava /// pra descobrir lendo o JSON exportado; agora o app já diz e resolve. private var acousticsSection: some View { Section { VStack(alignment: .leading, spacing: 10) { if let acousticsAvailable { Label( acousticsMessage.isEmpty ? (acousticsAvailable ? "Disponível" : "Indisponível") : acousticsMessage, systemImage: acousticsAvailable ? "checkmark.circle.fill" : "exclamationmark.triangle.fill" ) .font(.caption) .foregroundStyle(acousticsAvailable ? Color.green : Color.orange) } else { Label("Verificando…", systemImage: "hourglass") .font(.caption).foregroundStyle(.secondary) } if acousticsAvailable == false { Button { installAcoustics() } label: { if isInstallingAcoustics { HStack { ProgressView().controlSize(.small); Text("Instalando…") } } else { Label("Instalar (uv sync --all-extras)", systemImage: "arrow.down.circle") } } .disabled(isInstallingAcoustics) if !acousticsInstallLog.isEmpty { ScrollView { Text(acousticsInstallLog) .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) } .frame(height: 90) .background(RoundedRectangle(cornerRadius: 6).fill(Color.secondary.opacity(0.06))) } if let acousticsInstallError { Label(acousticsInstallError, systemImage: "xmark.circle.fill") .font(.caption).foregroundStyle(.red) } } } } header: { Text("Análise Acústica (zoom por voz)") } footer: { Text("Mede a energia e o tom de voz de verdade, para os candidatos a zoom da edição por voz. Sem isso, a análise ainda transcreve e decide cortes pelo texto — só o zoom fica sem base acústica.") .font(.caption) .foregroundStyle(.secondary) } } private func checkAcoustics() { PythonBridge.call(command: "acoustics_capability") { result, err in DispatchQueue.main.async { guard let result, result["ok"] as? Bool == true else { return } acousticsAvailable = result["available"] as? Bool acousticsMessage = result["message"] as? String ?? "" } } } private func installAcoustics() { isInstallingAcoustics = true acousticsInstallLog = "" acousticsInstallError = nil // --all-extras, não só "intelligence": `uv sync` substitui o // ambiente pelos extras pedidos em vez de somar, então um sync // parcial aqui derrubaria dev/transcribe/diarização já instalados. PythonBridge.runUV(arguments: ["sync", "--all-extras"]) { line in DispatchQueue.main.async { acousticsInstallLog += (acousticsInstallLog.isEmpty ? "" : "\n") + line } } completion: { code, err in DispatchQueue.main.async { isInstallingAcoustics = false if code != 0 { acousticsInstallError = err ?? "Falha ao instalar." } checkAcoustics() } } } // 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) } tokenField HStack { TextField("Nº de participantes (vazio = automático)", text: $numSpeakersText) .textFieldStyle(.roundedBorder) Button("Salvar") { saveDiarization() } .disabled(isLoading) } setupSteps } } header: { Text("Diarização (participantes)") } footer: { Text("Opcional. Sem token, cada fala é atribuída a um único participante padrão. O token só é usado para baixar o modelo uma vez — depois disso a análise roda offline, nesta máquina.") .font(.caption) .foregroundStyle(.secondary) } } /// Campo do token. Quando já existe um salvo, mostra o estado em vez de um /// campo vazio ambíguo — e oferece a remoção, já que salvar vazio não apaga. @ViewBuilder private var tokenField: some View { if catalog?.hfTokenSet == true { HStack { Label("Token salvo nesta máquina", systemImage: "key.fill") .font(.caption) .foregroundStyle(.secondary) Spacer() Button("Remover") { removeToken() } .buttonStyle(.link) .disabled(isLoading) } } SecureField( catalog?.hfTokenSet == true ? "Substituir token…" : "Token HuggingFace (diarização)", text: $hfTokenText ) .textFieldStyle(.roundedBorder) .help("Token com acesso aos modelos gated pyannote (segmentation + speaker-diarization)") } /// O token sozinho não basta: os dois modelos pyannote são "gated" e exigem /// aceitar os termos na conta antes do download funcionar. private var setupSteps: some View { VStack(alignment: .leading, spacing: 4) { Text("Para ativar, uma vez só:") .font(.caption).bold() .foregroundStyle(.secondary) Link("1. Aceitar os termos do modelo de segmentação", destination: URL(string: "https://huggingface.co/pyannote/segmentation-3.0")!) Link("2. Aceitar os termos do modelo de diarização", destination: URL(string: "https://huggingface.co/pyannote/speaker-diarization-3.1")!) Link("3. Criar um token de acesso e colar acima", destination: URL(string: "https://huggingface.co/settings/tokens")!) } .font(.caption) } 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() } } } } private func removeToken() { PythonBridge.call(command: "set_diarization", arguments: ["token": ""]) { _, _ in DispatchQueue.main.async { hfTokenText = "" 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() } } } } } }