diff --git a/CMakeLists.txt b/CMakeLists.txt index c59d1c3..446aac5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,7 +28,7 @@ foreach(_p endif() endforeach() -find_package(Qt6 REQUIRED COMPONENTS Core Widgets) +find_package(Qt6 REQUIRED COMPONENTS Core Widgets Network) find_package(PkgConfig REQUIRED) find_package(Threads REQUIRED) @@ -112,9 +112,10 @@ target_link_libraries(livecaption_core PUBLIC # --------------------------------------------------------------------------- add_library(livecaption_models STATIC src/models/modelstore.cpp + src/models/modeldownloader.cpp ) target_include_directories(livecaption_models PUBLIC src/models) -target_link_libraries(livecaption_models PUBLIC Qt6::Core) +target_link_libraries(livecaption_models PUBLIC Qt6::Core Qt6::Network) qt_add_resources(livecaption_models "registry" PREFIX "/" BASE "${CMAKE_CURRENT_SOURCE_DIR}" @@ -127,9 +128,11 @@ qt_add_resources(livecaption_models "registry" add_library(livecaption_ui STATIC src/ui/captionwindow.cpp src/ui/livetranscriber.cpp + src/ui/appsettings.cpp + src/ui/settingsdialog.cpp ) target_include_directories(livecaption_ui PUBLIC src/ui) -target_link_libraries(livecaption_ui PUBLIC livecaption_core Qt6::Widgets) +target_link_libraries(livecaption_ui PUBLIC livecaption_core livecaption_models Qt6::Widgets) # --------------------------------------------------------------------------- # 可执行程序 diff --git a/README.md b/README.md index 025fd7f..b5bb6b1 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,16 @@ aria2c -x16 -s16 -k4M -o mmproj-Qwen3-ASR-0.6B-Q8_0.gguf "$B/mmproj-Qwen3-ASR-0. cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j -# 3. 悬浮字幕窗 -./build/live-caption \ - --model Qwen3-ASR-0.6B-Q8_0.gguf \ - --mmproj mmproj-Qwen3-ASR-0.6B-Q8_0.gguf +# 3. 悬浮字幕窗(首次启动会打开设置对话框,引导下载并选择模型) +./build/live-caption + +# 完全通过 GUI 操作: +# · 首次启动 → 设置对话框:下载模型 / 选择当前使用 / 选识别语言 / 下载源与代理 +# · 点左上角的语言标签 → 再次打开设置 +# · 托盘图标:显示/隐藏字幕、设置、退出 +# · 窗口的 ✕ = 隐藏到托盘(用托盘菜单可以再显示回来) +# 命令行参数仍保留,仅用于自动化测试(显式给出时覆盖设置),例如: +# ./build/live-caption --asr-backend sherpa --asr-model zipformer-zh-2025 --no-tray # 4. 命令行:单文件转写 / 流式转写 / 无窗口实时 ./build/live-caption-cli --model M --mmproj MM --file some_16k_mono.wav @@ -55,7 +61,8 @@ ctest --test-dir build --output-on-failure - **`sherpa`**:sherpa-onnx + 流式 ONNX 模型(X-ASR / zipformer / Nemotron) —— 原生流式,每步成本恒定,CPU 约为前者的 1/35,中英准确率持平。 模型都放在 `~/.local/share/live-caption/models/`,注册表是 `data/models.json` -(**加模型只加一条数据,不改代码**),用 `scripts/fetch-models.sh` 下载。 +(**加模型只加一条数据,不改代码**)。GUI 里可以直接下载(支持设置下载源与代理、 +断点续传),命令行下也可以用 `scripts/fetch-models.sh`。 ```bash # 流式后端 diff --git a/apps/common/appcli.h b/apps/common/appcli.h index 6295e5b..639a660 100644 --- a/apps/common/appcli.h +++ b/apps/common/appcli.h @@ -106,6 +106,48 @@ inline void addBackendOptions(QCommandLineParser & p) p.addOption(makeOption("language", "语言提示,多语言模型用(zh / en / ja …);留空=自动", "lang")); } +// 按注册表里的模型直接构造后端(GUI 用;不依赖任何命令行参数) +inline std::unique_ptr makeAsrForModel(const models::ModelInfo & m, + const QString & language, + int threads, bool verbose, + std::string * err) +{ + QStringList missing; + if (!models::ModelStore::isInstalled(m, &missing)) { + if (err) { + *err = "模型未安装,缺失: " + missing.join(QStringLiteral(", ")).toStdString(); + } + return nullptr; + } + + if (m.backend == models::Backend::LlamaCpp) { + AsrOptions o; + o.modelPath = m.filePath(QStringLiteral("model")).toStdString(); + o.mmprojPath = m.filePath(QStringLiteral("mmproj")).toStdString(); + o.threads = threads; + o.verbose = verbose; + auto a = std::make_unique(); + if (!a->load(o, err)) return nullptr; + return a; + } + +#if defined(LC_HAVE_SHERPA) + SherpaAsr::Options o; + o.encoder = m.filePath(QStringLiteral("encoder")).toStdString(); + o.decoder = m.filePath(QStringLiteral("decoder")).toStdString(); + o.joiner = m.filePath(QStringLiteral("joiner")).toStdString(); + o.tokens = m.filePath(QStringLiteral("tokens")).toStdString(); + o.threads = 1; // 这类模型单线程最快 + if (m.languages.size() > 1 && !language.isEmpty()) o.language = language.toStdString(); + auto a = std::make_unique(); + if (!a->load(o, err)) return nullptr; + return a; +#else + if (err) *err = "编译时未找到 sherpa-onnx"; + return nullptr; +#endif +} + // 按命令行选择并加载后端;失败时返回 nullptr 并把原因写进 err inline std::unique_ptr makeAsr(const QCommandLineParser & p, const AsrOptions & asrOpt, std::string * err) diff --git a/apps/gui/main.cpp b/apps/gui/main.cpp index 36bf1d5..3abd15d 100644 --- a/apps/gui/main.cpp +++ b/apps/gui/main.cpp @@ -1,111 +1,263 @@ -// 图形界面版:悬浮字幕窗。 +// 图形界面版:悬浮字幕窗 + 托盘 + 设置对话框。 +// 完全通过 GUI 使用;命令行参数保留,仅用于自动化测试(显式给出时覆盖设置)。 #include +#include +#include #include #include #include -#include +#include +#include +#include +#include +#include +#include #include #include "appcli.h" +#include "appsettings.h" #include "captionwindow.h" -#include "livetranscriber.h" #include "iasr.h" +#include "livetranscriber.h" +#include "modelstore.h" #include "qwen3asr.h" +#include "settingsdialog.h" #include "streamingasr.h" #include "transcript.h" namespace { -int runGui(lc::IAsr & asr, const lc::StreamOptions & sop, - const QString & screenshotPath, int screenshotDelaySec, int opacityPercent) +// 托盘图标:直接画一个,免得依赖图标资源文件 +QIcon makeTrayIcon() { - lc::CaptionWindow window; - window.setPanelOpacity(opacityPercent); - lc::LiveTranscriber live(asr); + QPixmap pm(64, 64); + pm.fill(Qt::transparent); + QPainter p(&pm); + p.setRenderHint(QPainter::Antialiasing); + p.setBrush(QColor(20, 20, 20, 235)); + p.setPen(Qt::NoPen); + p.drawRoundedRect(2, 12, 60, 40, 10, 10); + p.setPen(QColor(240, 240, 240)); + QFont f = p.font(); + f.setPixelSize(26); + f.setBold(true); + p.setFont(f); + p.drawText(pm.rect(), Qt::AlignCenter, QStringLiteral("字")); + return QIcon(pm); +} - QObject::connect(&live, &lc::LiveTranscriber::caption, &window, - [&window](const QString & committed, const QString & tail, - const QString & lang) { - window.setLanguage( - QString::fromStdString(lc::languageDisplayName(lang.toStdString()))); - window.setCaption(committed, tail); - }); - QObject::connect(&live, &lc::LiveTranscriber::failed, &window, [](const QString & m) { - qWarning("采集失败: %s", qUtf8Printable(m)); - }); - QObject::connect(&window, &lc::CaptionWindow::closed, qApp, &QCoreApplication::quit); - QObject::connect(&window, &lc::CaptionWindow::languageClicked, &window, [] { - qInfo() << "语言标签被点击(设置界面将在 Phase 4 接入)"; - }); +// 把设置应用到字幕窗(字号/不透明度/行数/位置) +void applyAppearance(lc::CaptionWindow & window, const lc::AppSettings & cfg) +{ + window.setPanelOpacity(cfg.opacityPercent); + window.setTextFontSize(cfg.fontSize); + window.setLineCounts(cfg.collapsedLines, cfg.expandedLines); + if (cfg.hasWindowPos) window.move(cfg.windowPos); +} - QString err; - if (!live.start(sop, &err)) { - qCritical("启动采集失败: %s", qUtf8Printable(err)); - return 1; - } - qInfo("正在采集: %s", qUtf8Printable(live.deviceName())); - - window.show(); - - if (!screenshotPath.isEmpty()) { - // 自截图:用于验证渲染(不依赖外部截图工具) - QTimer::singleShot(screenshotDelaySec * 1000, &window, [&window, screenshotPath] { - const bool ok1 = window.grab().save(screenshotPath); - - window.setCollapsed(false); - QString expandedPath = screenshotPath; - expandedPath.replace(QStringLiteral(".png"), QStringLiteral("-expanded.png")); - const bool ok2 = window.grab().save(expandedPath); - - std::fprintf(stderr, "截图: 折叠态 %s / 展开态 %s\n", - ok1 ? "成功" : "失败", ok2 ? "成功" : "失败"); - QCoreApplication::quit(); - }); - } - - return QCoreApplication::exec(); +bool hasUsableModel() +{ + using namespace lc::models; + const lc::AppSettings & cfg = lc::AppSettings::instance(); + if (cfg.modelId.isEmpty()) return false; + const ModelInfo * m = ModelStore::find(cfg.modelId); + return m && ModelStore::isInstalled(*m); } } // namespace int main(int argc, char ** argv) { - // 说明意图:DPI 取整策略必须在 QGuiApplication 之前定。 - // (Deepin 的会话环境已经设了 QT_SCALE_FACTOR_ROUNDING_POLICY, - // 所以本机仍会看到 Qt 的那条提醒,那不是本程序的问题。) QGuiApplication::setHighDpiScaleFactorRoundingPolicy( Qt::HighDpiScaleFactorRoundingPolicy::PassThrough); - QApplication app(argc, argv); QCoreApplication::setApplicationName(QStringLiteral("live-caption")); + QCoreApplication::setOrganizationName(QStringLiteral("live-caption")); + lc::AppSettings & cfg = lc::AppSettings::instance(); + cfg.load(); + + // ---- 命令行(仅用于自动化测试,显式给出时覆盖设置)---- QCommandLineParser parser; parser.setApplicationDescription(QStringLiteral("实时字幕(悬浮窗)")); parser.addHelpOption(); lc::app::addModelOptions(parser); lc::app::addStreamOptions(parser); lc::app::addBackendOptions(parser); - parser.addOption(lc::app::makeOption("opacity", "字幕背景不透明度,取值 20-100", "n", "84")); - parser.addOption(lc::app::makeOption("screenshot", "启动后自动截图并退出(验证渲染用)", "path")); - parser.addOption(lc::app::makeOption("screenshot-delay", "截图前等待秒数", "sec", "6")); + parser.addOption(QCommandLineOption("opacity", "背景不透明度(20-100)", "n")); + parser.addOption(QCommandLineOption("screenshot", "启动后自动截图并退出", "path")); + parser.addOption(QCommandLineOption("screenshot-delay", "截图前等待秒数", "sec", "6")); + parser.addOption(QCommandLineOption("no-tray", "不创建托盘图标(测试用)")); + parser.addOption(QCommandLineOption("screenshot-settings", "打开设置对话框截图后退出", "path")); parser.process(app); - const lc::AsrOptions asrOpt = lc::app::asrOptions(parser); - lc::StreamOptions sop = lc::app::streamOptions(parser); + if (parser.isSet("asr-model")) cfg.modelId = parser.value("asr-model"); + if (parser.isSet("language")) cfg.language = parser.value("language"); + if (parser.isSet("opacity")) cfg.opacityPercent = parser.value("opacity").toInt(); + if (parser.isSet("model-dir")) lc::models::ModelStore::setRootOverride(parser.value("model-dir")); - std::string err; - std::unique_ptr asr = lc::app::makeAsr(parser, asrOpt, &err); - if (!asr) { - std::fprintf(stderr, "%s\n", err.c_str()); + // 仅用于验证渲染:打开设置对话框截图后退出 + if (parser.isSet("screenshot-settings")) { + const QString path = parser.value("screenshot-settings"); + auto * dlg = new lc::SettingsDialog(!hasUsableModel()); + dlg->show(); + QTimer::singleShot(1200, dlg, [dlg, path] { + dlg->grab().save(path); + std::fprintf(stderr, "设置对话框截图: %s\n", qUtf8Printable(path)); + QCoreApplication::quit(); + }); + return QCoreApplication::exec(); + } + + // ---- 启动:没有可用模型就先开设置 ---- + if (!hasUsableModel()) { + lc::SettingsDialog dlg(true); + if (dlg.exec() != QDialog::Accepted || !hasUsableModel()) return 0; + } + + lc::CaptionWindow window; + applyAppearance(window, cfg); + // 语言 pill:流式模型不提供语言识别,就显示设置里选的语言 + if (!cfg.language.isEmpty()) { + window.setLanguage(QString::fromStdString(lc::languageDisplayName(cfg.language.toStdString()))); + } + + // 注意声明顺序:asr 必须在 live 之前声明, + // 这样析构时 live(会 join 工作线程)先销毁、asr 后销毁,避免 use-after-free。 + std::unique_ptr asr; + lc::LiveTranscriber live; // 后端在 startTranscriber() 里注入 + const auto startTranscriber = [&]() -> bool { + live.stop(); + asr.reset(); + + const lc::models::ModelInfo * m = lc::models::ModelStore::find(cfg.modelId); + if (!m) { + qCritical("找不到模型 %s", qUtf8Printable(cfg.modelId)); + return false; + } + std::string err; + asr = lc::app::makeAsrForModel(*m, cfg.language, cfg.threads, + parser.isSet("verbose"), &err); + if (!asr) { + qCritical("加载模型失败: %s", err.c_str()); + return false; + } + + lc::StreamOptions sop; + if (asr->kind() == lc::AsrKind::NativeStreaming) { + sop.chunkSec = (cfg.chunkSec > 0.0) ? cfg.chunkSec : 0.32; + } else if (cfg.chunkSec > 0.0) { + sop.chunkSec = cfg.chunkSec; + } + QString qerr; + if (!live.start(*asr, sop, &qerr)) { + qCritical("启动采集失败: %s", qUtf8Printable(qerr)); + return false; + } + return true; + }; + + QObject::connect(&live, &lc::LiveTranscriber::caption, &window, + [&window](const QString & committed, const QString & tail, + const QString & lang) { + if (!lang.isEmpty()) { + window.setLanguage( + QString::fromStdString(lc::languageDisplayName(lang.toStdString()))); + } + window.setCaption(committed, tail); + }); + QObject::connect(&live, &lc::LiveTranscriber::failed, &window, [](const QString & m) { + qWarning("采集失败: %s", qUtf8Printable(m)); + }); + + // 语言 pill → 设置 + QObject::connect(&window, &lc::CaptionWindow::languageClicked, &window, [&] { + lc::SettingsDialog dlg(false); + if (dlg.exec() == QDialog::Accepted) { + applyAppearance(window, cfg); + if (!startTranscriber()) { + QMessageBox::warning(&window, QObject::tr("启动失败"), + QObject::tr("模型加载失败,请检查设置。")); + } + } + }); + + // ---- 托盘 ---- + QSystemTrayIcon * tray = nullptr; + QMenu * trayMenu = nullptr; + if (!parser.isSet("no-tray")) { + tray = new QSystemTrayIcon(makeTrayIcon(), &app); + tray->setToolTip(QObject::tr("实时字幕")); + trayMenu = new QMenu; + auto * showAct = trayMenu->addAction(QObject::tr("显示/隐藏字幕")); + auto * setAct = trayMenu->addAction(QObject::tr("设置…")); + trayMenu->addSeparator(); + auto * quitAct = trayMenu->addAction(QObject::tr("退出")); + tray->setContextMenu(trayMenu); + + QObject::connect(showAct, &QAction::triggered, &window, + [&window] { window.setVisible(!window.isVisible()); }); + QObject::connect(setAct, &QAction::triggered, &window, [&] { + lc::SettingsDialog dlg(false); + if (dlg.exec() == QDialog::Accepted) { + applyAppearance(window, cfg); + startTranscriber(); + } + }); + QObject::connect(quitAct, &QAction::triggered, &app, &QApplication::quit); + QObject::connect(tray, &QSystemTrayIcon::activated, &window, + [&window](QSystemTrayIcon::ActivationReason r) { + if (r == QSystemTrayIcon::Trigger) { + window.setVisible(!window.isVisible()); + } + }); + tray->show(); + } + + // 关闭按钮 = 隐藏到托盘(否则关掉就没法再打开了) + QObject::connect(&window, &lc::CaptionWindow::closed, &window, [&] { + if (tray) { + window.hide(); + } else { + qApp->quit(); + } + }); + + // 记住拖动后的位置 + QObject::connect(&window, &lc::CaptionWindow::moved, &window, [&](const QPoint & p) { + cfg.windowPos = p; + cfg.hasWindowPos = true; + cfg.save(); + }); + + if (!startTranscriber()) { + QMessageBox::critical(nullptr, QObject::tr("启动失败"), + QObject::tr("无法加载模型,请检查设置。")); return 1; } - if (asr->kind() == lc::AsrKind::NativeStreaming && !parser.isSet("stream-chunk")) { - sop.chunkSec = 0.32; + + window.show(); + + // 自截图(验证渲染用) + if (parser.isSet("screenshot")) { + const QString path = parser.value("screenshot"); + QTimer::singleShot(parser.value("screenshot-delay").toInt() * 1000, &window, + [&window, path] { + const bool ok1 = window.grab().save(path); + window.setCollapsed(false); + QString expanded = path; + expanded.replace(QStringLiteral(".png"), QStringLiteral("-expanded.png")); + const bool ok2 = window.grab().save(expanded); + std::fprintf(stderr, "截图: 折叠态 %s / 展开态 %s\n", + ok1 ? "成功" : "失败", ok2 ? "成功" : "失败"); + QCoreApplication::quit(); + }); } - return runGui(*asr, sop, - parser.value("screenshot"), - parser.value("screenshot-delay").toInt(), - parser.value("opacity").toInt()); + const int rc = QCoreApplication::exec(); + // 显式收尾,确保工作线程停下来之后再释放后端 + live.stop(); + asr.reset(); + return rc; } diff --git a/data/models.json b/data/models.json index 8977066..0e7420e 100644 --- a/data/models.json +++ b/data/models.json @@ -2,6 +2,25 @@ "version": 1, "comment": "live-caption 的模型注册表。新增模型 = 在这里加一条,不需要改代码。", "models": [ + { + "id": "qwen3-asr-0.6b-q8", + "name": "Qwen3-ASR 0.6B(通用,52 语言)", + "backend": "llama-cpp", + "languages": ["zh", "en", "ja", "ko", "fr", "de", "es", "ru", "ar", "auto"], + "license": "Apache-2.0", + "note": "973MB,准确率高、语言最全、自带语言识别;但每步成本随句子增长(CPU 约为流式后端的 35 倍)", + "files": { + "model": { + "name": "Qwen3-ASR-0.6B-Q8_0.gguf", + "url": "https://hf-mirror.com/ggml-org/Qwen3-ASR-0.6B-GGUF/resolve/main/Qwen3-ASR-0.6B-Q8_0.gguf" + }, + "mmproj": { + "name": "mmproj-Qwen3-ASR-0.6B-Q8_0.gguf", + "url": "https://hf-mirror.com/ggml-org/Qwen3-ASR-0.6B-GGUF/resolve/main/mmproj-Qwen3-ASR-0.6B-Q8_0.gguf" + } + }, + "options": {} + }, { "id": "zipformer-zh-2025", "name": "中文流式(k2-fsa 官方 2025-06-30)", diff --git a/src/models/modeldownloader.cpp b/src/models/modeldownloader.cpp new file mode 100644 index 0000000..042bc4d --- /dev/null +++ b/src/models/modeldownloader.cpp @@ -0,0 +1,147 @@ +#include "modeldownloader.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace lc::models { + +ModelDownloader::ModelDownloader(QObject * parent) : QObject(parent) {} + +ModelDownloader::~ModelDownloader() { cancel(); } + +QString ModelDownloader::rewriteUrl(const QString & url) const +{ + if (mirrorBase_.isEmpty()) return url; + const QUrl u(url); + QString base = mirrorBase_; + while (base.endsWith(QLatin1Char('/'))) base.chop(1); + return base + u.path(); +} + +void ModelDownloader::start(const ModelInfo & model) +{ + if (running_) return; + running_ = true; + cancel_ = false; + index_ = 0; + queue_ = model.files; + dir_ = ModelStore::dirFor(model.id); + + net_ = new QNetworkAccessManager(this); + if (!proxy_.hostName().isEmpty() || proxy_.type() != QNetworkProxy::DefaultProxy) { + net_->setProxy(proxy_); + } + + QDir().mkpath(dir_); + startNextFile(); +} + +void ModelDownloader::cancel() +{ + cancel_ = true; + if (reply_) { + reply_->abort(); + reply_->deleteLater(); + reply_ = nullptr; + } + if (out_) { + out_->close(); + delete out_; + out_ = nullptr; + } + running_ = false; +} + +void ModelDownloader::startNextFile() +{ + if (cancel_) { cleanup(); return; } + if (index_ >= queue_.size()) { + running_ = false; + Q_EMIT status(QStringLiteral("下载完成")); + Q_EMIT finished(true, QString()); + return; + } + + const ModelFile & f = queue_[index_]; + const QString path = dir_ + QLatin1Char('/') + f.name; + out_ = new QFile(path); + qint64 offset = 0; + if (QFileInfo::exists(path)) offset = QFileInfo(path).size(); + + if (offset > 0) { + if (!out_->open(QIODevice::ReadWrite)) { + Q_EMIT finished(false, QStringLiteral("无法打开文件: ") + path); + cleanup(); + return; + } + out_->seek(offset); + } else { + offset = 0; + if (!out_->open(QIODevice::WriteOnly)) { + Q_EMIT finished(false, QStringLiteral("无法写入: ") + path); + cleanup(); + return; + } + } + + QNetworkRequest req{QUrl(rewriteUrl(f.url))}; + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + if (offset > 0) { + req.setRawHeader("Range", QByteArray("bytes=") + QByteArray::number(offset) + "-"); + Q_EMIT status(QStringLiteral("续传 %1").arg(f.name)); + } else { + Q_EMIT status(QStringLiteral("下载 %1").arg(f.name)); + } + + baseOffset_ = offset; + reply_ = net_->get(req); + connect(reply_, &QNetworkReply::readyRead, this, &ModelDownloader::onReadyRead); + connect(reply_, &QNetworkReply::finished, this, &ModelDownloader::onFinished); + connect(reply_, &QNetworkReply::downloadProgress, this, + [this](qint64 got, qint64 tot) { + Q_EMIT progress(queue_[index_].name, + baseOffset_ + got, + tot > 0 ? baseOffset_ + tot : -1); + }); +} + +void ModelDownloader::onReadyRead() +{ + if (!reply_ || !out_) return; + const QByteArray data = reply_->readAll(); + out_->write(data); +} + +void ModelDownloader::onFinished() +{ + if (!reply_) return; + const QNetworkReply::NetworkError err = reply_->error(); + const QString errText = reply_->errorString(); + if (out_) { out_->close(); delete out_; out_ = nullptr; } + reply_->deleteLater(); + reply_ = nullptr; + + if (err != QNetworkReply::NoError && err != QNetworkReply::OperationCanceledError) { + running_ = false; + Q_EMIT finished(false, QStringLiteral("下载失败: ") + errText); + return; + } + if (cancel_) { cleanup(); return; } + + ++index_; + startNextFile(); +} + +void ModelDownloader::cleanup() +{ + running_ = false; + Q_EMIT finished(false, QStringLiteral("已取消")); +} + +} // namespace lc::models diff --git a/src/models/modeldownloader.h b/src/models/modeldownloader.h new file mode 100644 index 0000000..26c10aa --- /dev/null +++ b/src/models/modeldownloader.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include + +#include "modelstore.h" + +class QNetworkAccessManager; +class QNetworkReply; +class QFile; + +namespace lc::models { + +// 按注册表下载模型文件。支持: +// - 镜像基址替换(把注册表里的地址换成用户选的源) +// - HTTP 代理 +// - 断点续传(已下过的部分用 Range 续) +class ModelDownloader : public QObject { + Q_OBJECT + +public: + explicit ModelDownloader(QObject * parent = nullptr); + ~ModelDownloader() override; + + // mirrorBase 形如 https://hf-mirror.com;留空则用注册表里的原始地址 + void setMirrorBase(const QString & mirrorBase) { mirrorBase_ = mirrorBase; } + void setProxy(const QNetworkProxy & proxy) { proxy_ = proxy; } + + void start(const ModelInfo & model); + void cancel(); + bool isRunning() const { return running_; } + +Q_SIGNALS: + void progress(const QString & file, qint64 received, qint64 total); + void status(const QString & message); + void finished(bool ok, const QString & error); + +private: + void startNextFile(); + void onReadyRead(); + void onFinished(); + void cleanup(); + + QString rewriteUrl(const QString & url) const; + + QNetworkAccessManager * net_ = nullptr; + QNetworkReply * reply_ = nullptr; + QFile * out_ = nullptr; + std::vector queue_; + size_t index_ = 0; + QString dir_; + QString mirrorBase_; + QNetworkProxy proxy_; + bool running_ = false; + bool cancel_ = false; + qint64 baseOffset_ = 0; // 断点续传的起始偏移 +}; + +} // namespace lc::models diff --git a/src/ui/appsettings.cpp b/src/ui/appsettings.cpp new file mode 100644 index 0000000..e866667 --- /dev/null +++ b/src/ui/appsettings.cpp @@ -0,0 +1,78 @@ +#include "appsettings.h" + +#include + +namespace lc { + +AppSettings & AppSettings::instance() +{ + static AppSettings s; + return s; +} + +void AppSettings::load() +{ + QSettings s; + s.beginGroup(QStringLiteral("app")); + modelId = s.value(QStringLiteral("modelId")).toString(); + language = s.value(QStringLiteral("language")).toString(); + autoPickByLanguage = s.value(QStringLiteral("autoPickByLanguage"), false).toBool(); + + opacityPercent = s.value(QStringLiteral("opacity"), 84).toInt(); + fontSize = s.value(QStringLiteral("fontSize"), 15).toInt(); + showUnstableTail = s.value(QStringLiteral("showUnstableTail"), true).toBool(); + collapsedLines = s.value(QStringLiteral("collapsedLines"), 2).toInt(); + expandedLines = s.value(QStringLiteral("expandedLines"), 8).toInt(); + + hasWindowPos = s.contains(QStringLiteral("windowX")); + if (hasWindowPos) { + windowPos = QPoint(s.value(QStringLiteral("windowX")).toInt(), + s.value(QStringLiteral("windowY")).toInt()); + } + + mirrorBase = s.value(QStringLiteral("mirrorBase"), + QStringLiteral("https://hf-mirror.com")).toString(); + proxyMode = s.value(QStringLiteral("proxyMode"), 0).toInt(); + proxyHost = s.value(QStringLiteral("proxyHost")).toString(); + proxyPort = s.value(QStringLiteral("proxyPort"), 0).toInt(); + proxyUser = s.value(QStringLiteral("proxyUser")).toString(); + proxyPass = s.value(QStringLiteral("proxyPass")).toString(); + + chunkSec = s.value(QStringLiteral("chunkSec"), 0.0).toDouble(); + threads = s.value(QStringLiteral("threads"), 8).toInt(); + s.endGroup(); +} + +void AppSettings::save() +{ + QSettings s; + s.beginGroup(QStringLiteral("app")); + s.setValue(QStringLiteral("modelId"), modelId); + s.setValue(QStringLiteral("language"), language); + s.setValue(QStringLiteral("autoPickByLanguage"), autoPickByLanguage); + + s.setValue(QStringLiteral("opacity"), opacityPercent); + s.setValue(QStringLiteral("fontSize"), fontSize); + s.setValue(QStringLiteral("showUnstableTail"), showUnstableTail); + s.setValue(QStringLiteral("collapsedLines"), collapsedLines); + s.setValue(QStringLiteral("expandedLines"), expandedLines); + + if (hasWindowPos) { + s.setValue(QStringLiteral("windowX"), windowPos.x()); + s.setValue(QStringLiteral("windowY"), windowPos.y()); + } + + s.setValue(QStringLiteral("mirrorBase"), mirrorBase); + s.setValue(QStringLiteral("proxyMode"), proxyMode); + s.setValue(QStringLiteral("proxyHost"), proxyHost); + s.setValue(QStringLiteral("proxyPort"), proxyPort); + s.setValue(QStringLiteral("proxyUser"), proxyUser); + s.setValue(QStringLiteral("proxyPass"), proxyPass); + + s.setValue(QStringLiteral("chunkSec"), chunkSec); + s.setValue(QStringLiteral("threads"), threads); + s.endGroup(); + s.sync(); +} + +} // namespace lc diff --git a/src/ui/appsettings.h b/src/ui/appsettings.h new file mode 100644 index 0000000..aa8b8f1 --- /dev/null +++ b/src/ui/appsettings.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include + +namespace lc { + +// 应用配置(QSettings)。GUI 完全靠它驱动,不再依赖命令行参数; +// 命令行显式给出的值会覆盖这里的值(自动化测试用)。 +class AppSettings { +public: + static AppSettings & instance(); + + // 模型 + QString modelId; // 当前使用的模型注册表 id + QString language; // 识别语言提示;"" = 自动 + bool autoPickByLanguage = false; + + // 显示 + int opacityPercent = 84; + int fontSize = 15; + bool showUnstableTail = true; + int collapsedLines = 2; + int expandedLines = 8; + + // 窗口 + QPoint windowPos; // 空 = 默认(屏幕下方居中) + bool hasWindowPos = false; + + // 下载 + QString mirrorBase = QStringLiteral("https://hf-mirror.com"); + int proxyMode = 0; // 0=不使用 1=跟随系统 2=手动 + QString proxyHost; + int proxyPort = 0; + QString proxyUser; + QString proxyPass; + + // 高级 + double chunkSec = 0.0; // 0 = 由后端决定 + int threads = 8; + + void load(); + void save(); + +private: + AppSettings() = default; +}; + +} // namespace lc diff --git a/src/ui/captionwindow.cpp b/src/ui/captionwindow.cpp index 993cf8f..d27f0e7 100644 --- a/src/ui/captionwindow.cpp +++ b/src/ui/captionwindow.cpp @@ -127,9 +127,9 @@ void CaptionWindow::applyStyle() QTextEdit { color: #ffffff; background: transparent; - font-size: 15px; + font-size: %1px; } - )")); + )").arg(fontSize_)); } void CaptionWindow::paintEvent(QPaintEvent *) @@ -144,6 +144,28 @@ void CaptionWindow::paintEvent(QPaintEvent *) p.drawRoundedRect(rect(), kRadius, kRadius); } +void CaptionWindow::setTextFontSize(int px) +{ + fontSize_ = qBound(9, px, 40); + applyStyle(); + relayout(); +} + +void CaptionWindow::setLineCounts(int collapsed, int expanded) +{ + collapsedLines_ = qBound(1, collapsed, 6); + expandedLines_ = qBound(collapsedLines_, expanded, 40); + relayout(); +} + +void CaptionWindow::setShowTail(bool on) +{ + if (showTail_ == on) return; + showTail_ = on; + setCaption(QString(), QString()); // 强制重画需要外部再喂一次;这里先清空缓存 + committed_.clear(); +} + void CaptionWindow::setPanelOpacity(int percent) { const int clamped = qBound(20, percent, 100); @@ -159,7 +181,7 @@ int CaptionWindow::lineHeight() const int CaptionWindow::textHeight() const { - return lineHeight() * (collapsed_ ? kCollapsedLines : kExpandedLines) + return lineHeight() * (collapsed_ ? collapsedLines_ : expandedLines_) + (int) text_->document()->documentMargin() * 2; } @@ -206,8 +228,10 @@ void CaptionWindow::setCaption(const QString & committed, const QString & tail) // 已稳定部分正常色,不稳定尾部暗色 —— 和 CLI 版渲染一致 const QString html = - QStringLiteral("
%1%2
") - .arg(escapeHtml(committed_), escapeHtml(tail_)); + showTail_ + ? QStringLiteral("
%1%2
") + .arg(escapeHtml(committed_), escapeHtml(tail_)) + : QStringLiteral("
%1
").arg(escapeHtml(committed_)); text_->setHtml(html); scrollToEnd(); } @@ -231,6 +255,7 @@ void CaptionWindow::mouseMoveEvent(QMouseEvent * e) void CaptionWindow::mouseReleaseEvent(QMouseEvent * e) { + if (dragging_) Q_EMIT moved(pos()); dragging_ = false; e->accept(); } diff --git a/src/ui/captionwindow.h b/src/ui/captionwindow.h index 1cfe616..c5eee50 100644 --- a/src/ui/captionwindow.h +++ b/src/ui/captionwindow.h @@ -29,9 +29,14 @@ public: void setPanelOpacity(int percent); int panelOpacity() const { return opacityPercent_; } + void setTextFontSize(int px); + void setLineCounts(int collapsed, int expanded); + void setShowTail(bool on); + Q_SIGNALS: void languageClicked(); void closed(); + void moved(const QPoint & topLeft); protected: void mousePressEvent(QMouseEvent * e) override; @@ -51,7 +56,11 @@ private: QPushButton * toggleBtn_ = nullptr; QTextEdit * text_ = nullptr; - bool collapsed_ = true; + bool collapsed_ = true; + bool showTail_ = true; + int fontSize_ = 15; + int collapsedLines_ = 2; + int expandedLines_ = 8; int opacityPercent_ = 84; // 背景不透明度(百分比) bool dragging_ = false; QPoint dragOffset_; diff --git a/src/ui/livetranscriber.cpp b/src/ui/livetranscriber.cpp index 2f81f0d..76b7bed 100644 --- a/src/ui/livetranscriber.cpp +++ b/src/ui/livetranscriber.cpp @@ -4,10 +4,7 @@ namespace lc { -LiveTranscriber::LiveTranscriber(IAsr & asr, QObject * parent) - : QObject(parent), asr_(asr) -{ -} +LiveTranscriber::LiveTranscriber(QObject * parent) : QObject(parent) {} LiveTranscriber::~LiveTranscriber() { @@ -19,14 +16,14 @@ QString LiveTranscriber::deviceName() const return pipeline_ ? QString::fromStdString(pipeline_->deviceName()) : QString(); } -bool LiveTranscriber::start(const StreamOptions & opts, QString * error) +bool LiveTranscriber::start(IAsr & asr, const StreamOptions & opts, QString * error) { stop(); LivePipelineOptions popt; popt.stream = opts; - pipeline_ = std::make_unique(asr_, popt); + pipeline_ = std::make_unique(asr, popt); // 回调在工作线程上被调用;连到主线程的接收者时 Qt 会自动排队投递。 pipeline_->setCaptionCallback([this](const std::string & committed, diff --git a/src/ui/livetranscriber.h b/src/ui/livetranscriber.h index 2d3df1d..70398ca 100644 --- a/src/ui/livetranscriber.h +++ b/src/ui/livetranscriber.h @@ -18,10 +18,11 @@ class LiveTranscriber : public QObject { Q_OBJECT public: - explicit LiveTranscriber(IAsr & asr, QObject * parent = nullptr); + explicit LiveTranscriber(QObject * parent = nullptr); ~LiveTranscriber() override; - bool start(const StreamOptions & opts, QString * error = nullptr); + // 后端在 start 时注入:设置里换了模型就 stop() 再 start() + bool start(IAsr & asr, const StreamOptions & opts, QString * error = nullptr); void stop(); QString deviceName() const; @@ -32,7 +33,6 @@ Q_SIGNALS: void failed(const QString & message); private: - IAsr & asr_; std::unique_ptr pipeline_; }; diff --git a/src/ui/settingsdialog.cpp b/src/ui/settingsdialog.cpp new file mode 100644 index 0000000..5cebc2c --- /dev/null +++ b/src/ui/settingsdialog.cpp @@ -0,0 +1,422 @@ +#include "settingsdialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "appsettings.h" +#include "modeldownloader.h" +#include "modelstore.h" + +namespace lc { + +using models::ModelStore; +using models::ModelInfo; +using models::ModelDownloader; + +SettingsDialog::SettingsDialog(bool firstRun, QWidget * parent) + : QDialog(parent), downloader_(new ModelDownloader(this)), firstRun_(firstRun) +{ + setWindowTitle(firstRun ? tr("实时字幕 — 首次配置") : tr("实时字幕 — 设置")); + setMinimumSize(620, 520); + + AppSettings & cfg = AppSettings::instance(); + + auto * tabs = new QTabWidget(this); + tabs->addTab(buildModelPage(), tr("模型")); + tabs->addTab(buildDisplayPage(), tr("显示")); + tabs->addTab(buildAdvancedPage(), tr("高级")); + + auto * hint = new QLabel(this); + hint->setWordWrap(true); + hint->setText(firstRun ? tr("首次使用请先下载一个模型(中文/日文建议选流式模型,通用场景选 Qwen3-ASR)。") + : QString()); + + auto * okBtn = new QPushButton(tr("确定"), this); + auto * cancelBtn = new QPushButton(tr("取消"), this); + okBtn->setDefault(true); + connect(okBtn, &QPushButton::clicked, this, [this] { + if (ensureModelSelected()) onApply(); + }); + connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + + auto * buttons = new QHBoxLayout; + buttons->addStretch(1); + buttons->addWidget(cancelBtn); + buttons->addWidget(okBtn); + + auto * root = new QVBoxLayout(this); + root->addWidget(tabs, 1); + if (!hint->text().isEmpty()) root->addWidget(hint); + root->addLayout(buttons); + Q_UNUSED(cfg); + + refreshModelTable(); + refreshActiveCombo(); +} + +SettingsDialog::~SettingsDialog() = default; + +// --------------------------------------------------------------------------- +QWidget * SettingsDialog::buildModelPage() +{ + auto * page = new QWidget(this); + auto * root = new QVBoxLayout(page); + + table_ = new QTableWidget(page); + table_->setColumnCount(4); + table_->setHorizontalHeaderLabels({tr("模型"), tr("语言"), tr("许可"), tr("状态")}); + table_->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch); + for (int c = 1; c < 4; ++c) { + table_->horizontalHeader()->setSectionResizeMode(c, QHeaderView::ResizeToContents); + } + table_->setSelectionBehavior(QAbstractItemView::SelectRows); + table_->setSelectionMode(QAbstractItemView::SingleSelection); + table_->setEditTriggers(QAbstractItemView::NoEditTriggers); + table_->verticalHeader()->setVisible(false); + root->addWidget(table_, 1); + + auto * btnRow = new QHBoxLayout; + downloadBtn_ = new QPushButton(tr("下载"), page); + deleteBtn_ = new QPushButton(tr("删除"), page); + auto * openBtn = new QPushButton(tr("打开模型目录"), page); + btnRow->addWidget(downloadBtn_); + btnRow->addWidget(deleteBtn_); + btnRow->addWidget(openBtn); + btnRow->addStretch(1); + root->addLayout(btnRow); + + connect(downloadBtn_, &QPushButton::clicked, this, &SettingsDialog::onDownload); + connect(deleteBtn_, &QPushButton::clicked, this, &SettingsDialog::onDelete); + connect(openBtn, &QPushButton::clicked, this, &SettingsDialog::onOpenDir); + + progress_ = new QProgressBar(page); + progress_->setVisible(false); + statusLabel_ = new QLabel(page); + root->addWidget(progress_); + root->addWidget(statusLabel_); + + auto * form = new QFormLayout; + activeCombo_ = new QComboBox(page); + form->addRow(tr("当前使用"), activeCombo_); + + languageCombo_ = new QComboBox(page); + languageCombo_->addItem(tr("自动检测"), QString()); + languageCombo_->addItem(tr("中文"), QStringLiteral("zh")); + languageCombo_->addItem(tr("英文"), QStringLiteral("en")); + languageCombo_->addItem(tr("日语"), QStringLiteral("ja")); + form->addRow(tr("识别语言"), languageCombo_); + + mirrorEdit_ = new QLineEdit(page); + mirrorEdit_->setPlaceholderText(QStringLiteral("https://hf-mirror.com")); + form->addRow(tr("下载源"), mirrorEdit_); + + proxyCombo_ = new QComboBox(page); + proxyCombo_->addItem(tr("不使用代理"), 0); + proxyCombo_->addItem(tr("跟随系统"), 1); + proxyCombo_->addItem(tr("手动设置"), 2); + proxyHost_ = new QLineEdit(page); + proxyHost_->setPlaceholderText(tr("主机")); + proxyPort_ = new QSpinBox(page); + proxyPort_->setRange(0, 65535); + proxyUser_ = new QLineEdit(page); + proxyUser_->setPlaceholderText(tr("用户名(可选)")); + proxyPass_ = new QLineEdit(page); + proxyPass_->setEchoMode(QLineEdit::Password); + proxyPass_->setPlaceholderText(tr("密码(可选)")); + + auto * proxyRow = new QWidget(page); + auto * proxyLay = new QHBoxLayout(proxyRow); + proxyLay->setContentsMargins(0, 0, 0, 0); + proxyLay->addWidget(proxyCombo_); + proxyLay->addWidget(proxyHost_); + proxyLay->addWidget(proxyPort_); + form->addRow(tr("代理"), proxyRow); + form->addRow(QString(), proxyUser_); + form->addRow(QString(), proxyPass_); + + root->addLayout(form); + + const auto updateProxyEnabled = [this] { + const bool manual = proxyCombo_->currentData().toInt() == 2; + proxyHost_->setEnabled(manual); + proxyPort_->setEnabled(manual); + proxyUser_->setEnabled(manual); + proxyPass_->setEnabled(manual); + }; + connect(proxyCombo_, &QComboBox::currentIndexChanged, this, + [updateProxyEnabled](int) { updateProxyEnabled(); }); + updateProxyEnabled(); + + // 回填已保存的设置 + AppSettings & cfg = AppSettings::instance(); + mirrorEdit_->setText(cfg.mirrorBase); + proxyCombo_->setCurrentIndex(proxyCombo_->findData(cfg.proxyMode)); + proxyHost_->setText(cfg.proxyHost); + proxyPort_->setValue(cfg.proxyPort); + proxyUser_->setText(cfg.proxyUser); + proxyPass_->setText(cfg.proxyPass); + updateProxyEnabled(); + languageCombo_->setCurrentIndex( + qMax(0, languageCombo_->findData(cfg.language))); + + connect(activeCombo_, &QComboBox::currentIndexChanged, this, [this](int idx) { + if (idx >= 0) { + AppSettings::instance().modelId = activeCombo_->itemData(idx).toString(); + AppSettings::instance().save(); + } + }); + + connect(downloader_, &ModelDownloader::status, this, [this](const QString & m) { + statusLabel_->setText(m); + }); + connect(downloader_, &ModelDownloader::progress, this, + [this](const QString &, qint64 got, qint64 total) { + progress_->setVisible(true); + if (total > 0) { + progress_->setRange(0, 100); + progress_->setValue((int) (got * 100 / total)); + } else { + progress_->setRange(0, 0); // 未知总量 + } + }); + connect(downloader_, &ModelDownloader::finished, this, + [this](bool ok, const QString & err) { + progress_->setVisible(false); + downloadBtn_->setEnabled(true); + deleteBtn_->setEnabled(true); + refreshModelTable(); + refreshActiveCombo(); + if (!ok && !err.isEmpty()) statusLabel_->setText(err); + }); + return page; +} + +// --------------------------------------------------------------------------- +QWidget * SettingsDialog::buildDisplayPage() +{ + auto * page = new QWidget(this); + auto * form = new QFormLayout(page); + AppSettings & cfg = AppSettings::instance(); + + fontSpin_ = new QSpinBox(page); + fontSpin_->setRange(9, 40); + fontSpin_->setValue(cfg.fontSize); + form->addRow(tr("字号"), fontSpin_); + + showTailCheck_ = new QCheckBox(tr("用灰色显示尚未稳定的尾部"), page); + showTailCheck_->setChecked(cfg.showUnstableTail); + form->addRow(QString(), showTailCheck_); + + collapsedSpin_ = new QSpinBox(page); + collapsedSpin_->setRange(1, 6); + collapsedSpin_->setValue(cfg.collapsedLines); + form->addRow(tr("折叠行数"), collapsedSpin_); + + expandedSpin_ = new QSpinBox(page); + expandedSpin_->setRange(2, 30); + expandedSpin_->setValue(cfg.expandedLines); + form->addRow(tr("展开行数"), expandedSpin_); + + opacitySlider_ = new QSlider(Qt::Horizontal, page); + opacitySlider_->setRange(20, 100); + opacitySlider_->setValue(cfg.opacityPercent); + opacityLabel_ = new QLabel(QStringLiteral("%1%").arg(cfg.opacityPercent), page); + auto * opRow = new QWidget(page); + auto * opLay = new QHBoxLayout(opRow); + opLay->setContentsMargins(0, 0, 0, 0); + opLay->addWidget(opacitySlider_, 1); + opLay->addWidget(opacityLabel_); + form->addRow(tr("背景不透明度"), opRow); + connect(opacitySlider_, &QSlider::valueChanged, this, [this](int v) { + opacityLabel_->setText(QStringLiteral("%1%").arg(v)); + }); + + return page; +} + +// --------------------------------------------------------------------------- +QWidget * SettingsDialog::buildAdvancedPage() +{ + auto * page = new QWidget(this); + auto * form = new QFormLayout(page); + AppSettings & cfg = AppSettings::instance(); + + chunkSpin_ = new QDoubleSpinBox(page); + chunkSpin_->setRange(0.0, 5.0); + chunkSpin_->setSingleStep(0.1); + chunkSpin_->setDecimals(2); + chunkSpin_->setSpecialValueText(tr("自动")); + chunkSpin_->setValue(cfg.chunkSec); + form->addRow(tr("更新粒度(秒)"), chunkSpin_); + + threadsSpin_ = new QSpinBox(page); + threadsSpin_->setRange(1, 32); + threadsSpin_->setValue(cfg.threads); + form->addRow(tr("线程数"), threadsSpin_); + + auto * note = new QLabel( + tr("说明:流式模型(zipformer / X-ASR / Nemotron)会自动使用单线程," + "因为这类模型多线程反而更慢;线程数只影响 Qwen3-ASR。"), page); + note->setWordWrap(true); + form->addRow(QString(), note); + return page; +} + +// --------------------------------------------------------------------------- +void SettingsDialog::refreshModelTable() +{ + const auto & reg = ModelStore::registry(); + table_->setRowCount((int) reg.size()); + for (int i = 0; i < (int) reg.size(); ++i) { + const ModelInfo & m = reg[i]; + QStringList missing; + const bool installed = ModelStore::isInstalled(m, &missing); + + QStringList langs = m.languages; + QString langText = langs.mid(0, 4).join(QStringLiteral(", ")); + if (langs.size() > 4) langText += QStringLiteral(" …"); + table_->setItem(i, 0, new QTableWidgetItem(m.name)); + table_->setItem(i, 1, new QTableWidgetItem(langText)); + table_->setItem(i, 2, new QTableWidgetItem(m.license)); + table_->setItem(i, 3, new QTableWidgetItem(installed ? tr("已安装") : tr("未安装"))); + table_->item(i, 0)->setData(Qt::UserRole, m.id); + if (installed) { + for (int c = 0; c < 4; ++c) table_->item(i, c)->setToolTip(m.note); + } else { + table_->item(i, 3)->setToolTip(tr("缺失: %1").arg(missing.join(QStringLiteral(", ")))); + } + } +} + +void SettingsDialog::refreshActiveCombo() +{ + const QString current = AppSettings::instance().modelId; + activeCombo_->blockSignals(true); + activeCombo_->clear(); + for (const ModelInfo & m : ModelStore::registry()) { + if (ModelStore::isInstalled(m)) { + activeCombo_->addItem(m.name, m.id); + } + } + const int idx = activeCombo_->findData(current); + if (idx >= 0) activeCombo_->setCurrentIndex(idx); + activeCombo_->blockSignals(false); + if (idx >= 0) AppSettings::instance().modelId = activeCombo_->itemData(idx).toString(); +} + +void SettingsDialog::onDownload() +{ + const int row = table_->currentRow(); + if (row < 0) { statusLabel_->setText(tr("请先在上面选一个模型")); return; } + const QString id = table_->item(row, 0)->data(Qt::UserRole).toString(); + const ModelInfo * m = ModelStore::find(id); + if (!m) return; + + downloader_->setMirrorBase(mirrorEdit_->text().trimmed()); + QNetworkProxy proxy; + const int mode = proxyCombo_->currentData().toInt(); + if (mode == 1) { + proxy = QNetworkProxy::applicationProxy(); + } else if (mode == 2 && !proxyHost_->text().isEmpty()) { + proxy.setType(QNetworkProxy::HttpProxy); + proxy.setHostName(proxyHost_->text().trimmed()); + proxy.setPort((quint16) proxyPort_->value()); + if (!proxyUser_->text().isEmpty()) { + proxy.setUser(proxyUser_->text()); + proxy.setPassword(proxyPass_->text()); + } + } + downloader_->setProxy(proxy); + + downloadBtn_->setEnabled(false); + deleteBtn_->setEnabled(false); + progress_->setVisible(true); + progress_->setRange(0, 0); + statusLabel_->setText(tr("开始下载…")); + downloader_->start(*m); +} + +void SettingsDialog::onDelete() +{ + const int row = table_->currentRow(); + if (row < 0) return; + const QString id = table_->item(row, 0)->data(Qt::UserRole).toString(); + if (QMessageBox::question(this, tr("删除模型"), + tr("确定要删除「%1」已下载的文件吗?").arg(id)) + != QMessageBox::Yes) { + return; + } + QDir(ModelStore::dirFor(id)).removeRecursively(); + if (AppSettings::instance().modelId == id) AppSettings::instance().modelId.clear(); + refreshModelTable(); + refreshActiveCombo(); +} + +void SettingsDialog::onOpenDir() +{ + QDir().mkpath(ModelStore::root()); + QDesktopServices::openUrl(QUrl::fromLocalFile(ModelStore::root())); +} + +bool SettingsDialog::ensureModelSelected() +{ + AppSettings & cfg = AppSettings::instance(); + if (cfg.modelId.isEmpty() || !ModelStore::find(cfg.modelId) || + !ModelStore::isInstalled(*ModelStore::find(cfg.modelId))) { + QMessageBox::warning(this, tr("还没有可用模型"), + tr("请先在「模型」页下载一个模型,然后在「当前使用」里选中它。")); + return false; + } + return true; +} + +void SettingsDialog::onApply() +{ + AppSettings & cfg = AppSettings::instance(); + cfg.mirrorBase = mirrorEdit_->text().trimmed(); + if (cfg.mirrorBase.isEmpty()) cfg.mirrorBase = QStringLiteral("https://hf-mirror.com"); + cfg.proxyMode = proxyCombo_->currentData().toInt(); + cfg.proxyHost = proxyHost_->text().trimmed(); + cfg.proxyPort = proxyPort_->value(); + cfg.proxyUser = proxyUser_->text(); + cfg.proxyPass = proxyPass_->text(); + cfg.language = languageCombo_->currentData().toString(); + + cfg.fontSize = fontSpin_->value(); + cfg.showUnstableTail = showTailCheck_->isChecked(); + cfg.collapsedLines = collapsedSpin_->value(); + cfg.expandedLines = expandedSpin_->value(); + cfg.opacityPercent = opacitySlider_->value(); + + cfg.chunkSec = chunkSpin_->value(); + cfg.threads = threadsSpin_->value(); + + if (activeCombo_->currentIndex() >= 0) { + cfg.modelId = activeCombo_->currentData().toString(); + } + cfg.save(); + Q_EMIT settingsApplied(); + accept(); +} + +} // namespace lc diff --git a/src/ui/settingsdialog.h b/src/ui/settingsdialog.h new file mode 100644 index 0000000..fcbec7f --- /dev/null +++ b/src/ui/settingsdialog.h @@ -0,0 +1,75 @@ +#pragma once + +#include + +class QComboBox; +class QCheckBox; +class QDoubleSpinBox; +class QLabel; +class QLineEdit; +class QProgressBar; +class QPushButton; +class QSlider; +class QSpinBox; +class QTableWidget; + +namespace lc { + +namespace models { class ModelDownloader; } + +// 设置对话框:模型管理(下载/删除/选择)、显示、窗口、下载源与代理、高级。 +// GUI 用户完全靠它驱动,不再需要命令行参数。 +class SettingsDialog : public QDialog { + Q_OBJECT + +public: + explicit SettingsDialog(bool firstRun, QWidget * parent = nullptr); + ~SettingsDialog() override; + +Q_SIGNALS: + // 设置已变更且用户点了确定 + void settingsApplied(); + +private: + QWidget * buildModelPage(); + QWidget * buildDisplayPage(); + QWidget * buildAdvancedPage(); + + void refreshModelTable(); + void refreshActiveCombo(); + void onDownload(); + void onDelete(); + void onOpenDir(); + void onApply(); + bool ensureModelSelected(); + + QTableWidget * table_ = nullptr; + QComboBox * activeCombo_ = nullptr; + QComboBox * languageCombo_ = nullptr; + QPushButton * downloadBtn_ = nullptr; + QPushButton * deleteBtn_ = nullptr; + QProgressBar * progress_ = nullptr; + QLabel * statusLabel_ = nullptr; + + QLineEdit * mirrorEdit_ = nullptr; + QComboBox * proxyCombo_ = nullptr; + QLineEdit * proxyHost_ = nullptr; + QSpinBox * proxyPort_ = nullptr; + QLineEdit * proxyUser_ = nullptr; + QLineEdit * proxyPass_ = nullptr; + + QSpinBox * fontSpin_ = nullptr; + QCheckBox * showTailCheck_ = nullptr; + QSpinBox * collapsedSpin_ = nullptr; + QSpinBox * expandedSpin_ = nullptr; + QSlider * opacitySlider_ = nullptr; + QLabel * opacityLabel_ = nullptr; + + QDoubleSpinBox * chunkSpin_ = nullptr; + QSpinBox * threadsSpin_ = nullptr; + + models::ModelDownloader * downloader_ = nullptr; + bool firstRun_ = false; +}; + +} // namespace lc