<!DOCTYPE html>
<html lang="__RDSH_LOCALE__" data-rdsh-locale="__RDSH_LOCALE__">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>rdsh setup</title>
<link rel="icon" type="image/png" href="/icon.png">
<link rel="apple-touch-icon" href="/icon.png">
<style>
:root {
color-scheme: light;
--bg: #f6f6f3;
--card: #ffffff;
--line: #e6e5df;
--fg: #1f201c;
--mut: #6a6b64;
--soft: #f1f1ec;
--accent: #2f5fd0;
--accent-hover: #264fb3;
--accent-soft: #eaf0fc;
--ok: #2f7a4f;
--ok-soft: #e7f3ec;
--warn: #8a6214;
--warn-soft: #fbf2dc;
--danger: #a23b2b;
--shadow: 0 1px 2px rgba(31, 32, 28, .04), 0 12px 32px -18px rgba(31, 32, 28, .22);
--radius: 14px;
--ease: cubic-bezier(.2, .8, .25, 1);
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--bg: #131411;
--card: #1c1d19;
--line: #30312b;
--fg: #ededea;
--mut: #a3a59b;
--soft: #24251f;
--accent: #6f98f5;
--accent-hover: #86a9f7;
--accent-soft: #1d2a45;
--ok: #7cc99b;
--ok-soft: #1c3125;
--warn: #e2bd68;
--warn-soft: #33290f;
--danger: #f0907e;
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 16px 40px -20px rgba(0, 0, 0, .7);
}
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--fg);
font-family: "Segoe UI Variable Text", "Segoe UI", -apple-system, BlinkMacSystemFont, "Hiragino Sans", "Hiragino Kaku Gothic ProN", "BIZ UDPGothic", "Noto Sans JP", "Noto Sans CJK JP", "Yu Gothic UI", Meiryo, system-ui, sans-serif;
font-size: 15px;
line-height: 1.75;
letter-spacing: .02em;
font-feature-settings: "palt" 1;
-webkit-font-smoothing: antialiased;
}
:lang(en) body, html:lang(en) body { letter-spacing: 0; font-feature-settings: normal; }
.shell { max-width: 620px; margin: 0 auto; padding: 40px 20px 64px; }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 15px; }
.brand img { border-radius: 9px; }
.steps { display: flex; gap: 6px; margin: 28px 0 18px; padding: 0; list-style: none; }
.steps li { flex: 1; font-size: 12.5px; color: var(--mut); }
.steps li::before { content: ""; display: block; height: 4px; border-radius: 4px; background: var(--line); margin-bottom: 8px; transition: background .35s var(--ease); }
.steps li.done::before, .steps li.now::before { background: var(--accent); }
.steps li.now { color: var(--fg); font-weight: 600; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 18px; box-shadow: var(--shadow); padding: 32px 32px 28px; }
.step { animation: enter .32s var(--ease); }
@keyframes enter { from { opacity: 0; transform: translateY(8px); } }
h1 { margin: 0; font-size: 24px; line-height: 1.45; letter-spacing: .01em; font-weight: 700; }
.lead { margin: 8px 0 24px; color: var(--mut); }
h2 { margin: 0; font-size: 16px; font-weight: 700; line-height: 1.5; }
.small, .hint { font-size: 13px; color: var(--mut); }
.hint { margin: 10px 0 0; overflow-wrap: anywhere; }
.checks { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
.check { display: grid; grid-template-columns: 28px 1fr; gap: 4px 12px; padding: 16px 0; border-bottom: 1px solid var(--line); }
.mark { width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; font-size: 13px; font-weight: 700; margin-top: 1px; }
.mark.ok { background: var(--ok-soft); color: var(--ok); }
.mark.todo { background: var(--warn-soft); color: var(--warn); }
.check p { margin: 0; }
.check .small { grid-column: 2; }
.check .extra { grid-column: 2; margin-top: 6px; }
.option { border: 1px solid var(--line); border-radius: var(--radius); padding: 18px 18px 16px; margin-top: 12px; transition: border-color .2s, box-shadow .2s; }
.option.connected { border-color: color-mix(in srgb, var(--ok) 45%, var(--line)); }
.option .head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.option p { margin: 4px 0 0; }
.badge { font-size: 11.5px; font-weight: 600; padding: 1px 8px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); letter-spacing: .04em; }
.badge.ok { background: var(--ok-soft); color: var(--ok); }
.row-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; align-items: center; }
button, .button { font: inherit; font-size: 14px; font-weight: 600; border-radius: 10px; padding: 9px 16px; cursor: pointer; border: 1px solid var(--line); background: var(--card); color: var(--fg); transition: background .15s, border-color .15s, transform .08s, opacity .2s; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; line-height: 1.4; }
button:hover, .button:hover { border-color: color-mix(in srgb, var(--fg) 25%, var(--line)); }
button:active, .button:active { transform: scale(.98); }
button:disabled { opacity: .45; cursor: default; transform: none; }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
button.big { padding: 12px 20px; font-size: 15px; }
button.link, .linkish { border: 0; background: none; padding: 4px 2px; color: var(--accent); font-weight: 500; font-size: 13.5px; }
h1:focus { outline: none; }
:focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
.footer { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 28px; flex-wrap: wrap; }
.footer .right { display: flex; gap: 8px; margin-left: auto; }
.field { display: flex; gap: 8px; margin-top: 12px; }
input[type=password], select { flex: 1; min-width: 0; font: inherit; font-size: 14px; background: var(--soft); border: 1px solid var(--line); border-radius: 10px; padding: 9px 12px; color: var(--fg); outline: none; transition: border-color .15s, background .15s; }
input[type=password]:focus, select:focus { border-color: var(--accent); background: var(--card); }
.cmd { display: flex; align-items: center; gap: 8px; margin-top: 8px; padding: 7px 8px 7px 12px; border-radius: 10px; background: var(--soft); font-family: ui-monospace, "Cascadia Code", "SFMono-Regular", Menlo, Consolas, monospace; font-size: 13px; letter-spacing: 0; }
.cmd code { flex: 1; overflow-x: auto; white-space: nowrap; }
.banner { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-radius: 12px; font-weight: 600; font-size: 14px; margin: 0 0 16px; animation: enter .3s var(--ease); }
.banner.ok { background: var(--ok-soft); color: var(--ok); }
.wait { display: inline-flex; align-items: center; gap: 8px; font-size: 13.5px; color: var(--mut); }
.wait::before { content: ""; width: 14px; height: 14px; border-radius: 50%; border: 2px solid var(--line); border-top-color: var(--accent); animation: spin .9s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
details { margin-top: 18px; }
summary { cursor: pointer; font-size: 13.5px; color: var(--mut); list-style: none; display: inline-flex; align-items: center; gap: 6px; }
summary::-webkit-details-marker { display: none; }
summary::before { content: "›"; display: inline-block; transition: transform .2s var(--ease); font-size: 16px; line-height: 1; }
details[open] > summary::before { transform: rotate(90deg); }
.list .row { display: flex; align-items: center; gap: 10px; padding: 10px 2px; font-size: 13.5px; border-bottom: 1px solid var(--line); }
.list .row small { margin-left: auto; font-size: 12px; color: var(--mut); }
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.dot.on { background: var(--ok); }
.dot.off { background: var(--line); }
#extras .row { cursor: pointer; border-radius: 8px; transition: background .15s; padding-left: 8px; padding-right: 8px; }
#extras .row:hover { background: var(--soft); }
.done-mark { width: 56px; height: 56px; border-radius: 50%; background: var(--ok-soft); color: var(--ok); display: grid; place-items: center; margin-bottom: 18px; }
.done-mark svg { width: 28px; height: 28px; stroke-dasharray: 30; stroke-dashoffset: 30; animation: draw .5s .15s var(--ease) forwards; }
@keyframes draw { to { stroke-dashoffset: 0; } }
.starts { display: grid; gap: 10px; margin-top: 4px; }
.start { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 12px; text-align: left; width: 100%; padding: 14px 16px; border-radius: var(--radius); }
.start strong { display: block; font-size: 15px; }
.start span.small { display: block; font-weight: 400; }
.start::after { content: "→"; color: var(--mut); transition: transform .2s var(--ease); }
.start:hover::after { transform: translateX(3px); }
.start.primary::after { color: #fff; }
.start.primary span.small { color: rgba(255,255,255,.82); }
.is-connected .option:not(.connected), .is-connected #other-keys { display: none; }
#show-more { display: none; margin-top: 10px; }
.is-connected #show-more { display: inline-flex; }
.toast { position: fixed; left: 50%; bottom: 24px; transform: translate(-50%, 12px); opacity: 0; max-width: min(560px, calc(100vw - 32px)); background: var(--fg); color: var(--bg); padding: 10px 16px; border-radius: 12px; font-size: 13.5px; transition: opacity .25s, transform .25s var(--ease); pointer-events: none; }
.toast.show { opacity: 1; transform: translate(-50%, 0); }
@media (max-width: 560px) {
.shell { padding: 24px 16px 48px; }
.card { padding: 24px 18px 20px; border-radius: 16px; }
h1 { font-size: 21px; }
.field { flex-direction: column; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
.done-mark svg { stroke-dashoffset: 0; }
}
</style>
</head>
<body>
<div class="shell">
<div class="brand"><img src="/icon.png" width="30" height="30" alt=""> rdsh</div>
<ol class="steps" id="steps" aria-label="setup">
<li data-step="ready" id="step-label-ready">準備</li>
<li data-step="connect" id="step-label-connect">AIとつなぐ</li>
<li data-step="finish" id="step-label-finish">完了</li>
</ol>
<main class="card">
<section class="step" id="step-token" hidden>
<h1 id="t">はじめましょう</h1>
<p class="lead" id="s">ターミナルに表示されたファイルの中身を貼り付けると、設定画面につながります。</p>
<div id="token-box" class="field"><input type="password" id="setup-token" autocomplete="off" spellcheck="false" placeholder="ファイルの中身"><button class="primary" id="setup-connect" onclick="connectSetup()">接続</button></div>
<p class="hint" id="token-hint">次回からは <code>rdsh setup --web</code> を実行すると、自動でつながった状態で開きます。</p>
</section>
<section class="step" id="step-ready" hidden>
<h1 id="ready-title">まずは準備を確認します</h1>
<p class="lead" id="ready-lead">必要なものがそろっているか、自動で調べました。</p>
<ul class="checks" id="checks"></ul>
<div class="footer">
<button class="link" id="recheck" onclick="setupAction(loadEnv)">もう一度確認</button>
<div class="right"><button class="primary big" id="ready-next" onclick="go('connect')">次へ</button></div>
</div>
</section>
<section class="step" id="step-connect" hidden>
<h1 id="connect-title">使うAIを選びましょう</h1>
<p class="lead" id="connect-lead">どれか1つつなげば始められます。迷ったら一番上がおすすめです。</p>
<div class="banner ok" id="okbar" hidden></div>
<div id="options"></div>
<div class="option" id="deepseek-option">
<div class="head"><h2 id="h2">DeepSeek の APIキー</h2><span class="badge ok" id="deepseek-saved" hidden>保存済み</span></div>
<p class="small" id="kh">キーをお持ちなら貼り付けてください。このPCの dsh 設定に保存します。</p>
<div class="field"><input type="password" id="key" autocomplete="off" spellcheck="false" placeholder="sk-..."><button id="save-key" onclick="setupAction(save)">保存</button></div>
<div class="row-actions"><a class="linkish" id="issue" href="https://platform.deepseek.com/api_keys" target="_blank" rel="noopener noreferrer">キーを発行する(DeepSeek)</a></div>
</div>
<button class="link" id="show-more" onclick="$('step-connect').classList.remove('is-connected')">ほかの方法でつなぐ</button>
<details id="other-keys">
<summary id="other-keys-summary">ほかの APIキー(OpenAI・Anthropic)</summary>
<div class="field"><select id="other-key-name" aria-label="key"><option value="OPENAI_API_KEY">OpenAI</option><option value="ANTHROPIC_API_KEY">Anthropic</option></select></div>
<div class="field"><input type="password" id="other-key" autocomplete="off" spellcheck="false" placeholder="API key"><button id="save-other-key" onclick="setupAction(saveOther)">保存</button></div>
<p class="hint" id="other-keys-hint" hidden></p>
</details>
<details id="detected-box" hidden>
<summary id="detected-summary">この PC で見つかったもの</summary>
<h3 class="small" id="detected-known-head">dsh がそのまま使えます</h3>
<div class="list" id="detected-known"></div>
<h3 class="small" id="detected-route-head">rdsh で登録できます</h3>
<div class="list" id="detected-routes"></div>
<p class="hint" id="detected-hint"></p>
</details>
<details id="detail-box">
<summary id="detail-summary">くわしい情報(上級者向け)</summary>
<h3 class="small" id="h0">dsh が使うモデル</h3>
<div class="list" id="defs"></div>
<h3 class="small" id="h-records">見つかったログイン</h3>
<div class="list" id="list"></div>
<h3 class="small" id="hs">コマンドで設定する場合</h3>
<div id="sharing"></div>
<p class="hint" id="sharehint"></p>
</details>
<div class="footer">
<button id="connect-back" onclick="go('ready')">戻る</button>
<div class="right"><button class="link" id="connect-skip" onclick="go('finish')">あとで設定する</button><button class="primary big" id="connect-next" onclick="go('finish')" disabled>次へ</button></div>
</div>
</section>
<section class="step" id="step-finish" hidden>
<div class="done-mark" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></div>
<h1 id="finish-title">準備ができました</h1>
<p class="lead" id="finish-lead">さっそく AI に話しかけてみましょう。</p>
<div class="starts" id="starts"></div>
<div class="option" id="webui-box">
<div class="head"><h2 id="webui-title">Web UI を常時起動</h2><span class="badge" id="webui-badge">…</span></div>
<p class="hint" id="webui-state"></p>
<div class="row-actions" id="webui-actions"></div>
<p class="hint" id="webui-url" hidden></p>
</div>
<p class="hint" id="finish-cmd-hint">うまく開かないときは、ターミナルで次のコマンドを実行してください。</p>
<div class="cmd" id="finish-cmd"><code>rdsh tui</code><button class="link" onclick="cp(this)">コピー</button></div>
<p class="hint" id="model-line"></p>
<details>
<summary id="hx">追加機能(必要なときだけ)</summary>
<div class="list" id="extras"></div>
<p class="hint" id="khx">ふだんは不要です。使いたいものだけ、押してオンにできます。</p>
</details>
<div class="footer">
<button id="finish-back" onclick="go('connect')">戻る</button>
<div class="right"><button class="primary" id="done" onclick="setupAction(bye)">セットアップを終える</button></div>
</div>
</section>
</main>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script>
var setupToken = new URLSearchParams(location.hash.slice(1)).get("key") || sessionStorage.getItem("rdsh_setup_token") || "";
if (setupToken) sessionStorage.setItem("rdsh_setup_token", setupToken);
history.replaceState(null, "", location.pathname);
var LANG = document.documentElement.dataset.rdshLocale || "en";
var JA = LANG === "ja";
var ZH = LANG === "zh-CN";
var STATUS = null;
var ENV = null;
var pendingLogin = "";
var current = "";
var timer = 0;
var wasConnected = false;
function t(ja, en, zh) { return JA ? ja : (ZH ? zh : en); }
function $(id) { return document.getElementById(id); }
function text(id, ja, en, zh) { $(id).textContent = t(ja, en, zh); }
function el(tag, cls, content) { var node = document.createElement(tag); if (cls) node.className = cls; if (content !== undefined) node.textContent = content; return node; }
text("t", "はじめましょう", "Let's get started", "开始吧");
text("s", "ターミナルに表示されたファイルの中身を貼り付けると、設定画面につながります。", "Paste the contents of the file shown in the terminal to open setup.", "粘贴终端中显示的文件内容即可打开设置。");
$("setup-token").placeholder = t("ファイルの中身", "File contents", "文件内容");
text("setup-connect", "接続", "Connect", "连接");
$("token-hint").textContent = t("次回からは rdsh setup --web を実行すると、自動でつながった状態で開きます。", "Next time, run rdsh setup --web and the page opens already connected.", "下次运行 rdsh setup --web 时,页面会自动连接。");
text("step-label-ready", "準備", "Check", "准备");
text("step-label-connect", "AIとつなぐ", "Connect AI", "连接模型");
text("step-label-finish", "完了", "Done", "完成");
text("ready-title", "まずは準備を確認します", "First, a quick check", "先检查一下环境");
text("ready-lead", "必要なものがそろっているか、自動で調べました。", "We checked what is already installed.", "已自动检查所需组件。");
text("recheck", "もう一度確認", "Check again", "重新检查");
text("ready-next", "次へ", "Next", "下一步");
text("connect-title", "使うAIを選びましょう", "Choose your AI", "选择要使用的 AI");
text("connect-lead", "どれか1つつなげば始められます。迷ったら一番上がおすすめです。", "Connect any one to start. If unsure, use the first option.", "连接任意一个即可开始。不确定时选第一个。");
text("h2", "DeepSeek の APIキー", "DeepSeek API key", "DeepSeek API 密钥");
text("deepseek-saved", "保存済み", "Saved", "已保存");
text("kh", "キーをお持ちなら貼り付けてください。このPCの dsh 設定に保存します。", "Paste your key if you have one. It is saved to dsh on this PC.", "如有密钥请粘贴,它会保存到本机的 dsh 设置。");
text("save-key", "保存", "Save", "保存");
text("issue", "キーを発行する(DeepSeek)", "Get a key (DeepSeek)", "获取密钥(DeepSeek)");
text("other-keys-summary", "ほかの APIキー(ルート別)", "Other API keys (by route)", "其他 API 密钥(按路由)");
text("save-other-key", "保存", "Save", "保存");
text("show-more", "ほかの方法でつなぐ", "Connect another way", "使用其他方式连接");
$("key").addEventListener("input", function () { $("save-key").classList.toggle("primary", !!$("key").value.trim()); });
$("key").addEventListener("keydown", function (e) { if (e.key === "Enter") setupAction(save); });
text("detail-summary", "くわしい情報(上級者向け)", "Details (advanced)", "详细信息(高级)");
text("h0", "dsh が使うモデル", "Models dsh will use", "dsh 将使用的模型");
text("h-records", "見つかったログイン", "Logins found", "找到的登录");
text("hs", "コマンドで設定する場合", "Using the command line", "使用命令行设置");
text("connect-back", "戻る", "Back", "返回");
text("connect-skip", "あとで設定する", "Set up later", "稍后设置");
text("connect-next", "次へ", "Next", "下一步");
text("finish-title", "準備ができました", "You're all set", "准备就绪");
text("finish-lead", "さっそく AI に話しかけてみましょう。", "Say hello to your AI.", "现在就和 AI 对话吧。");
text("finish-cmd-hint", "うまく開かないときは、ターミナルで次のコマンドを実行してください。", "If nothing opens, run this in a terminal.", "如果没有打开,请在终端运行以下命令。");
text("webui-title", "Web UI を常時起動", "Always start the Web UI", "常时启动 Web UI");
text("hx", "追加機能(必要なときだけ)", "Extras (only if you need them)", "附加功能(按需)");
text("khx", "ふだんは不要です。使いたいものだけ、押してオンにできます。", "Usually unnecessary. Click one to turn it on.", "通常不需要。点击即可开启。");
text("finish-back", "戻る", "Back", "返回");
text("done", "セットアップを終える", "Finish setup", "完成设置");
document.querySelectorAll(".cmd .link").forEach(function (b) { b.textContent = t("コピー", "Copy", "复制"); });
function toast(message) { var box = $("toast"); box.textContent = message; box.classList.add("show"); clearTimeout(toast.timer); toast.timer = setTimeout(function () { box.classList.remove("show"); }, 3600); }
function showSetupError(error) { toast(t("うまくいきませんでした:", "Something went wrong: ", "操作失败:") + friendlyError(error.message)); }
function friendlyError(message) {
var map = {
"login helper is not installed": t("ログイン用のツールが見つかりません", "the login tool is not installed", "未找到登录工具"),
"credential is not available to share": t("このログインは見つかりませんでした。もう一度ログインしてください", "that login was not found; log in again", "未找到该登录,请重新登录"),
"DeepSeek Harness (dsh) is not installed": t("DeepSeek Harness がまだ入っていません", "DeepSeek Harness is not installed yet", "尚未安装 DeepSeek Harness"),
"empty or oversized value": t("キーが空か、長すぎます", "the key is empty or too long", "密钥为空或过长")
};
return map[message] || message;
}
async function setupAction(action) { try { await action(); } catch (error) { showSetupError(error); } }
function cp(b) {
var value = b.parentNode.querySelector("code").textContent;
function ok() { b.textContent = t("コピーしました", "Copied", "已复制"); setTimeout(function () { b.textContent = t("コピー", "Copy", "复制"); }, 1400); }
if (navigator.clipboard) navigator.clipboard.writeText(value).then(ok, function () { prompt(t("コピー:", "Copy:", "复制:"), value); });
else prompt(t("コピー:", "Copy:", "复制:"), value);
}
function cmdRow(command) { var row = el("div", "cmd"); var code = el("code", "", command); var b = el("button", "link", t("コピー", "Copy", "复制")); b.onclick = function () { cp(b); }; row.append(code, b); return row; }
async function api(method, path, body) {
var init = {method: method, headers: {"X-RDSH-Token": setupToken}};
if (body !== undefined) { init.headers["Content-Type"] = "application/json"; init.body = JSON.stringify(body); }
var response = await fetch(path, init);
var data = await response.json().catch(function () { return {}; });
if (!response.ok) throw new Error(data.error || String(response.status));
return data;
}
function go(step) {
current = step;
["token", "ready", "connect", "finish"].forEach(function (name) { $("step-" + name).hidden = name !== step; });
$("steps").hidden = step === "token";
var order = ["ready", "connect", "finish"];
var at = order.indexOf(step);
document.querySelectorAll("#steps li").forEach(function (li) {
var i = order.indexOf(li.dataset.step);
li.className = i < at ? "done" : (i === at ? "now" : "");
if (i === at) li.setAttribute("aria-current", "step"); else li.removeAttribute("aria-current");
});
if (step === "finish") { renderFinish(); setupAction(loadExtras); setupAction(loadWebUi); }
window.scrollTo({top: 0, behavior: "smooth"});
var heading = document.querySelector("#step-" + step + " h1");
if (heading) { heading.tabIndex = -1; heading.focus({preventScroll: true}); }
}
function checkRow(ok, title, sub, extra) {
var li = el("li", "check");
var mark = el("span", "mark " + (ok ? "ok" : "todo"), ok ? "✓" : "!");
mark.setAttribute("aria-label", ok ? t("OK", "OK", "正常") : t("要対応", "Action needed", "需处理"));
var p = el("p"); p.append(el("strong", "", title));
li.append(mark, p, el("p", "small", sub));
if (extra) { var box = el("div", "extra"); box.append(extra); li.append(box); }
return li;
}
async function loadEnv() {
ENV = await api("GET", "/api/env");
var list = $("checks"); list.replaceChildren();
list.append(checkRow(true, "rdsh", t("インストール済み(v", "Installed (v", "已安装(v") + ENV.rdsh + t(")", ")", ")")));
var nodeOk = ENV.node.found && ENV.node.supported;
var nodeLink = el("a", "linkish", t("Node.js をダウンロード(公式サイト)", "Download Node.js (official site)", "下载 Node.js(官网)"));
nodeLink.href = JA ? "https://nodejs.org/ja/download" : "https://nodejs.org/en/download"; nodeLink.target = "_blank"; nodeLink.rel = "noopener noreferrer";
list.append(checkRow(nodeOk, "Node.js 22+",
nodeOk ? t("見つかりました(", "Found (", "已找到(") + ENV.node.version + t(")", ")", ")")
: (ENV.node.found ? t("バージョンが古いです(", "Too old (", "版本过旧(") + (ENV.node.version || "?") + t(")。新しい版を入れてください。", "). Please update.", ")。请更新。")
: t("DeepSeek Harness を動かすのに必要です。", "Needed to run DeepSeek Harness.", "运行 DeepSeek Harness 需要它。")),
nodeOk ? null : nodeLink));
list.append(checkRow(ENV.dsh.found, "DeepSeek Harness",
ENV.dsh.found ? t("見つかりました", "Found", "已找到") : t("AIとの会話に使います。下のコマンドで入れられます。", "Used for conversations. Install it with this command.", "用于与 AI 对话。可用下面的命令安装。"),
ENV.dsh.found ? null : cmdRow("npm install -g @deepseek-ai/dsh")));
var allOk = nodeOk && ENV.dsh.found;
$("ready-next").textContent = allOk ? t("次へ", "Next", "下一步") : t("このまま進む", "Continue anyway", "继续");
if (!allOk) $("ready-lead").textContent = t("足りないものがあります。あとから入れても大丈夫です。", "Something is missing. You can install it later.", "缺少一些组件,之后再安装也可以。");
else text("ready-lead", "必要なものはそろっています。", "Everything you need is installed.", "所需组件已就绪。");
if (STATUS) renderOptions();
}
function statusLabel(value) {
var labels = {ok: ["接続済み", "Connected", "已连接"], import: ["取り込み可能", "Importable", "可导入"], importable: ["取り込み可能", "Importable", "可导入"], refresh: ["更新あり", "Refresh", "可刷新"], missing: ["未設定", "Not configured", "未配置"], invalid: ["無効", "Invalid", "无效"], unavailable: ["未取得", "Unavailable", "未获取"], not_selected: ["未選択", "Not selected", "未选择"]};
return labels[value] ? t.apply(null, labels[value]) : value;
}
var PROVIDER_NAMES = {"openai-codex": "ChatGPT (OpenAI)", anthropic: "Claude (Anthropic)", "github-copilot": "GitHub Copilot", google: "Gemini (Google)"};
function providerName(id) { return PROVIDER_NAMES[id] || id; }
// 検出結果はどれも読み取りだけの情報。ここでは並べるだけで、書き込みは
// ターミナルの rdsh provider import に任せる(この画面からの経路追加は
// cordis.patch.yml を触るため、範囲を広げない)。
var KEY_STATE_LABELS = {"env": "環境変数", "elsewhere": "他ツール", "not-needed": "キー不要", "missing": "キー未検出"};
function renderDetected() {
var box = $("detected-box");
if (!box || !STATUS) return;
var detected = STATUS.detected || {};
var known = detected.dshKnown || [];
var routes = detected.routes || [];
if (!known.length && !routes.length) { box.hidden = true; return; }
box.hidden = false;
var total = known.length + routes.length;
$("detected-summary").textContent = t("この PC で見つかったもの(" + total + " 件)", "Found on this PC (" + total + ")", "本机检测到(" + total + " 项)");
var knownList = $("detected-known");
knownList.replaceChildren();
known.forEach(function (item) {
var row = document.createElement("div");
row.className = "row";
var name = document.createElement("span");
name.textContent = providerName(item.provider);
var badge = document.createElement("span");
badge.className = "badge ok";
badge.textContent = t("登録不要", "no route needed", "无需注册");
var where = document.createElement("small");
where.textContent = item.evidence;
row.appendChild(name);
row.appendChild(badge);
row.appendChild(where);
knownList.appendChild(row);
});
$("detected-known-head").hidden = !known.length;
var routeList = $("detected-routes");
routeList.replaceChildren();
routes.forEach(function (item) {
var row = document.createElement("div");
row.className = "row";
var name = document.createElement("span");
name.textContent = item.route;
var badge = document.createElement("span");
badge.className = item.registered || item.writable ? "badge ok" : "badge";
badge.textContent = item.registered
? t("登録済み", "registered", "已注册")
: KEY_STATE_LABELS[item.key] || item.key;
var detail = document.createElement("small");
var parts = [];
if (item.baseURL) parts.push(item.baseURL);
if (item.models && item.models.length) parts.push(item.models.length + " models");
if (item.note) parts.push(item.note);
detail.textContent = parts.join(" ・ ");
row.appendChild(name);
row.appendChild(badge);
row.appendChild(detail);
routeList.appendChild(row);
});
$("detected-route-head").hidden = !routes.length;
var writable = routes.filter(function (item) { return item.writable && !item.registered; }).length;
$("detected-hint").textContent = writable
? t("書き込める経路は " + writable + " 件です。ターミナルで rdsh provider import を実行すると登録できます。",
writable + " route(s) can be written; run rdsh provider import in a terminal.",
"可写入的路由有 " + writable + " 条。请在终端运行 rdsh provider import。")
: t("いま書き込める経路はありません。キーを保存すると登録できます。",
"No route can be written yet. Store a key first.",
"当前没有可写入的路由。请先保存密钥。");
}
async function load() {
var response = await fetch("/api/status", {headers: {"X-RDSH-Token": setupToken}});
if (!response.ok) { go("token"); return; }
STATUS = await response.json();
renderOptions();
renderDetails();
renderKeyNames();
renderDetected();
var connected = !STATUS.needed;
$("okbar").hidden = !connected;
$("okbar").textContent = "✓ " + t("AIとつながりました。「次へ」で最初の会話へ進めます。", "Connected. Press Next to start your first conversation.", "已连接。点击“下一步”开始第一次对话。");
if (connected && !wasConnected) $("step-connect").classList.add("is-connected");
if (!connected) $("step-connect").classList.remove("is-connected");
wasConnected = connected;
$("connect-next").disabled = !connected;
$("connect-skip").hidden = connected;
$("deepseek-saved").hidden = (STATUS.refs_stored || []).indexOf("DEEPSEEK_API_KEY") < 0;
$("deepseek-option").classList.toggle("connected", !$("deepseek-saved").hidden);
}
// The "other API keys" select offers the built-in names plus every route key
// the profiles declare (apiKeyEnv), so a Command Code or HAI key can be stored
// from this page without hand-editing the credentials file.
var BASE_KEY_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"];
var KEY_LABELS = {"OPENAI_API_KEY": "OpenAI", "ANTHROPIC_API_KEY": "Anthropic"};
function renderKeyNames() {
if (!STATUS) return;
var names = BASE_KEY_NAMES.slice();
function add(name) { if (name && names.indexOf(name) < 0) names.push(name); }
(STATUS.declared_key_envs || []).forEach(add);
(STATUS.defaults || []).forEach(function (x) { add(x.api_key_env); });
var sel = $("other-key-name");
var previous = sel.value;
sel.replaceChildren();
names.forEach(function (name) {
var option = document.createElement("option");
option.value = name; option.textContent = KEY_LABELS[name] || name;
sel.appendChild(option);
});
if (names.indexOf(previous) >= 0) sel.value = previous;
var missing = [];
(STATUS.defaults || []).forEach(function (x) {
if (x.state !== "missing" || !x.api_key_env || missing.indexOf(x.api_key_env) >= 0) return;
missing.push(x.api_key_env);
});
var hint = $("other-keys-hint");
hint.textContent = missing.length ? t("未設定のキー: ", "Keys still missing: ", "仍未配置的密钥:") + missing.join(", ") : "";
hint.hidden = !missing.length;
}
function oauthItems(from) {
var seen = {};
return ((STATUS.sharing && STATUS.sharing.inventory) || []).filter(function (item) {
if (item.kind !== "oauth" || item.from !== from || seen[item.selector]) return false;
seen[item.selector] = true; return true;
});
}
function waitLine(message) { return el("span", "wait", message); }
function shareButton(item, label, secondary) {
var b = el("button", secondary ? "" : "primary", label);
b.onclick = function () { setupAction(async function () { b.disabled = true; await api("POST", "/api/share", {selector: item.selector}); toast(t("つなぎました", "Connected", "已连接")); await load(); }); };
return b;
}
function loginButton(tool, label, secondary) {
var b = el("button", secondary ? "" : "primary", label);
b.onclick = function () { setupAction(async function () { await api("POST", "/api/login", {tool: tool}); pendingLogin = tool; renderOptions(); }); };
return b;
}
function optionCard(title, sub, badge) {
var box = el("div", "option"); var head = el("div", "head"); head.append(el("h2", "", title));
if (badge) head.append(el("span", "badge", badge));
box.append(head, el("p", "small", sub));
var actions = el("div", "row-actions"); box.append(actions);
return {box: box, head: head, actions: actions};
}
function markConnected(card) { card.box.classList.add("connected"); card.head.append(el("span", "badge ok", "✓ " + t("つながっています", "Connected", "已连接"))); }
function renderOptions() {
var root = $("options"); root.replaceChildren();
if (!STATUS || STATUS.sharing && STATUS.sharing.policy_error) return;
var env = ENV || {};
var codex = oauthItems("codex").filter(function (i) { return i.provider === "openai-codex"; })[0];
var gpt = optionCard(t("ChatGPT のアカウント", "Your ChatGPT account", "ChatGPT 账号"), t("ChatGPT Plus / Pro をお使いなら、APIキーなしで使えます。", "With ChatGPT Plus or Pro, no API key is needed.", "如有 ChatGPT Plus / Pro,无需 API 密钥。"), t("おすすめ", "Recommended", "推荐"));
if (codex && codex.selected && codex.action === "ok") markConnected(gpt);
else if (codex) {
if (pendingLogin === "codex") { pendingLogin = ""; setupAction(async function () { await api("POST", "/api/share", {selector: codex.selector}); toast(t("ログインを確認してつなぎました", "Login found and connected", "已确认登录并连接")); await load(); }); }
gpt.actions.append(shareButton(codex, t("このアカウントを使う", "Use this account", "使用此账号")));
gpt.actions.append(el("span", "small", t("Codex でのログインが見つかりました", "Found your Codex login", "找到 Codex 登录")));
} else if (pendingLogin === "codex") {
gpt.actions.append(waitLine(t("開いた画面でログインを完了してください。終わると自動でつながります…", "Finish logging in in the window that opened. This page updates automatically…", "请在打开的窗口中完成登录,完成后会自动连接…")));
} else if (env.codex) {
gpt.actions.append(loginButton("codex", t("ChatGPT でログイン", "Log in with ChatGPT", "使用 ChatGPT 登录")));
} else {
gpt.box.append(el("p", "small", t("ログインには Codex(OpenAI 公式ツール)を使います。先に入れてください:", "Logging in uses Codex, OpenAI's tool. Install it first:", "登录需要 OpenAI 的 Codex 工具,请先安装:")));
gpt.box.append(cmdRow("npm install -g @openai/codex"));
}
root.append(gpt.box);
var opencode = oauthItems("opencode");
if (opencode.length || env.opencode) {
var oc = optionCard(t("OpenCode のログイン", "Your OpenCode login", "OpenCode 登录"), t("OpenCode でログイン済みのアカウントを、そのまま使えます。", "Reuse an account you already logged in to with OpenCode.", "直接使用已在 OpenCode 中登录的账号。"));
var anyOn = false;
opencode.forEach(function (item) {
if (item.selected && item.action === "ok") { anyOn = true; oc.actions.append(el("span", "badge ok", "✓ " + providerName(item.provider))); }
else oc.actions.append(shareButton(item, providerName(item.provider) + t(" を使う", " — use", " — 使用"), true));
});
if (anyOn) oc.box.classList.add("connected");
if (pendingLogin === "opencode" && !opencode.length) oc.actions.append(waitLine(t("開いたウィンドウでログインを完了してください…", "Finish logging in in the new window…", "请在新窗口中完成登录…")));
else if (!opencode.length && env.opencode) oc.actions.append(loginButton("opencode", t("OpenCode でログイン", "Log in with OpenCode", "使用 OpenCode 登录"), true));
if (pendingLogin === "opencode" && opencode.length) pendingLogin = "";
root.append(oc.box);
}
}
function renderDetails() {
var el1 = $("list"); el1.replaceChildren();
(STATUS.records || []).forEach(function (x) { el1.append(listRow(x.action === "ok", providerName(x.provider), x.from + " / " + statusLabel(x.action))); });
if (!el1.children.length) el1.append(el("p", "hint", t("見つかりませんでした", "None found", "未找到")));
var df = $("defs"); df.replaceChildren();
(STATUS.defaults || []).forEach(function (x) { df.append(listRow(x.state === "ok", x.profile + " · " + x.provider + "/" + x.model, x.via + " / " + statusLabel(x.state))); });
if (!df.children.length) df.append(el("p", "hint", t("プロファイルが見つかりません(dsh を一度起動すると作られます)", "No profiles yet (created on first dsh launch)", "尚无配置文件(首次启动 dsh 时创建)")));
showSharing(STATUS.sharing || {});
}
function listRow(good, name, note) {
var d = el("div", "row"); d.append(el("span", "dot " + (good ? "on" : "off")), el("span", "", name), el("small", "", note)); return d;
}
function sharingPolicyError(value) {
var messages = {
"credential sharing policy is unreadable; imports are disabled": ["認証情報の共有ポリシーを読み取れません。インポートは無効です", "credential sharing policy is unreadable; imports are disabled", "无法读取凭据共享策略;已禁用导入"],
"credential sharing policy is oversized; imports are disabled": ["認証情報の共有ポリシーが大きすぎます。インポートは無効です", "credential sharing policy is oversized; imports are disabled", "凭据共享策略过大;已禁用导入"],
"invalid credential sharing policy; imports are disabled": ["認証情報の共有ポリシーが無効です。インポートは無効です", "invalid credential sharing policy; imports are disabled", "凭据共享策略无效;已禁用导入"],
"unsupported credential sharing policy; imports are disabled": ["認証情報の共有ポリシーは未対応です。インポートは無効です", "unsupported credential sharing policy; imports are disabled", "凭据共享策略不受支持;已禁用导入"]
};
return Object.hasOwn(messages, value) ? t.apply(null, messages[value]) : value;
}
function showSharing(sharing) {
var list = document.getElementById("sharing"); list.replaceChildren();
var hint = document.getElementById("sharehint");
hint.textContent = t("保存先: ", "Saved to: ", "保存位置:") + (sharing.persistent_destination || "") + t("。同じ DSH_HOME の全プロファイルで使えます。選択を外すと今後の取り込みは止まり、保存済みのコピーは残ります。発行元での失効は別に必要です。", ". Copies are available to every profile in this DSH_HOME. Unselect stops future imports and retains existing copies. Provider revocation is separate.", "。此 DSH_HOME 中所有配置文件都可使用。取消选择会停止将来导入并保留现有副本。在提供方撤销需要单独进行。");
if (sharing.policy_error) {
hint.textContent += " " + sharingPolicyError(sharing.policy_error);
document.getElementById("detail-box").open = true;
return;
}
var seen = new Set();
(sharing.inventory || []).forEach(function (item) {
if (seen.has(item.selector)) return;
seen.add(item.selector);
list.append(cmdRow("rdsh auth --" + (item.selected ? "unselect " : "select ") + item.selector + (item.selected ? "" : " --import")));
});
(sharing.environment_references || []).forEach(function (item) {
list.append(el("p", "hint", item.name + t(": 環境変数として利用中(ファイル保存なし)", ": used from the environment (not saved to a file)", ":从环境变量使用(未写入文件)")));
});
}
function startButton(kind, title, sub, primary) {
var b = el("button", "start" + (primary ? " primary" : ""));
var label = el("span"); label.append(el("strong", "", title), el("span", "small", sub)); b.append(label);
b.onclick = function () { setupAction(async function () { await api("POST", "/api/start", {kind: kind}); toast(t("開いています…", "Opening…", "正在打开…")); }); };
return b;
}
function renderFinish() {
var env = ENV || {dsh: {}};
var connected = STATUS && !STATUS.needed;
var missing = [];
if (!connected) missing.push("ai");
if (!env.dsh.found) missing.push("dsh");
text("finish-title", missing.length ? "あと少しです" : "準備ができました", missing.length ? "Almost there" : "You're all set", missing.length ? "还差一点" : "准备就绪");
document.querySelector(".done-mark").hidden = !!missing.length;
text("finish-lead", missing.length ? missing.length > 1 ? "あと2つで使えるようになります。" : "あと1つで使えるようになります。" : "さっそく AI に話しかけてみましょう。", missing.length ? missing.length > 1 ? "Two more steps and you're ready." : "One more step and you're ready." : "Say hello to your AI.", missing.length ? missing.length > 1 ? "再完成两步即可使用。" : "再完成一步即可使用。" : "现在就和 AI 对话吧。");
$("done").classList.toggle("primary", !missing.length);
var starts = $("starts"); starts.replaceChildren();
$("finish-cmd-hint").hidden = !env.dsh.found;
$("finish-cmd").hidden = !env.dsh.found;
if (!connected) {
var link = el("button", "primary", t("AIをつなぐ", "Connect an AI", "连接 AI"));
link.onclick = function () { go("connect"); };
starts.append(link);
}
if (!env.dsh.found) {
var box = el("div", "option");
box.append(el("h2", "", t("DeepSeek Harness を入れましょう", "Install DeepSeek Harness", "安装 DeepSeek Harness")));
box.append(el("p", "small", t("AIとの会話に使います。ターミナルで次のコマンドを実行してください。", "It runs your conversations. Run this in a terminal.", "它用于与 AI 对话。请在终端运行以下命令。")));
box.append(cmdRow("npm install -g @deepseek-ai/dsh"));
var again = el("button", connected ? "primary" : "", t("入れたので確認する", "I've installed it", "已安装,重新检查"));
again.onclick = function () { setupAction(async function () { await loadEnv(); renderFinish(); if (!ENV.dsh.found) toast(t("まだ見つかりません。ターミナルを開き直すと見つかることがあります。", "Not found yet. Reopening the terminal can help.", "仍未找到。重新打开终端可能会有帮助。")); }); };
var actions = el("div", "row-actions"); actions.append(again); box.append(actions);
starts.append(box);
}
if (env.desktop) starts.append(startButton("desktop", t("DeepSeek Harness アプリを開く", "Open the DeepSeek Harness app", "打开 DeepSeek Harness 应用"), t("いちばん簡単です", "The easiest way", "最简单的方式"), true));
if (env.dsh.found) {
starts.append(startButton("tui", t("ターミナルで会話をはじめる", "Chat in a terminal", "在终端中对话"), t("Codex や Claude Code と同じ使い方です", "Works like Codex or Claude Code", "用法与 Codex、Claude Code 相同"), !env.desktop));
starts.append(startButton("web", t("ブラウザで使う", "Use it in your browser", "在浏览器中使用"), t("画面で操作したい方に", "If you prefer a visual interface", "适合喜欢图形界面的用户"), false));
}
var model = (STATUS && STATUS.defaults || []).filter(function (d) { return d.state === "ok"; })[0];
$("model-line").textContent = model ? t("使うモデル:", "Model: ", "使用模型:") + model.provider + " / " + model.model : "";
}
var EXTRAS = [{id: "serve", ja: "ダッシュボード(rdsh serve)", en: "dashboard (rdsh serve)", zh: "仪表板(rdsh serve)"}, {id: "search-web", ja: "Web検索(rdsh search-web)", en: "web search (rdsh search-web)", zh: "网页搜索(rdsh search-web)"}];
async function loadExtras() {
var r = await api("GET", "/api/extras");
var on = (r && r.enable) || [];
var box = $("extras"); box.replaceChildren();
EXTRAS.forEach(function (x) {
var good = on.indexOf(x.id) >= 0;
var d = listRow(good, t(x.ja, x.en, x.zh), good ? t("有効", "on", "已启用") : t("無効", "off", "已禁用"));
d.setAttribute("role", "button"); d.tabIndex = 0;
d.onclick = function () { setupAction(function () { return toggleExtra(x.id, !good); }); };
d.onkeydown = function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); d.onclick(); } };
box.append(d);
});
}
async function toggleExtra(id, want) {
var r = await api("GET", "/api/extras");
var on = (r && r.enable) || [];
if (want && on.indexOf(id) < 0) on.push(id);
if (!want) on = on.filter(function (y) { return y !== id; });
await api("POST", "/api/extras", {enable: on});
await loadExtras();
}
async function loadWebUi() {
var r = await api("GET", "/api/webui");
var on = !!r.enabled;
var badge = $("webui-badge");
badge.textContent = on ? t("オン", "on", "已开启") : t("オフ", "off", "已关闭");
badge.className = "badge" + (on ? " ok" : "");
var state = $("webui-state");
state.textContent = on
? (r.active ? t("このPCのログイン時に起動しています。", "Starts when you log in to this PC.", "登录此 PC 时自动启动。")
: t("有効ですが、まだ動作していません。", "Enabled, but not running yet.", "已启用,但尚未运行。"))
: t("既定ではオフです。オンにすると、ログイン時に Web UI が起動します。", "Off by default. Turn it on to start the Web UI at login.", "默认关闭。开启后将在登录时启动 Web UI。");
if (r.hint) state.textContent += " " + r.hint;
var actions = $("webui-actions"); actions.replaceChildren();
var onb = el("button", on ? "" : "primary", t("オンにする", "Turn on", "开启"));
onb.disabled = on;
onb.onclick = function () { setupAction(function () { return setWebUi(true); }); };
var offb = el("button", on ? "primary" : "", t("オフにする", "Turn off", "关闭"));
offb.disabled = !on;
offb.onclick = function () { setupAction(function () { return setWebUi(false); }); };
actions.append(onb, offb);
var url = $("webui-url");
url.replaceChildren();
url.hidden = !(r.active && r.url);
if (r.active && r.url) {
url.append(document.createTextNode(t("開く: ", "Open: ", "打开:")));
var a = el("a", "linkish", r.url);
a.href = r.url; a.target = "_blank"; a.rel = "noreferrer";
url.append(a);
}
}
async function setWebUi(enable) {
await api("POST", "/api/webui", {enable: enable});
toast(enable ? t("オンにしました", "Turned on", "已开启") : t("オフにしました", "Turned off", "已关闭"));
await loadWebUi();
}
async function storeKey(name, input) {
var r = await api("POST", "/api/key", {name: name, value: input.value});
if (!r.stored) throw new Error(r.error || "?");
input.value = "";
toast(t("保存しました", "Saved", "已保存"));
await load();
}
async function save() { await storeKey("DEEPSEEK_API_KEY", $("key")); }
async function saveOther() { await storeKey($("other-key-name").value, $("other-key")); }
async function bye() {
var r = await fetch("/api/done", {method: "POST", headers: {"X-RDSH-Token": setupToken}, body: "{}"});
if (!r.ok) return;
clearInterval(timer);
document.querySelector(".card").innerHTML = "";
var p = el("p", "lead", t("セットアップを終えました。このタブは閉じて大丈夫です。", "Setup finished. You can close this tab.", "设置已完成,可以关闭此标签页。"));
p.style.textAlign = "center"; p.style.margin = "24px 0";
document.querySelector(".card").append(p);
$("steps").hidden = true;
}
function startRefresh() { clearInterval(timer); timer = setInterval(function () { if (current === "connect" || current === "finish") setupAction(load); }, 3000); }
async function connectSetup(candidateInput) {
var candidate = typeof candidateInput === "string" ? candidateInput : $("setup-token").value.trim();
var response = await fetch("/api/status", {headers: {"X-RDSH-Token": candidate}}).catch(function () { return {ok: false}; });
if (!response.ok) {
go("token");
if (typeof candidateInput !== "string") toast(t("確認できませんでした。ファイルの中身をそのまま貼り付けてください。", "That didn't match. Paste the file contents exactly.", "无法确认,请原样粘贴文件内容。"));
return;
}
setupToken = candidate;
sessionStorage.setItem("rdsh_setup_token", setupToken);
$("setup-token").value = "";
await Promise.all([setupAction(loadEnv), setupAction(load)]);
go(STATUS && !STATUS.needed ? "finish" : "ready");
startRefresh();
}
$("setup-token").addEventListener("keydown", function (e) { if (e.key === "Enter") connectSetup(); });
if (setupToken) connectSetup(setupToken); else go("token");
</script>
</body>
</html>