Spaces:
Running
Running
<!-- | |
VOICEVOXエンジンの設定ページです。 | |
VueとBootstrapを使っています。 | |
ライブラリを読み込んだあと、Vueコンポーネントの初期化が完了してからUIを表示します。 | |
--> | |
<html lang="ja"> | |
<head> | |
<meta charset="utf-8" /> | |
<title>VOICEVOX Engine 設定</title> | |
<link | |
rel="shortcut icon" | |
href="https://voicevox.hiroshiba.jp/favicon-32x32.png" | |
/> | |
<style> | |
.before-init-fadein { | |
animation: fadein 0.5s; | |
} | |
/* 指定時間の最後に現れるフェードイン */ | |
@keyframes fadein { | |
0% { | |
opacity: 0; | |
} | |
95% { | |
opacity: 0; | |
} | |
100% { | |
opacity: 1; | |
} | |
} | |
</style> | |
</head> | |
<body> | |
<!-- Vueの準備が完了した後にdisplay: noneにする --> | |
<div id="before-init" style="display: block" class="before-init-fadein"> | |
<p>読み込み中です。表示には数秒かかることがあります。</p> | |
</div> | |
<!-- Vueの準備が完了した後にdisplay: blockにする --> | |
<div id="app" class="container p-3" style="display: none"> | |
<h1 class="mb-3">{{brandName}} エンジン 設定</h1> | |
<div class="alert alert-warning" role="alert"> | |
変更を反映するにはエンジンの再起動が必要です。 | |
</div> | |
<div class="mb-3"> | |
<label class="form-label">CORS Policy Mode</label> | |
<select | |
class="form-select" | |
aria-label="corsPolicyMode" | |
v-model="corsPolicyMode" | |
> | |
<option value="localapps">localapps</option> | |
<option value="all">all</option> | |
</select> | |
<div class="form-text"> | |
<p class="mb-1"> | |
localappsはオリジン間リソース共有ポリシーを、app://.とlocalhost関連に限定します。 | |
</p> | |
<p class="mb-1"> | |
その他のオリジンはAllow Originオプションで追加できます。 | |
</p> | |
<p>allはすべてを許可します。危険性を理解した上でご利用ください。</p> | |
</div> | |
</div> | |
<div class="mb-3"> | |
<label class="form-label">Allow Origin</label> | |
<input | |
class="form-control" | |
type="text" | |
v-model.trim.lazy="allowOrigin" | |
/> | |
<div class="form-text"> | |
許可するオリジンを指定します。スペースで区切ることで複数指定できます。 | |
</div> | |
</div> | |
<div class="mb-3"> | |
<label class="form-label">ユーザー辞書のインポート</label> | |
<div class="col-12"> | |
<button | |
type="button" | |
class="btn btn-primary" | |
data-bs-toggle="modal" | |
data-bs-target="#importUserDictModal" | |
> | |
インポート | |
</button> | |
</div> | |
</div> | |
<div class="mb-3"> | |
<label class="form-label">ユーザー辞書のエクスポート</label> | |
<div class="col-12"> | |
<a | |
download="VOICEVOXユーザー辞書.json" | |
class="btn btn-primary mb-3" | |
href="/user_dict" | |
@click="showToastWithMessage('辞書をエクスポートしました。');" | |
target="_blank" | |
rel="noopener noreferrer" | |
> | |
エクスポート | |
</a> | |
</div> | |
</div> | |
<!-- ユーザー辞書インポート用モーダル --> | |
<div | |
class="modal fade" | |
id="importUserDictModal" | |
tabindex="-1" | |
aria-labelledby="importUserDictModalLabel" | |
aria-hidden="true" | |
> | |
<div class="modal-dialog"> | |
<div class="modal-content"> | |
<div class="modal-header"> | |
<h5 class="modal-title" id="importUserDictModalLabel"> | |
ユーザー辞書のインポート | |
</h5> | |
<button | |
type="button" | |
class="btn-close" | |
data-bs-dismiss="modal" | |
aria-label="Close" | |
></button> | |
</div> | |
<div class="modal-body"> | |
<input | |
class="form-control" | |
type="file" | |
accept="application/json" | |
@change="(e) => { userDictFileForImport = e.target.files[0]; }" | |
/> | |
</div> | |
<div class="modal-footer"> | |
<button | |
type="button" | |
class="btn btn-secondary" | |
data-bs-dismiss="modal" | |
> | |
キャンセル | |
</button> | |
<button | |
type="button" | |
@click="importUserDict" | |
class="btn btn-primary" | |
data-bs-dismiss="modal" | |
:disabled="userDictFileForImport == undefined" | |
> | |
インポート | |
</button> | |
</div> | |
</div> | |
</div> | |
</div> | |
<!-- トースト --> | |
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 5"> | |
<div | |
class="toast align-items-center autohide text-white bg-success" | |
role="alert" | |
aria-live="assertive" | |
aria-atomic="true" | |
ref="toastElem" | |
> | |
<div class="d-flex"> | |
<div class="toast-body">{{toastMessage}}</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<script> | |
// Vueの初期化 | |
function initVue() { | |
const { createApp, ref, watch, onMounted } = Vue; | |
createApp({ | |
setup() { | |
// 設定値周り | |
const corsPolicyMode = ref( | |
"<JINJA_PRE>cors_policy_mode<JINJA_POST>" | |
); | |
const allowOrigin = ref("<JINJA_PRE>allow_origin<JINJA_POST>"); | |
// 設定が変更されたら自動保存 | |
watch([corsPolicyMode, allowOrigin], () => { | |
const formData = new FormData(); | |
formData.append("cors_policy_mode", corsPolicyMode.value); | |
formData.append("allow_origin", allowOrigin.value); | |
fetch("/setting", { | |
method: "POST", | |
mode: "same-origin", | |
body: formData, | |
}).then((res) => { | |
if (res.ok) { | |
showToastWithMessage("設定を保存しました。"); | |
} else { | |
showToastWithMessage("設定の保存に失敗しました。"); | |
} | |
}); | |
}); | |
// ユーザー辞書周り | |
const userDictFileForImport = ref(); | |
const importUserDict = () => { | |
if (userDictFileForImport.value == undefined) { | |
throw new Error("userDictFileForImportが見つかりません。"); | |
} | |
const reader = new FileReader(); | |
reader.addEventListener("load", async () => { | |
const params = new URLSearchParams({ | |
override: true, // 重複するエントリを上書きする | |
}); | |
await fetch(`/import_user_dict?${params}`, { | |
method: "POST", | |
mode: "same-origin", | |
headers: { "Content-Type": "application/json" }, | |
body: reader.result, | |
}); | |
showToastWithMessage("辞書をインポートしました。"); | |
}); | |
reader.readAsText(userDictFileForImport.value); | |
}; | |
// トースト | |
const toastElem = ref(undefined); | |
const bootstrapToast = ref(undefined); | |
const toastMessage = ref(""); | |
onMounted(() => { | |
if (toastElem.value == undefined) { | |
throw new Error("toastElemが見つかりません。"); | |
} | |
bootstrapToast.value = new bootstrap.Toast(toastElem.value); | |
}); | |
const showToastWithMessage = (message) => { | |
console.log(`showToastWithMessage: ${message}`); | |
bootstrapToast.value.show(); | |
toastMessage.value = message; | |
}; | |
// 表示用の情報 | |
const brandName = ref("<JINJA_PRE>brand_name<JINJA_POST>"); | |
// Vueの準備が完了したら表示・非表示を切り替える | |
onMounted(() => { | |
document.getElementById("before-init").style.display = "none"; | |
document.getElementById("app").style.display = "block"; | |
}); | |
return { | |
corsPolicyMode, | |
allowOrigin, | |
userDictFileForImport, | |
importUserDict, | |
toastElem, | |
toastMessage, | |
showToastWithMessage, | |
brandName, | |
}; | |
}, | |
}).mount("#app"); | |
} | |
/** | |
* CDNからscriptやCSSを読み込む。 | |
* CDNが使えないときのために複数の候補を試す。 | |
*/ | |
const loadCDN = async (scriptOrCss, candidateUrlList, integrity) => { | |
if (scriptOrCss !== "script" && scriptOrCss !== "css") { | |
throw new Error("scriptOrCssはscriptかcssを指定してください。"); | |
} | |
let current = 0; | |
await new Promise((resolve, reject) => { | |
const loadNext = async () => { | |
if (current >= candidateUrlList.length) { | |
reject(new Error("全てのCDNで読み込みに失敗しました。")); | |
return; | |
} | |
let elem; | |
if (scriptOrCss === "script") { | |
elem = document.createElement("script"); | |
elem.src = candidateUrlList[current]; | |
} else { | |
elem = document.createElement("link"); | |
elem.href = candidateUrlList[current]; | |
elem.rel = "stylesheet"; | |
} | |
elem.integrity = integrity; | |
elem.crossOrigin = "anonymous"; | |
elem.onload = resolve; | |
elem.onerror = () => { | |
console.warn( | |
`CDNの読み込みに失敗しました。 ${candidateUrlList[current]}` | |
); | |
document.head.removeChild(elem); | |
current++; | |
loadNext(); | |
}; | |
document.head.appendChild(elem); | |
}; | |
loadNext(); | |
}); | |
}; | |
// 初期化用の関数 | |
const init = async () => { | |
// ライブラリ読み込み用のPromiseリスト | |
const libraryLoadingPromises = []; | |
// Bootstrapを読み込む | |
const bootstrapCssPromise = loadCDN( | |
"css", | |
[ | |
"https://unpkg.com/[email protected]/dist/css/bootstrap.min.css", | |
"https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css", | |
"https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.0.2/css/bootstrap.min.css", | |
], | |
"sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" | |
); | |
libraryLoadingPromises.push(bootstrapCssPromise); | |
const bootstrapScriptPromise = loadCDN( | |
"script", | |
[ | |
"https://unpkg.com/[email protected]/dist/js/bootstrap.bundle.min.js", | |
"https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js", | |
"https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.0.2/js/bootstrap.bundle.min.js", | |
], | |
"sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" | |
); | |
libraryLoadingPromises.push(bootstrapScriptPromise); | |
// Vueを読み込む | |
const vuePromise = loadCDN( | |
"script", | |
[ | |
"https://unpkg.com/[email protected]/dist/vue.global.js", | |
"https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.global.js", | |
"https://cdnjs.cloudflare.com/ajax/libs/vue/3.3.10/vue.global.js", | |
], | |
"sha384-ttfhgYK68lNlS8ak6Z//mvUbpRbRCh43MYGuqEtK8mj/yzlKqY8GA8o3BPMi23cE" | |
); | |
libraryLoadingPromises.push(vuePromise); | |
// ライブラリの読み込みが完了したらVueを初期化 | |
await Promise.all(libraryLoadingPromises); | |
initVue(); | |
}; | |
init(); | |
</script> | |
</body> | |
</html> | |