left0ver's picture
Publish com.left0ver.agentcade 0.2.0
21e8d36 verified
Raw History Blame Contribute Delete
24.3 kB
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>设置 · Agentcade</title>
<link rel="icon" href="assets/logo.svg">
<style>
:root {
color-scheme: light dark;
--bg: #ffffff; --panel: #f7f7f8; --border: #e4e4e7; --text: #1c1c1f; --muted: #777783;
--hover: #f1f1f3; --accent: #2f62e8; --error: #c9302c; --ok: #19874a; --game: #0c8585;
--warn: #b26a00;
--shadow: 0 6px 24px rgb(15 23 42 / .08);
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #141416; --panel: #1a1a1d; --border: #2a2a2f; --text: #e6e6ea; --muted: #8b8b97;
--hover: #202024; --accent: #7ea4ff; --error: #ff6f66; --ok: #4dc480; --game: #4fcfcf;
--warn: #f0b04f;
--shadow: 0 6px 24px rgb(0 0 0 / .4);
}
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; }
body {
background: var(--bg); color: var(--text);
font: 14px/22px -apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", sans-serif;
}
button, input { font: inherit; color: inherit; }
.top {
position: sticky; top: 0; z-index: 2; display: flex; align-items: center; gap: 16px; height: 52px;
padding: 0 18px; border-bottom: 1px solid var(--border); background: var(--bg);
}
.brand { display: flex; align-items: center; gap: 9px; font-weight: 650; color: inherit; text-decoration: none; }
.logo { width: 26px; height: 26px; }
.crumb { color: var(--muted); }
.spacer { flex: 1; }
.button {
display: inline-flex; align-items: center; height: 28px; padding: 0 11px; border: 1px solid var(--border);
border-radius: 7px; background: var(--bg); color: inherit; font-size: 13px; text-decoration: none; cursor: pointer;
}
.button:hover { background: var(--hover); }
.page { max-width: 720px; margin: 0 auto; padding: 32px 24px 132px; }
h1 { margin: 0 0 8px; font-size: 22px; line-height: 30px; font-weight: 650; }
.where { margin: 16px 0 0; font: 12px/18px var(--mono); color: var(--muted); overflow-wrap: anywhere; }
.banner {
margin-top: 20px; padding: 12px 16px; border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border));
border-radius: 12px; background: color-mix(in srgb, var(--accent) 7%, transparent);
}
.card { margin-top: 20px; border: 1px solid var(--border); border-radius: 14px; overflow: hidden; }
.card-head {
display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border);
background: var(--panel);
}
.card-head h2 { margin: 0; font-size: 15px; line-height: 22px; font-weight: 600; }
.kind {
padding: 0 7px; border-radius: 9px; font-size: 11px; line-height: 18px; color: var(--game);
background: color-mix(in srgb, var(--game) 13%, transparent);
}
.kind.ok { color: var(--ok); background: color-mix(in srgb, var(--ok) 13%, transparent); }
.kind.warn { color: var(--warn); background: color-mix(in srgb, var(--warn) 15%, transparent); }
.kind.bad { color: var(--error); background: color-mix(in srgb, var(--error) 13%, transparent); }
.state { margin-left: auto; font-size: 12px; line-height: 18px; color: var(--ok); text-align: right; }
.state.problem { color: var(--error); }
.card-body { padding: 6px 18px 16px; }
.field { display: block; margin-top: 12px; }
.label { display: flex; align-items: baseline; gap: 10px; }
.label span { font-weight: 500; }
.label code { margin-left: auto; font: 11px/18px var(--mono); color: var(--muted); white-space: nowrap; }
.field input {
width: 100%; height: 36px; margin-top: 5px; padding: 0 11px; border: 1px solid var(--border);
border-radius: 9px; background: var(--bg); outline: none;
}
.field input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); }
.field input::placeholder { color: var(--muted); }
details { margin-top: 16px; }
summary { color: var(--muted); font-size: 13px; cursor: pointer; }
.bar {
position: fixed; left: 0; right: 0; bottom: 0; display: flex; justify-content: center; padding: 14px 16px 18px;
border-top: 1px solid var(--border); background: var(--bg);
}
.bar-inner { display: flex; align-items: center; gap: 14px; width: 100%; max-width: 672px; }
.message { flex: 1; min-width: 0; color: var(--muted); }
.message.error { color: var(--error); }
.message.done { color: var(--ok); }
.message a { color: var(--accent); }
.save {
height: 38px; padding: 0 22px; border: 0; border-radius: 10px; background: var(--accent); color: #fff;
font-weight: 600; cursor: pointer;
}
.save:disabled { opacity: .55; cursor: default; }
@media (prefers-color-scheme: dark) { .save { color: #0b1020; } }
h2.section { margin: 36px 0 0; font-size: 17px; line-height: 26px; font-weight: 650; }
.hint { margin: 4px 0 0; color: var(--muted); font-size: 13px; }
.state.muted { color: var(--muted); }
.games { padding: 4px 18px 16px; }
.game { display: flex; gap: 12px; align-items: flex-start; padding: 12px 0; border-bottom: 1px solid var(--border); }
.game:last-of-type { border-bottom: 0; }
.game-main { flex: 1; min-width: 0; }
.game-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.game-title b { font-weight: 600; }
.game-text { color: var(--muted); font-size: 13px; line-height: 20px; overflow-wrap: anywhere; }
.game-text.error { color: var(--error); }
.game-text.warn { color: var(--warn); }
.game-actions { display: flex; gap: 6px; flex-shrink: 0; }
.button:disabled { opacity: .5; cursor: default; }
.button.danger { color: var(--error); }
.button.primary { border-color: var(--accent); background: var(--accent); color: #fff; }
@media (prefers-color-scheme: dark) { .button.primary { color: #0b1020; } }
.add { display: flex; gap: 8px; margin-top: 14px; }
.add input {
flex: 1; min-width: 0; height: 32px; padding: 0 11px; border: 1px solid var(--border); border-radius: 8px;
background: var(--bg); outline: none;
}
.add input:focus { border-color: var(--accent); }
.add .button { height: 32px; }
.preview { margin-top: 12px; padding: 14px 16px; border: 1px solid var(--border); border-radius: 12px; background: var(--panel); }
.preview h3 { margin: 0; font-size: 15px; line-height: 22px; }
.preview ul { margin: 8px 0 0; padding: 0; list-style: none; }
.preview li { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
.preview .actions { display: flex; gap: 8px; margin-top: 14px; }
.notice { margin-top: 10px; padding: 8px 12px; border-radius: 9px; font-size: 13px; line-height: 20px; }
.notice.ok { color: var(--ok); background: color-mix(in srgb, var(--ok) 9%, transparent); }
.notice.warn { color: var(--warn); background: color-mix(in srgb, var(--warn) 11%, transparent); }
.notice.bad { color: var(--error); background: color-mix(in srgb, var(--error) 9%, transparent); }
.discover { margin-top: 6px; padding-top: 12px; border-top: 1px solid var(--border); }
.discover-head { display: flex; align-items: center; gap: 10px; }
.discover-head b { font-weight: 600; }
.discover-head .game-text { flex: 1; min-width: 0; }
.games-message { margin-top: 10px; font-size: 13px; color: var(--muted); }
.games-message.error { color: var(--error); }
@media (max-width: 640px) {
.page { padding: 20px 14px 132px; } .label code { display: none; }
.game { flex-direction: column; } .add { flex-direction: column; }
}
</style>
</head>
<body>
<header class="top">
<a class="brand" href="./">
<img class="logo" src="assets/logo.svg" alt="">
<b>Agentcade</b>
</a>
<span class="crumb">设置</span>
<div class="spacer"></div>
<a class="button" href="./">返回对话</a>
</header>
<div class="page">
<h1>设置</h1>
<div class="banner" id="banner" hidden>填好并保存后,小懒会自动启动,不用重新打开应用。</div>
<h2 class="section">游戏</h2>
<p class="hint">改动马上生效,不用保存,也不用重启。远程游戏需要你确认它能让机器人做什么。</p>
<section class="card">
<div class="games">
<div id="games"></div>
<div class="discover">
<div class="discover-head">
<b>发现更多游戏</b>
<span class="game-text" id="catalog-state"></span>
<button class="button" id="catalog-refresh" type="button">刷新</button>
</div>
<div id="catalog"></div>
</div>
<div class="add">
<input id="game-url" type="url" placeholder="也可以粘贴没有收录的游戏地址,例如 http://1.2.3.4:7860" spellcheck="false" autocomplete="off">
<button class="button" id="game-preview" type="button">查看</button>
</div>
<div id="game-confirm"></div>
<div class="games-message" id="games-message"></div>
</div>
</section>
<h2 class="section">服务配置</h2>
<div id="cards"></div>
</div>
<div class="bar">
<div class="bar-inner">
<div class="message" id="message"></div>
<button class="save" id="save" type="button">保存</button>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const forms = new Map();
let waiting = false;
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function fieldNode(field, inputs) {
const node = el("label", "field");
const label = el("div", "label");
label.append(el("span", "", field.label), el("code", "", field.key));
const input = el("input");
input.type = field.secret ? "password" : "text";
input.autocomplete = "off";
input.spellcheck = false;
input.value = field.value;
// 密钥不回显:已经保存过的留空就继续用原来的。
input.placeholder = field.secret ? (field.saved ? "已保存,留空则不修改" : "请填写") : "";
inputs.set(field.key, input);
node.append(label, input);
return node;
}
function render(data) {
waiting = data.waiting;
$("banner").hidden = !data.waiting;
forms.clear();
const cards = data.sections.map((section, index) => {
const card = el("section", "card");
const head = el("div", "card-head");
head.append(el("h2", "", section.title));
if (index) head.append(el("span", "kind", "游戏"));
if (section.disabled) head.append(el("span", "state muted", "游戏已停用,不影响启动"));
else head.append(el("span", section.problem ? "state problem" : "state", section.problem || "已配置"));
const body = el("div", "card-body");
card.append(head, body);
const inputs = new Map();
forms.set(section.id, inputs);
const advanced = el("details");
advanced.append(el("summary", "", "更多设置"));
for (const field of section.fields) (field.advanced ? advanced : body).append(fieldNode(field, inputs));
if (advanced.childElementCount > 1) body.append(advanced);
body.append(el("p", "where", `保存到:${section.file}`));
return card;
});
$("cards").replaceChildren(...cards);
}
function say(text, kind) {
$("message").className = `message ${kind || ""}`;
$("message").textContent = text;
}
async function load() {
const response = await fetch("settings/data", { cache: "no-store" });
const data = await response.json();
render(data);
return data;
}
async function watch() {
// 应用在等配置:保存之后它会自己继续启动,这里等到它启动完再提示。
for (let attempt = 0; attempt < 30; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 1000));
const data = await (await fetch("settings/data", { cache: "no-store" })).json();
if (!data.waiting) {
$("banner").hidden = true;
$("message").className = "message done";
$("message").replaceChildren("已保存,小懒已经启动。", Object.assign(el("a", "", "去对话页面"), { href: "./" }));
return;
}
}
}
$("save").addEventListener("click", async () => {
const body = {};
for (const [id, inputs] of forms) {
body[id] = Object.fromEntries([...inputs].map(([key, input]) => [key, input.value]));
}
$("save").disabled = true;
say("正在保存…");
try {
const wasWaiting = waiting;
const response = await fetch("settings/data", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
say(data.error || "保存失败", "error");
return;
}
render(data);
const unfinished = data.sections.filter((section) => section.problem).map((section) => section.title);
if (unfinished.length) say(`已保存,但还有没填好的:${unfinished.join("、")}`, "error");
else if (wasWaiting) {
say("已保存,小懒正在启动…", "done");
watch();
} else say("已保存,重新启动应用后生效。", "done");
} catch {
say("没有连上应用,请确认应用还在运行。", "error");
} finally {
$("save").disabled = false;
}
});
// 游戏:列表、安装、停用、启用、卸载,每个操作马上生效。
const STATES = { connected: "已连接", disabled: "已停用", waiting: "配置填好后连接" };
// 远程游戏在游戏目录里的审核状态。
const REVIEWS = {
reviewed: ["ok", "已审核"], changed: ["warn", "当前版本未经审核"],
unreviewed: ["warn", "未审核"], removed: ["bad", "已下架"],
};
function reviewBadge(review) {
const [kind, text] = REVIEWS[review.state];
return el("span", `kind ${kind}`, text);
}
// 当前版本和审核时不一样在哪里。
function reviewChanges(review, version) {
const parts = [];
if (version && version !== review.version) parts.push(`现在是 ${version} 版,审核过的是 ${review.version} 版`);
if (review.extra.length) parts.push(`审核时没有申请:${review.extra.map((item) => item.label).join(";")}`);
return parts.join(";");
}
function gamesSay(text, kind) {
$("games-message").className = `games-message ${kind || ""}`;
$("games-message").textContent = text || "";
}
async function gameAction(body, done) {
gamesSay("正在处理…");
try {
const response = await fetch("settings/games", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
gamesSay(data.error || "操作失败", "error");
return null;
}
gamesSay(done || "");
if (data.games) renderGames(data);
// 装上或卸载之后,“发现更多游戏”里的“已安装”跟着变。
if (["install", "uninstall"].includes(body.action)) loadCatalog();
return data;
} catch {
gamesSay("没有连上应用,请确认应用还在运行。", "error");
return null;
}
}
function gameRow(game) {
const row = el("div", "game");
const main = el("div", "game-main");
const title = el("div", "game-title");
title.append(el("b", "", game.name), el("span", "kind", game.source === "builtin" ? "内置" : game.host));
if (game.review) title.append(reviewBadge(game.review));
if (game.unencrypted) title.append(el("span", "kind warn", "未加密"));
if (game.playing) title.append(el("span", "kind", "正在玩"));
main.append(title);
if (game.summary) main.append(el("div", "game-text", game.summary));
if (game.review?.state === "removed") {
main.append(el("div", "game-text error", `已从游戏目录下架:${game.review.note}。建议卸载。`));
} else if (game.review?.state === "changed") {
main.append(el("div", "game-text warn", reviewChanges(game.review, game.version)));
}
const state = game.state === "error" ? `连接失败:${game.error}` : STATES[game.state];
main.append(el("div", game.state === "error" ? "game-text error" : "game-text", state));
if (game.permissions.length) main.append(el("div", "game-text", `可以:${game.permissions.join(";")}`));
if (game.source === "remote") main.append(el("div", "game-text", `你在玩它时说的话会发送到 ${game.host}`));
const actions = el("div", "game-actions");
if (game.pending.length) {
main.append(el("div", "game-text error", `还没允许:${game.pending.map((item) => item.label).join(";")}`));
const allow = el("button", "button", "允许");
allow.type = "button";
allow.onclick = () => gameAction(
{ action: "grant", id: game.id, capabilities: game.pending.map((item) => item.name) }, "已允许。");
actions.append(allow);
}
if (game.state === "error") {
const retry = el("button", "button", "重新连接");
retry.type = "button";
retry.onclick = () => gameAction({ action: "enable", id: game.id });
actions.append(retry);
}
const toggle = el("button", "button", game.state === "disabled" ? "启用" : "停用");
toggle.type = "button";
toggle.disabled = game.playing;
// 内置游戏停用后不再检查它的配置,服务配置那几栏要跟着刷新。
toggle.onclick = () => gameAction({ action: game.state === "disabled" ? "enable" : "disable", id: game.id })
.then(() => load());
actions.append(toggle);
if (game.source === "remote") {
const remove = el("button", "button danger", "卸载");
remove.type = "button";
remove.disabled = game.playing;
remove.onclick = () => {
if (confirm(`卸载“${game.name}”?`)) gameAction({ action: "uninstall", id: game.id }, "已卸载。");
};
actions.append(remove);
}
if (game.playing) toggle.title = "正在玩这个游戏,先说“结束游戏”";
row.append(main, actions);
return row;
}
function renderGames(data) {
$("games").replaceChildren(...data.games.map(gameRow));
}
function renderPreview(preview) {
const box = el("div", "preview");
const head = el("h3", "", preview.name);
const meta = [preview.version && `版本 ${preview.version}`, preview.author && `作者 ${preview.author}`].filter(Boolean);
box.append(head);
if (meta.length) box.append(el("div", "game-text", meta.join(" · ")));
const review = preview.review;
const extra = new Set(review.extra?.map((item) => item.name) || []);
if (review.state === "reviewed") {
box.append(el("div", "notice ok", `已审核:收录在游戏目录里,${review.reviewed_at} 审核过这个版本。`));
} else if (review.state === "changed") {
box.append(el("div", "notice warn",
`收录在游戏目录里,但当前版本没有经过审核:${reviewChanges(review, preview.version)}。`));
} else if (review.state === "unreviewed") {
box.append(el("div", "notice warn", "这个游戏没有收录在游戏目录里,没有经过审核。确认你信任它的作者再安装。"));
} else {
box.append(el("div", "notice bad", `这个游戏已经从游戏目录下架,不能安装:${review.note}`));
}
if (preview.unencrypted) {
box.append(el("div", "notice warn",
"这个地址用的是 http,没有加密:你在玩这个游戏时说的话和游戏的回复都会明文传输,网络上的其他人可能看到或改动。"));
}
box.append(el("div", "", preview.summary));
if (preview.description && preview.description !== preview.summary) box.append(el("div", "game-text", preview.description));
box.append(el("div", "game-text", "安装后它可以:"));
const list = el("ul");
const boxes = [];
for (const capability of preview.capabilities) {
const item = el("li");
const check = el("input");
check.type = "checkbox";
check.checked = capability.supported;
check.disabled = !capability.supported;
boxes.push([capability.name, check]);
const notes = [!capability.supported && "机器人不支持", extra.has(capability.name) && "审核时没有这项"]
.filter(Boolean);
item.append(check, el("span", "", capability.label + (notes.length ? `(${notes.join(",")})` : "")));
list.append(item);
}
const data = el("li");
data.append(el("span", "", `你在玩这个游戏时说的话,会发送到 ${preview.host}`));
list.append(data);
box.append(list);
const actions = el("div", "actions");
const install = el("button", "button primary", "安装");
install.type = "button";
install.onclick = async () => {
const granted = boxes.filter(([, check]) => check.checked).map(([name]) => name);
const done = await gameAction({ action: "install", url: preview.url, granted }, `已安装“${preview.name}”。`);
if (done) {
$("game-confirm").replaceChildren();
$("game-url").value = "";
}
};
const cancel = el("button", "button", "取消");
cancel.type = "button";
cancel.onclick = () => { $("game-confirm").replaceChildren(); gamesSay(""); };
if (review.state !== "removed") actions.append(install);
actions.append(cancel);
box.append(actions);
$("game-confirm").replaceChildren(box);
// 底部固定的保存栏可能挡住安装按钮,把确认内容滚到页面中间。
box.scrollIntoView({ block: "center", behavior: "smooth" });
}
// 发现更多游戏:游戏目录里审核过的游戏,安装时和粘贴地址一样要确认权限。
function catalogRow(listing) {
const row = el("div", "game");
const main = el("div", "game-main");
const title = el("div", "game-title");
title.append(el("b", "", listing.name), el("span", "kind", listing.host), el("span", "kind ok", "已审核"));
if (listing.unencrypted) title.append(el("span", "kind warn", "未加密"));
main.append(title, el("div", "game-text", listing.summary));
const meta = [`作者 ${listing.author}`, `版本 ${listing.version}`, `${listing.reviewed_at} 审核`];
main.append(el("div", "game-text", meta.join(" · ")));
if (listing.permissions.length) main.append(el("div", "game-text", `需要:${listing.permissions.join(";")}`));
const actions = el("div", "game-actions");
const install = el("button", "button", listing.installed ? "已安装" : "安装");
install.type = "button";
install.disabled = listing.installed || listing.taken;
if (listing.taken) install.title = `已经装了另一个 ID 为 ${listing.id} 的游戏,先卸载它`;
install.onclick = async () => {
$("game-confirm").replaceChildren();
const data = await gameAction({ action: "preview", url: listing.url });
if (data?.preview) renderPreview(data.preview);
};
actions.append(install);
row.append(main, actions);
return row;
}
function renderCatalog(data) {
const time = data.fetched_at && new Date(data.fetched_at * 1000).toLocaleString("zh-CN", { hour12: false });
const state = $("catalog-state");
state.className = data.error ? "game-text error" : "game-text";
if (data.error) state.textContent = data.fetched_at ? `${data.error},下面是 ${time} 读到的内容` : data.error;
else state.textContent = data.fetched_at ? `更新于 ${time}` : "";
const rows = data.games.map(catalogRow);
if (!rows.length && data.fetched_at) rows.push(el("div", "game-text", "目录里还没有游戏。"));
$("catalog").replaceChildren(...rows);
}
async function loadCatalog(refresh) {
$("catalog-refresh").disabled = true;
if (refresh) $("catalog-state").textContent = "正在读取游戏目录…";
try {
const response = await fetch(`settings/catalog${refresh ? "?refresh=1" : ""}`, { cache: "no-store" });
renderCatalog(await response.json());
} catch {
$("catalog-state").className = "game-text error";
$("catalog-state").textContent = "没有连上应用,请确认应用还在运行。";
} finally {
$("catalog-refresh").disabled = false;
}
}
$("catalog-refresh").addEventListener("click", () => loadCatalog(true));
$("game-preview").addEventListener("click", async () => {
const url = $("game-url").value.trim();
if (!url) return gamesSay("先粘贴游戏的地址。", "error");
$("game-confirm").replaceChildren();
const data = await gameAction({ action: "preview", url });
if (data?.preview) renderPreview(data.preview);
});
async function loadGames() {
const data = await (await fetch("settings/games", { cache: "no-store" })).json();
renderGames(data);
}
load().catch(() => say("没有连上应用,请确认应用还在运行。", "error"));
loadGames().catch(() => gamesSay("没有连上应用,请确认应用还在运行。", "error"));
loadCatalog();
</script>
</body>
</html>