chore: adiciona .gitignore e commit.command
This commit is contained in:
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user