Files
msime-windows/engine/voice/assets/html/settings.html
T
fanlusky a7779d2b05 refactor(engine): 将 engine 从 submodule 内嵌为仓内一等组件
`vendor/MetasequoiaImeEngine` submodule 改为 `engine/` 目录,导入的是
product-lock.json 原本钉住的提交 c810d201,逐文件核对过 blob,因此这是一次
搬迁而不是引擎升级。引擎自己的嵌套 submodule(googlepinyinime-rev、utfcpp、
miniaudio)按各自 gitlink 一并展开,来源与提交见 engine/UPSTREAM.md。

Windows 专门化裁掉了不属于本产品的部分:whisper.cpp 与本地推理 provider、
macOS 示例宿主、整个 dictionary/ 建库流水线、上游 CI 与 release-please 配置。
词库仍按 product-lock.json 在构建时取发布产物。

引擎不再进 product-lock.json:没有 gitlink 就没有可比对的东西,锁里写一个上游
commit 等于宣称这棵树仍等于上游,而第一个 Windows 专门化改动落地时这句话就假了。
verify-contracts / verify-checkout 随之移除,verify-published 保留。

CI 与工具链:引擎源码并入根 scripts/format.sh(删掉引擎自己的那份,避免两套排除
列表分叉);引擎 voice.yml 里的 silero_vad.onnx 摘要校验移到本仓 lock job;
engine-bump-triage 工作流与其测试删除;gitsubmodule 的 dependabot 频率由 daily
改为 monthly。

README、README.en、AGENTS.md、CONTRIBUTING、docs/product-release.md 及各子目录
文档同步说明新的位置与边界。
2026-09-12 03:40:48 +08:00

872 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>(MVI)Settings</title>
<link rel="icon" href="./imgs/app.png">
<style>
:root {
--accent-color: #8e8cd8;
--bg-color: #202020;
--sidebar-hover: rgba(255, 255, 255, 0.08);
--item-active: rgba(255, 255, 255, 0.06);
--text-color: #f3f3f3;
--subtext-color: #c7c7c7;
--border-color: rgba(255, 255, 255, 0.08);
--card-bg: rgba(38, 38, 38, 0.6);
--input-bg: rgba(45, 45, 45, 0.8);
--danger: #c95f5f;
--ok: #4caf81;
}
* {
box-sizing: border-box;
font-family: "Noto Sans SC", "Segoe UI Variable", "Segoe UI", sans-serif;
}
body {
margin: 0;
width: 100vw;
height: 100vh;
display: flex;
color: var(--text-color);
background: var(--bg-color);
overflow: hidden;
}
.sidebar {
width: 220px;
padding: 16px 10px;
overflow-y: auto;
position: relative;
/* scrollbar-width: thin; */
scrollbar-color: rgba(170, 170, 170, 0.35) transparent;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 10px;
padding: 0 10px 16px;
margin-bottom: 6px;
}
.sidebar-header .logo {
width: 28px;
height: 28px;
border-radius: 6px;
object-fit: cover;
display: block;
}
.sidebar-header .name {
font-size: 18px;
font-weight: 600;
}
.nav-item {
margin: 4px 6px;
padding: 10px 12px;
border-radius: 8px;
color: #e8e8e8;
cursor: default;
display: flex;
align-items: center;
gap: 10px;
position: relative;
user-select: none;
}
.nav-item:hover {
background: var(--sidebar-hover);
}
.nav-item.active {
background: var(--item-active);
}
.nav-indicator {
position: absolute;
left: 0;
top: 0;
width: 4px;
height: 18px;
border-radius: 4px;
background: var(--accent-color);
pointer-events: none;
opacity: 0;
z-index: 2;
}
.nav-indicator.ready {
transition: transform 0.24s ease, opacity 0.18s ease;
}
.content {
flex: 1;
overflow-y: auto;
padding: 20px;
/* scrollbar-width: thin; */
scrollbar-color: rgba(170, 170, 170, 0.35) transparent;
display: flex;
flex-direction: column;
align-items: center;
}
.sidebar::-webkit-scrollbar,
.content::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.sidebar::-webkit-scrollbar-track,
.content::-webkit-scrollbar-track {
background: transparent;
}
.sidebar::-webkit-scrollbar-thumb,
.content::-webkit-scrollbar-thumb {
background: rgba(170, 170, 170, 0.35);
border-radius: 3px;
}
.sidebar::-webkit-scrollbar-thumb:hover,
.content::-webkit-scrollbar-thumb:hover {
background: rgba(205, 205, 205, 0.45);
}
.content-page {
display: none;
width: 100%;
max-width: 800px;
}
.content-page.active {
display: block;
}
.page-title {
margin: 0 0 14px;
font-size: 20px;
font-weight: 600;
}
.section {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.22);
}
.section-title {
margin: 0 0 12px;
color: #d2d2d2;
font-size: 15px;
font-weight: 600;
}
.about-text {
margin: 0;
color: #dfdfdf;
line-height: 1.7;
font-size: 14px;
}
.about-meta {
margin: 10px 0 0;
color: var(--subtext-color);
font-size: 13px;
line-height: 1.7;
}
.about-link-row {
margin-top: 12px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.about-link-label {
color: var(--subtext-color);
font-size: 13px;
}
.about-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid var(--border-color);
background: rgba(255, 255, 255, 0.04);
color: #e8e4ff;
font-size: 13px;
text-decoration: none;
transition: background 0.2s ease, border-color 0.2s ease, transform 0.18s ease;
}
.about-link:hover {
background: rgba(142, 140, 216, 0.2);
border-color: rgba(142, 140, 216, 0.55);
transform: translateY(-1px);
}
.about-link:focus-visible {
outline: 2px solid rgba(142, 140, 216, 0.55);
outline-offset: 2px;
}
.field {
display: grid;
gap: 6px;
margin-bottom: 12px;
}
.field:last-child {
margin-bottom: 0;
}
.field label {
font-size: 13px;
color: var(--subtext-color);
}
input[type="text"],
input[type="password"] {
width: 100%;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
color: #f3f3f3;
padding: 9px 10px;
font-size: 14px;
outline: none;
}
input[type="text"]:focus,
input[type="password"]:focus {
border-color: var(--accent-color);
}
input[type="password"] {
letter-spacing: 2px;
font-family: "Segoe UI", sans-serif;
}
.dropdown {
position: relative;
width: 100%;
}
.dropdown-toggle {
width: 100%;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
color: #f3f3f3;
padding: 9px 10px;
font-size: 14px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
text-align: left;
}
.dropdown-toggle:hover {
background: rgba(50, 50, 50, 0.9);
}
.dropdown.open .dropdown-toggle {
border-color: var(--accent-color);
}
.arrow {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.arrow svg {
width: 18px;
height: 18px;
display: block;
transition: transform 0.4s ease;
transform-origin: center;
}
.dropdown.open .arrow svg {
transform: rotate(180deg);
}
.dropdown-menu {
display: none;
position: absolute;
top: calc(100% + 4px);
left: 0;
width: 100%;
background: #282828;
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 2px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
z-index: 20;
}
.dropdown.open .dropdown-menu {
display: block;
}
.dropdown-item {
width: 100%;
border: 0;
background: transparent;
color: #eee;
border-radius: 8px;
padding: 7px 8px;
text-align: left;
cursor: pointer;
font-size: 14px;
}
.dropdown-item:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.switch-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.switch-row:last-child {
border-bottom: 0;
}
.switch-label {
display: flex;
flex-direction: column;
gap: 2px;
}
.switch-label strong {
font-size: 14px;
font-weight: 500;
}
.switch-label span {
font-size: 12px;
color: var(--subtext-color);
}
.switch-input {
appearance: none;
-webkit-appearance: none;
width: 38px;
height: 20px;
border-radius: 999px;
background: #555;
position: relative;
cursor: pointer;
transition: background 0.2s ease;
border: 1px solid transparent;
flex-shrink: 0;
}
.switch-input::after {
content: "";
position: absolute;
top: 50%;
left: 1px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
transform: translateY(-50%);
transition: transform 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.switch-input:checked {
background: var(--accent-color);
}
.switch-input:checked::after {
transform: translate(18px, -50%);
}
.actions {
margin-top: 18px;
display: flex;
gap: 10px;
align-items: center;
width: 100%;
max-width: 800px;
}
.actions.hidden {
display: none;
}
button {
border: 0;
border-radius: 8px;
padding: 9px 14px;
cursor: default;
font-size: 14px;
}
.btn-primary {
background: #3c2d47;
color: #fff;
}
.btn-secondary {
background: #3a3a3a;
color: #f3f3f3;
}
.status {
font-size: 13px;
color: var(--subtext-color);
min-height: 18px;
}
.status.ok {
color: var(--ok);
}
.status.err {
color: var(--danger);
}
@media (max-width: 760px) {
body {
flex-direction: column;
}
.sidebar {
width: 100%;
border-right: 0;
border-bottom: 1px solid var(--border-color);
padding: 10px;
}
.content {
padding: 12px;
}
}
</style>
</head>
<body>
<aside class="sidebar">
<div class="sidebar-header">
<img class="logo" src="imgs/app.png" alt="App Logo" />
<div class="name">水杉记言</div>
</div>
<div class="nav-item active" data-target="api-page">🔌 API 配置</div>
<div class="nav-item" data-target="basic-page">🧩 基础设置</div>
<div class="nav-item" data-target="about-page">ℹ️ 关于</div>
</aside>
<main class="content">
<section id="api-page" class="content-page active">
<h1 class="page-title">API 配置</h1>
<div class="section">
<h2 class="section-title">ASR 接口([asr_api])</h2>
<div class="field">
<label for="asr-provider">provider</label>
<input id="asr-provider" type="text" value="siliconflow" />
</div>
<div class="field">
<label for="asr-token">token</label>
<input id="asr-token" type="password" value="" placeholder="填入你自己的 SiliconFlow API key" />
</div>
<div class="field">
<label for="asr-endpoint">endpoint</label>
<input id="asr-endpoint" type="text" value="https://api.siliconflow.cn/v1/audio/transcriptions" />
</div>
</div>
<div class="section">
<h2 class="section-title">润色接口([polish_api])</h2>
<div class="field">
<label for="polish-provider">provider</label>
<input id="polish-provider" type="text" value="siliconflow" />
</div>
<div class="field">
<label for="polish-token">token</label>
<input id="polish-token" type="password" value="" placeholder="填入你自己的 SiliconFlow API key" />
</div>
<div class="field">
<label for="polish-endpoint">endpoint</label>
<input id="polish-endpoint" type="text" value="https://api.siliconflow.cn/v1/chat/completions" />
</div>
</div>
</section>
<section id="basic-page" class="content-page">
<h1 class="page-title">基础设置</h1>
<div class="section">
<h2 class="section-title">[settings]</h2>
<div class="field">
<label for="language-btn">language</label>
<div class="dropdown" id="language-dropdown">
<button id="language-btn" class="dropdown-toggle" type="button">
<span class="dropdown-value">zh-cn</span>
<span class="arrow" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.5 7.5L10 12L14.5 7.5" stroke="#cfcfcf" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</span>
</button>
<div class="dropdown-menu">
<button class="dropdown-item" type="button" data-value="zh-cn">zh-cn</button>
<button class="dropdown-item" type="button" data-value="en-us">en-us</button>
</div>
<input id="language" type="hidden" value="zh-cn" />
</div>
</div>
<div class="field">
<label for="stt-provider-btn">云端语音识别</label>
<div class="dropdown" id="stt-provider-dropdown">
<button id="stt-provider-btn" class="dropdown-toggle" type="button">
<span class="dropdown-value">cloud_siliconflow</span>
<span class="arrow" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.5 7.5L10 12L14.5 7.5" stroke="#cfcfcf" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</span>
</button>
<div class="dropdown-menu">
<button class="dropdown-item" type="button" data-value="cloud_siliconflow">cloud_siliconflow</button>
</div>
<input id="stt-provider" type="hidden" value="cloud_siliconflow" />
</div>
</div>
<div class="switch-row">
<div class="switch-label">
<strong>上屏前先进行文本润色</strong>
<span>polish_text</span>
</div>
<input id="polish-text" class="switch-input" type="checkbox" />
</div>
<div class="switch-row">
<div class="switch-label">
<strong>触发时播放提示音</strong>
<span>notification_sound</span>
</div>
<input id="notification-sound" class="switch-input" type="checkbox" checked />
</div>
</div>
</section>
<section id="about-page" class="content-page">
<h1 class="page-title">关于</h1>
<div class="section">
<h2 class="section-title">项目简介</h2>
<p class="about-text">
<span>水杉记言</span>(Metasequoia Voice
Input)是一个面向中文输入场景的开源语音输入工具,源代码在 GitHub 上可查看。本项目通过语音识别(ASR)将语音实时转换为文本,并可选接入文本润色能力,帮助你以更自然、更高效的方式完成输入。
</p>
<br>
<p class="about-text">
当前设置窗口用于管理 ASR API、润色 API 和基础行为配置。您可以在“API 配置”和“基础设置”中修改参数,并通过“保存设置”提交到宿主程序。
</p>
<br>
<div class="about-link-row">
<span class="about-link-label">GitHub:</span>
<a class="about-link" href="https://github.com/metasequoiaime/MSIME-Engine/tree/main/voice" target="_blank"
rel="noopener noreferrer">MetasequoiaVoiceInput ↗</a>
</div>
<div class="about-link-row">
<span class="about-link-label">作者的更多作品,Windows 文字输入法:</span>
<a class="about-link" href="https://github.com/fanlumaster/MetasequoiaImeTsf" target="_blank"
rel="noopener noreferrer">水杉输入法 ↗</a>
</div>
</div>
</section>
<div class="actions">
<button id="save-btn" class="btn-primary" type="button">保存设置</button>
<button id="reset-btn" class="btn-secondary" type="button">恢复默认</button>
<span id="status" class="status"></span>
</div>
</main>
<script>
const defaultConfig = {
asr_api: {
provider: "siliconflow",
token: "",
endpoint: "https://api.siliconflow.cn/v1/audio/transcriptions"
},
polish_api: {
provider: "siliconflow",
token: "",
endpoint: "https://api.siliconflow.cn/v1/chat/completions"
},
settings: {
language: "zh-cn",
polish_text: false,
notification_sound: true,
stt_provider: "cloud_siliconflow"
}
};
const $ = (id) => document.getElementById(id);
function setStatus(text, type) {
const el = $("status");
el.textContent = text;
el.classList.remove("ok", "err");
if (type) {
el.classList.add(type);
}
}
function mergeConfigWithDefaults(config) {
const safe = config && typeof config === "object" ? config : {};
const asr = safe.asr_api && typeof safe.asr_api === "object" ? safe.asr_api : {};
const polish = safe.polish_api && typeof safe.polish_api === "object" ? safe.polish_api : {};
const settings = safe.settings && typeof safe.settings === "object" ? safe.settings : {};
return {
asr_api: {
provider: typeof asr.provider === "string" ? asr.provider : defaultConfig.asr_api.provider,
token: typeof asr.token === "string" ? asr.token : defaultConfig.asr_api.token,
endpoint: typeof asr.endpoint === "string" ? asr.endpoint : defaultConfig.asr_api.endpoint
},
polish_api: {
provider: typeof polish.provider === "string" ? polish.provider : defaultConfig.polish_api.provider,
token: typeof polish.token === "string" ? polish.token : defaultConfig.polish_api.token,
endpoint: typeof polish.endpoint === "string" ? polish.endpoint : defaultConfig.polish_api.endpoint
},
settings: {
language: typeof settings.language === "string" ? settings.language : defaultConfig.settings.language,
polish_text: typeof settings.polish_text === "boolean" ? settings.polish_text : defaultConfig.settings.polish_text,
notification_sound: typeof settings.notification_sound === "boolean" ? settings.notification_sound : defaultConfig.settings.notification_sound,
stt_provider: defaultConfig.settings.stt_provider
}
};
}
function readConfigFromForm() {
return {
asr_api: {
provider: $("asr-provider").value.trim(),
token: $("asr-token").value.trim(),
endpoint: $("asr-endpoint").value.trim()
},
polish_api: {
provider: $("polish-provider").value.trim(),
token: $("polish-token").value.trim(),
endpoint: $("polish-endpoint").value.trim()
},
settings: {
language: $("language").value,
polish_text: $("polish-text").checked,
notification_sound: $("notification-sound").checked,
stt_provider: $("stt-provider").value
}
};
}
function applyConfigToForm(config) {
$("asr-provider").value = config.asr_api.provider;
$("asr-token").value = config.asr_api.token;
$("asr-endpoint").value = config.asr_api.endpoint;
$("polish-provider").value = config.polish_api.provider;
$("polish-token").value = config.polish_api.token;
$("polish-endpoint").value = config.polish_api.endpoint;
$("language").value = config.settings.language;
$("polish-text").checked = config.settings.polish_text;
$("notification-sound").checked = config.settings.notification_sound;
$("stt-provider").value = config.settings.stt_provider;
syncDropdownText("language-dropdown");
syncDropdownText("stt-provider-dropdown");
}
function closeAllDropdowns() {
document.querySelectorAll(".dropdown.open").forEach((el) => {
el.classList.remove("open");
});
}
function syncDropdownText(dropdownId) {
const dropdown = document.getElementById(dropdownId);
if (!dropdown) {
return;
}
const hiddenInput = dropdown.querySelector("input[type='hidden']");
const valueEl = dropdown.querySelector(".dropdown-value");
const activeItem = dropdown.querySelector(`.dropdown-item[data-value="${hiddenInput.value}"]`);
valueEl.textContent = activeItem ? activeItem.textContent : hiddenInput.value;
}
function initDropdown(dropdownId) {
const dropdown = document.getElementById(dropdownId);
if (!dropdown) {
return;
}
const toggle = dropdown.querySelector(".dropdown-toggle");
const hiddenInput = dropdown.querySelector("input[type='hidden']");
toggle.addEventListener("click", (event) => {
event.stopPropagation();
const willOpen = !dropdown.classList.contains("open");
closeAllDropdowns();
if (willOpen) {
dropdown.classList.add("open");
}
});
dropdown.querySelectorAll(".dropdown-item").forEach((item) => {
item.addEventListener("click", () => {
hiddenInput.value = item.dataset.value;
syncDropdownText(dropdownId);
dropdown.classList.remove("open");
});
});
syncDropdownText(dropdownId);
}
function ensureNavIndicator() {
const sidebar = document.querySelector(".sidebar");
if (!sidebar) {
return null;
}
let indicator = sidebar.querySelector(".nav-indicator");
if (!indicator) {
indicator = document.createElement("div");
indicator.className = "nav-indicator";
sidebar.appendChild(indicator);
}
return indicator;
}
function moveNavIndicatorToItem(item) {
const sidebar = document.querySelector(".sidebar");
const indicator = ensureNavIndicator();
if (!sidebar || !indicator || !item) {
return;
}
const barLeft = item.offsetLeft - 2;
const barTop = item.offsetTop + (item.offsetHeight - 18) / 2;
if (!indicator.classList.contains("ready")) {
indicator.classList.add("ready");
indicator.style.transition = "none";
indicator.style.transform = `translate(${barLeft}px, ${barTop}px)`;
indicator.style.opacity = "1";
requestAnimationFrame(() => {
indicator.style.transition = "";
});
return;
}
indicator.style.transform = `translate(${barLeft}px, ${barTop}px)`;
indicator.style.opacity = "1";
}
function updateActionsVisibility(targetId) {
const actions = document.querySelector(".actions");
if (!actions) {
return;
}
const shouldHide = targetId === "about-page";
actions.classList.toggle("hidden", shouldHide);
}
document.querySelectorAll(".nav-item").forEach((item) => {
item.addEventListener("click", () => {
document.querySelector(".nav-item.active")?.classList.remove("active");
item.classList.add("active");
document.querySelector(".content-page.active")?.classList.remove("active");
const targetId = item.dataset.target;
document.getElementById(targetId)?.classList.add("active");
updateActionsVisibility(targetId);
moveNavIndicatorToItem(item);
});
});
$("save-btn").addEventListener("click", () => {
const config = readConfigFromForm();
try {
if (window.chrome?.webview) {
window.chrome.webview.postMessage({ action: "save_settings", config });
} else {
setStatus("当前不在宿主环境,无法写入配置文件", "err");
return;
}
} catch (err) {
setStatus("保存失败: " + String(err), "err");
}
});
$("reset-btn").addEventListener("click", () => {
applyConfigToForm(defaultConfig);
setStatus("已恢复默认值", "ok");
});
document.addEventListener("click", () => {
closeAllDropdowns();
});
window.__applyHostConfig = (config) => {
const normalized = mergeConfigWithDefaults(config);
applyConfigToForm(normalized);
setStatus("已加载本地配置", "ok");
};
window.__onHostSaveResult = (result) => {
if (!result || typeof result !== "object") {
setStatus("保存结果无效", "err");
return;
}
const message = typeof result.message === "string" ? result.message : (result.success ? "设置已保存" : "保存失败");
setStatus(message, result.success ? "ok" : "err");
};
initDropdown("language-dropdown");
initDropdown("stt-provider-dropdown");
applyConfigToForm(defaultConfig);
updateActionsVisibility("api-page");
moveNavIndicatorToItem(document.querySelector(".nav-item.active"));
if (window.chrome?.webview) {
window.chrome.webview.postMessage({ action: "load_settings" });
}
window.addEventListener("resize", () => {
moveNavIndicatorToItem(document.querySelector(".nav-item.active"));
});
</script>
</body>
</html>