chore: adiciona .gitignore e commit.command

This commit is contained in:
João Henrique
2026-08-18 08:25:29 -04:00
parent 68958fde00
commit 8fca456ceb
215 changed files with 65752 additions and 0 deletions
+399
View File
@@ -0,0 +1,399 @@
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() }
}
}
}
}
}