feat(gui): drive everything from settings, add tray and in-app download
The GUI no longer needs command line arguments: on first run it opens a settings dialog that lists the registry, downloads models in-app (mirror base, HTTP proxy, resume), and picks the active model. The caption window gains a tray icon (show/hide, settings, quit); its close button hides to the tray so the window can always be brought back, and the language pill opens the settings. Appearance (font, opacity, line counts, position) is persisted. CLI flags remain for automated testing and override settings. GUI 不再依赖命令行参数:首次启动打开设置对话框,可在应用内下载模型 (下载源/代理/断点续传)、选择当前模型与识别语言;字幕窗增加托盘(显示 隐藏/设置/退出),关闭按钮改为隐藏到托盘以免关掉后无法再打开,语言标签 点击进设置;字号/透明度/行数/位置都会持久化。命令行参数保留作自动化测试。
This commit is contained in:
1 parent
b37b9eb779
commit
a7a6f3e6b6
15 files changed
+1175
-90
No files matched your search
+6
-3
@@ -28,7 +28,7 @@ foreach(_p
|
|||||||
endif()
|
endif()
|
||||||
endforeach()
|
endforeach()
|
||||||
|
|
||||||
find_package(Qt6 REQUIRED COMPONENTS Core Widgets)
|
find_package(Qt6 REQUIRED COMPONENTS Core Widgets Network)
|
||||||
find_package(PkgConfig REQUIRED)
|
find_package(PkgConfig REQUIRED)
|
||||||
find_package(Threads REQUIRED)
|
find_package(Threads REQUIRED)
|
||||||
|
|
||||||
@@ -112,9 +112,10 @@ target_link_libraries(livecaption_core PUBLIC
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
add_library(livecaption_models STATIC
|
add_library(livecaption_models STATIC
|
||||||
src/models/modelstore.cpp
|
src/models/modelstore.cpp
|
||||||
|
src/models/modeldownloader.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(livecaption_models PUBLIC src/models)
|
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"
|
qt_add_resources(livecaption_models "registry"
|
||||||
PREFIX "/"
|
PREFIX "/"
|
||||||
BASE "${CMAKE_CURRENT_SOURCE_DIR}"
|
BASE "${CMAKE_CURRENT_SOURCE_DIR}"
|
||||||
@@ -127,9 +128,11 @@ qt_add_resources(livecaption_models "registry"
|
|||||||
add_library(livecaption_ui STATIC
|
add_library(livecaption_ui STATIC
|
||||||
src/ui/captionwindow.cpp
|
src/ui/captionwindow.cpp
|
||||||
src/ui/livetranscriber.cpp
|
src/ui/livetranscriber.cpp
|
||||||
|
src/ui/appsettings.cpp
|
||||||
|
src/ui/settingsdialog.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(livecaption_ui PUBLIC src/ui)
|
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)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 可执行程序
|
# 可执行程序
|
||||||
|
|||||||
@@ -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 -S . -B build -DCMAKE_BUILD_TYPE=Release
|
||||||
cmake --build build -j
|
cmake --build build -j
|
||||||
|
|
||||||
# 3. 悬浮字幕窗
|
# 3. 悬浮字幕窗(首次启动会打开设置对话框,引导下载并选择模型)
|
||||||
./build/live-caption \
|
./build/live-caption
|
||||||
--model Qwen3-ASR-0.6B-Q8_0.gguf \
|
|
||||||
--mmproj mmproj-Qwen3-ASR-0.6B-Q8_0.gguf
|
# 完全通过 GUI 操作:
|
||||||
|
# · 首次启动 → 设置对话框:下载模型 / 选择当前使用 / 选识别语言 / 下载源与代理
|
||||||
|
# · 点左上角的语言标签 → 再次打开设置
|
||||||
|
# · 托盘图标:显示/隐藏字幕、设置、退出
|
||||||
|
# · 窗口的 ✕ = 隐藏到托盘(用托盘菜单可以再显示回来)
|
||||||
|
# 命令行参数仍保留,仅用于自动化测试(显式给出时覆盖设置),例如:
|
||||||
|
# ./build/live-caption --asr-backend sherpa --asr-model zipformer-zh-2025 --no-tray
|
||||||
|
|
||||||
# 4. 命令行:单文件转写 / 流式转写 / 无窗口实时
|
# 4. 命令行:单文件转写 / 流式转写 / 无窗口实时
|
||||||
./build/live-caption-cli --model M --mmproj MM --file some_16k_mono.wav
|
./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,中英准确率持平。
|
- **`sherpa`**:sherpa-onnx + 流式 ONNX 模型(X-ASR / zipformer / Nemotron) —— 原生流式,每步成本恒定,CPU 约为前者的 1/35,中英准确率持平。
|
||||||
|
|
||||||
模型都放在 `~/.local/share/live-caption/models/`,注册表是 `data/models.json`
|
模型都放在 `~/.local/share/live-caption/models/`,注册表是 `data/models.json`
|
||||||
(**加模型只加一条数据,不改代码**),用 `scripts/fetch-models.sh` 下载。
|
(**加模型只加一条数据,不改代码**)。GUI 里可以直接下载(支持设置下载源与代理、
|
||||||
|
断点续传),命令行下也可以用 `scripts/fetch-models.sh`。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 流式后端
|
# 流式后端
|
||||||
|
|||||||
@@ -106,6 +106,48 @@ inline void addBackendOptions(QCommandLineParser & p)
|
|||||||
p.addOption(makeOption("language", "语言提示,多语言模型用(zh / en / ja …);留空=自动", "lang"));
|
p.addOption(makeOption("language", "语言提示,多语言模型用(zh / en / ja …);留空=自动", "lang"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 按注册表里的模型直接构造后端(GUI 用;不依赖任何命令行参数)
|
||||||
|
inline std::unique_ptr<IAsr> 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<Qwen3Asr>();
|
||||||
|
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<SherpaAsr>();
|
||||||
|
if (!a->load(o, err)) return nullptr;
|
||||||
|
return a;
|
||||||
|
#else
|
||||||
|
if (err) *err = "编译时未找到 sherpa-onnx";
|
||||||
|
return nullptr;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
// 按命令行选择并加载后端;失败时返回 nullptr 并把原因写进 err
|
// 按命令行选择并加载后端;失败时返回 nullptr 并把原因写进 err
|
||||||
inline std::unique_ptr<IAsr> makeAsr(const QCommandLineParser & p,
|
inline std::unique_ptr<IAsr> makeAsr(const QCommandLineParser & p,
|
||||||
const AsrOptions & asrOpt, std::string * err)
|
const AsrOptions & asrOpt, std::string * err)
|
||||||
|
|||||||
+219
-67
@@ -1,111 +1,263 @@
|
|||||||
// 图形界面版:悬浮字幕窗。
|
// 图形界面版:悬浮字幕窗 + 托盘 + 设置对话框。
|
||||||
|
// 完全通过 GUI 使用;命令行参数保留,仅用于自动化测试(显式给出时覆盖设置)。
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include <QAction>
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QCommandLineOption>
|
#include <QCommandLineOption>
|
||||||
#include <QCommandLineParser>
|
#include <QCommandLineParser>
|
||||||
#include <QGuiApplication>
|
#include <QIcon>
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPixmap>
|
||||||
|
#include <QSystemTrayIcon>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
|
|
||||||
#include "appcli.h"
|
#include "appcli.h"
|
||||||
|
#include "appsettings.h"
|
||||||
#include "captionwindow.h"
|
#include "captionwindow.h"
|
||||||
#include "livetranscriber.h"
|
|
||||||
#include "iasr.h"
|
#include "iasr.h"
|
||||||
|
#include "livetranscriber.h"
|
||||||
|
#include "modelstore.h"
|
||||||
#include "qwen3asr.h"
|
#include "qwen3asr.h"
|
||||||
|
#include "settingsdialog.h"
|
||||||
#include "streamingasr.h"
|
#include "streamingasr.h"
|
||||||
#include "transcript.h"
|
#include "transcript.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
int runGui(lc::IAsr & asr, const lc::StreamOptions & sop,
|
// 托盘图标:直接画一个,免得依赖图标资源文件
|
||||||
const QString & screenshotPath, int screenshotDelaySec, int opacityPercent)
|
QIcon makeTrayIcon()
|
||||||
{
|
{
|
||||||
lc::CaptionWindow window;
|
QPixmap pm(64, 64);
|
||||||
window.setPanelOpacity(opacityPercent);
|
pm.fill(Qt::transparent);
|
||||||
lc::LiveTranscriber live(asr);
|
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,
|
void applyAppearance(lc::CaptionWindow & window, const lc::AppSettings & cfg)
|
||||||
const QString & lang) {
|
{
|
||||||
window.setLanguage(
|
window.setPanelOpacity(cfg.opacityPercent);
|
||||||
QString::fromStdString(lc::languageDisplayName(lang.toStdString())));
|
window.setTextFontSize(cfg.fontSize);
|
||||||
window.setCaption(committed, tail);
|
window.setLineCounts(cfg.collapsedLines, cfg.expandedLines);
|
||||||
});
|
if (cfg.hasWindowPos) window.move(cfg.windowPos);
|
||||||
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 接入)";
|
|
||||||
});
|
|
||||||
|
|
||||||
QString err;
|
bool hasUsableModel()
|
||||||
if (!live.start(sop, &err)) {
|
{
|
||||||
qCritical("启动采集失败: %s", qUtf8Printable(err));
|
using namespace lc::models;
|
||||||
return 1;
|
const lc::AppSettings & cfg = lc::AppSettings::instance();
|
||||||
}
|
if (cfg.modelId.isEmpty()) return false;
|
||||||
qInfo("正在采集: %s", qUtf8Printable(live.deviceName()));
|
const ModelInfo * m = ModelStore::find(cfg.modelId);
|
||||||
|
return m && ModelStore::isInstalled(*m);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int main(int argc, char ** argv)
|
int main(int argc, char ** argv)
|
||||||
{
|
{
|
||||||
// 说明意图:DPI 取整策略必须在 QGuiApplication 之前定。
|
|
||||||
// (Deepin 的会话环境已经设了 QT_SCALE_FACTOR_ROUNDING_POLICY,
|
|
||||||
// 所以本机仍会看到 Qt 的那条提醒,那不是本程序的问题。)
|
|
||||||
QGuiApplication::setHighDpiScaleFactorRoundingPolicy(
|
QGuiApplication::setHighDpiScaleFactorRoundingPolicy(
|
||||||
Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);
|
Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);
|
||||||
|
|
||||||
QApplication app(argc, argv);
|
QApplication app(argc, argv);
|
||||||
QCoreApplication::setApplicationName(QStringLiteral("live-caption"));
|
QCoreApplication::setApplicationName(QStringLiteral("live-caption"));
|
||||||
|
QCoreApplication::setOrganizationName(QStringLiteral("live-caption"));
|
||||||
|
|
||||||
|
lc::AppSettings & cfg = lc::AppSettings::instance();
|
||||||
|
cfg.load();
|
||||||
|
|
||||||
|
// ---- 命令行(仅用于自动化测试,显式给出时覆盖设置)----
|
||||||
QCommandLineParser parser;
|
QCommandLineParser parser;
|
||||||
parser.setApplicationDescription(QStringLiteral("实时字幕(悬浮窗)"));
|
parser.setApplicationDescription(QStringLiteral("实时字幕(悬浮窗)"));
|
||||||
parser.addHelpOption();
|
parser.addHelpOption();
|
||||||
lc::app::addModelOptions(parser);
|
lc::app::addModelOptions(parser);
|
||||||
lc::app::addStreamOptions(parser);
|
lc::app::addStreamOptions(parser);
|
||||||
lc::app::addBackendOptions(parser);
|
lc::app::addBackendOptions(parser);
|
||||||
parser.addOption(lc::app::makeOption("opacity", "字幕背景不透明度,取值 20-100", "n", "84"));
|
parser.addOption(QCommandLineOption("opacity", "背景不透明度(20-100)", "n"));
|
||||||
parser.addOption(lc::app::makeOption("screenshot", "启动后自动截图并退出(验证渲染用)", "path"));
|
parser.addOption(QCommandLineOption("screenshot", "启动后自动截图并退出", "path"));
|
||||||
parser.addOption(lc::app::makeOption("screenshot-delay", "截图前等待秒数", "sec", "6"));
|
parser.addOption(QCommandLineOption("screenshot-delay", "截图前等待秒数", "sec", "6"));
|
||||||
|
parser.addOption(QCommandLineOption("no-tray", "不创建托盘图标(测试用)"));
|
||||||
|
parser.addOption(QCommandLineOption("screenshot-settings", "打开设置对话框截图后退出", "path"));
|
||||||
parser.process(app);
|
parser.process(app);
|
||||||
|
|
||||||
const lc::AsrOptions asrOpt = lc::app::asrOptions(parser);
|
if (parser.isSet("asr-model")) cfg.modelId = parser.value("asr-model");
|
||||||
lc::StreamOptions sop = lc::app::streamOptions(parser);
|
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<lc::IAsr> asr = lc::app::makeAsr(parser, asrOpt, &err);
|
if (parser.isSet("screenshot-settings")) {
|
||||||
if (!asr) {
|
const QString path = parser.value("screenshot-settings");
|
||||||
std::fprintf(stderr, "%s\n", err.c_str());
|
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<lc::IAsr> 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;
|
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,
|
const int rc = QCoreApplication::exec();
|
||||||
parser.value("screenshot"),
|
// 显式收尾,确保工作线程停下来之后再释放后端
|
||||||
parser.value("screenshot-delay").toInt(),
|
live.stop();
|
||||||
parser.value("opacity").toInt());
|
asr.reset();
|
||||||
|
return rc;
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,25 @@
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"comment": "live-caption 的模型注册表。新增模型 = 在这里加一条,不需要改代码。",
|
"comment": "live-caption 的模型注册表。新增模型 = 在这里加一条,不需要改代码。",
|
||||||
"models": [
|
"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",
|
"id": "zipformer-zh-2025",
|
||||||
"name": "中文流式(k2-fsa 官方 2025-06-30)",
|
"name": "中文流式(k2-fsa 官方 2025-06-30)",
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
#include "modeldownloader.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QNetworkRequest>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QNetworkProxy>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#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<ModelFile> queue_;
|
||||||
|
size_t index_ = 0;
|
||||||
|
QString dir_;
|
||||||
|
QString mirrorBase_;
|
||||||
|
QNetworkProxy proxy_;
|
||||||
|
bool running_ = false;
|
||||||
|
bool cancel_ = false;
|
||||||
|
qint64 baseOffset_ = 0; // 断点续传的起始偏移
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lc::models
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#include "appsettings.h"
|
||||||
|
|
||||||
|
#include <QSettings>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QPoint>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -127,9 +127,9 @@ void CaptionWindow::applyStyle()
|
|||||||
QTextEdit {
|
QTextEdit {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-size: 15px;
|
font-size: %1px;
|
||||||
}
|
}
|
||||||
)"));
|
)").arg(fontSize_));
|
||||||
}
|
}
|
||||||
|
|
||||||
void CaptionWindow::paintEvent(QPaintEvent *)
|
void CaptionWindow::paintEvent(QPaintEvent *)
|
||||||
@@ -144,6 +144,28 @@ void CaptionWindow::paintEvent(QPaintEvent *)
|
|||||||
p.drawRoundedRect(rect(), kRadius, kRadius);
|
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)
|
void CaptionWindow::setPanelOpacity(int percent)
|
||||||
{
|
{
|
||||||
const int clamped = qBound(20, percent, 100);
|
const int clamped = qBound(20, percent, 100);
|
||||||
@@ -159,7 +181,7 @@ int CaptionWindow::lineHeight() const
|
|||||||
|
|
||||||
int CaptionWindow::textHeight() const
|
int CaptionWindow::textHeight() const
|
||||||
{
|
{
|
||||||
return lineHeight() * (collapsed_ ? kCollapsedLines : kExpandedLines)
|
return lineHeight() * (collapsed_ ? collapsedLines_ : expandedLines_)
|
||||||
+ (int) text_->document()->documentMargin() * 2;
|
+ (int) text_->document()->documentMargin() * 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,8 +228,10 @@ void CaptionWindow::setCaption(const QString & committed, const QString & tail)
|
|||||||
|
|
||||||
// 已稳定部分正常色,不稳定尾部暗色 —— 和 CLI 版渲染一致
|
// 已稳定部分正常色,不稳定尾部暗色 —— 和 CLI 版渲染一致
|
||||||
const QString html =
|
const QString html =
|
||||||
QStringLiteral("<div>%1<span style=\"color:#9a9a9a\">%2</span></div>")
|
showTail_
|
||||||
.arg(escapeHtml(committed_), escapeHtml(tail_));
|
? QStringLiteral("<div>%1<span style=\"color:#9a9a9a\">%2</span></div>")
|
||||||
|
.arg(escapeHtml(committed_), escapeHtml(tail_))
|
||||||
|
: QStringLiteral("<div>%1</div>").arg(escapeHtml(committed_));
|
||||||
text_->setHtml(html);
|
text_->setHtml(html);
|
||||||
scrollToEnd();
|
scrollToEnd();
|
||||||
}
|
}
|
||||||
@@ -231,6 +255,7 @@ void CaptionWindow::mouseMoveEvent(QMouseEvent * e)
|
|||||||
|
|
||||||
void CaptionWindow::mouseReleaseEvent(QMouseEvent * e)
|
void CaptionWindow::mouseReleaseEvent(QMouseEvent * e)
|
||||||
{
|
{
|
||||||
|
if (dragging_) Q_EMIT moved(pos());
|
||||||
dragging_ = false;
|
dragging_ = false;
|
||||||
e->accept();
|
e->accept();
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -29,9 +29,14 @@ public:
|
|||||||
void setPanelOpacity(int percent);
|
void setPanelOpacity(int percent);
|
||||||
int panelOpacity() const { return opacityPercent_; }
|
int panelOpacity() const { return opacityPercent_; }
|
||||||
|
|
||||||
|
void setTextFontSize(int px);
|
||||||
|
void setLineCounts(int collapsed, int expanded);
|
||||||
|
void setShowTail(bool on);
|
||||||
|
|
||||||
Q_SIGNALS:
|
Q_SIGNALS:
|
||||||
void languageClicked();
|
void languageClicked();
|
||||||
void closed();
|
void closed();
|
||||||
|
void moved(const QPoint & topLeft);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void mousePressEvent(QMouseEvent * e) override;
|
void mousePressEvent(QMouseEvent * e) override;
|
||||||
@@ -51,7 +56,11 @@ private:
|
|||||||
QPushButton * toggleBtn_ = nullptr;
|
QPushButton * toggleBtn_ = nullptr;
|
||||||
QTextEdit * text_ = 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; // 背景不透明度(百分比)
|
int opacityPercent_ = 84; // 背景不透明度(百分比)
|
||||||
bool dragging_ = false;
|
bool dragging_ = false;
|
||||||
QPoint dragOffset_;
|
QPoint dragOffset_;
|
||||||
|
|||||||
@@ -4,10 +4,7 @@
|
|||||||
|
|
||||||
namespace lc {
|
namespace lc {
|
||||||
|
|
||||||
LiveTranscriber::LiveTranscriber(IAsr & asr, QObject * parent)
|
LiveTranscriber::LiveTranscriber(QObject * parent) : QObject(parent) {}
|
||||||
: QObject(parent), asr_(asr)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
LiveTranscriber::~LiveTranscriber()
|
LiveTranscriber::~LiveTranscriber()
|
||||||
{
|
{
|
||||||
@@ -19,14 +16,14 @@ QString LiveTranscriber::deviceName() const
|
|||||||
return pipeline_ ? QString::fromStdString(pipeline_->deviceName()) : QString();
|
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();
|
stop();
|
||||||
|
|
||||||
LivePipelineOptions popt;
|
LivePipelineOptions popt;
|
||||||
popt.stream = opts;
|
popt.stream = opts;
|
||||||
|
|
||||||
pipeline_ = std::make_unique<LivePipeline>(asr_, popt);
|
pipeline_ = std::make_unique<LivePipeline>(asr, popt);
|
||||||
|
|
||||||
// 回调在工作线程上被调用;连到主线程的接收者时 Qt 会自动排队投递。
|
// 回调在工作线程上被调用;连到主线程的接收者时 Qt 会自动排队投递。
|
||||||
pipeline_->setCaptionCallback([this](const std::string & committed,
|
pipeline_->setCaptionCallback([this](const std::string & committed,
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ class LiveTranscriber : public QObject {
|
|||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit LiveTranscriber(IAsr & asr, QObject * parent = nullptr);
|
explicit LiveTranscriber(QObject * parent = nullptr);
|
||||||
~LiveTranscriber() override;
|
~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();
|
void stop();
|
||||||
|
|
||||||
QString deviceName() const;
|
QString deviceName() const;
|
||||||
@@ -32,7 +33,6 @@ Q_SIGNALS:
|
|||||||
void failed(const QString & message);
|
void failed(const QString & message);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
IAsr & asr_;
|
|
||||||
std::unique_ptr<LivePipeline> pipeline_;
|
std::unique_ptr<LivePipeline> pipeline_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
#include "settingsdialog.h"
|
||||||
|
|
||||||
|
#include <QCheckBox>
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QDesktopServices>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QDoubleSpinBox>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QFormLayout>
|
||||||
|
#include <QGroupBox>
|
||||||
|
#include <QHeaderView>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QNetworkProxy>
|
||||||
|
#include <QProgressBar>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QSlider>
|
||||||
|
#include <QSpinBox>
|
||||||
|
#include <QTabWidget>
|
||||||
|
#include <QTableWidget>
|
||||||
|
#include <QUrl>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#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
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QDialog>
|
||||||
|
|
||||||
|
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
|
||||||
Reference in new issue
Block a user