1784 lines
107 KiB
HTML
1784 lines
107 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>laya-opencv</title>
|
||
<style>
|
||
:root {
|
||
--bg: #f2f6f7;
|
||
--wash: #dff3f0;
|
||
--card: #ffffff;
|
||
--ink: #1d2733;
|
||
--ink-2: #51606f;
|
||
--muted: #94a1ae;
|
||
--line: #e3e9ee;
|
||
--teal: #14b8a6;
|
||
--teal-deep: #0b8f81;
|
||
--teal-soft: #e5f7f4;
|
||
--warn: #dd7a0c;
|
||
--warn-soft: #fff3e2;
|
||
--bad: #d04848;
|
||
--bad-soft: #fdecec;
|
||
--sans: "Segoe UI", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
||
--mono: "Cascadia Mono", Consolas, "SF Mono", Menlo, monospace;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
[hidden] { display: none !important; }
|
||
html { background: var(--bg); }
|
||
body {
|
||
margin: 0; padding: 10px 12px 28px;
|
||
font: 15px/1.55 var(--sans); color: var(--ink);
|
||
background: linear-gradient(180deg, var(--wash) 0, var(--bg) 240px) no-repeat var(--bg);
|
||
min-height: 100vh;
|
||
}
|
||
button, input, select, textarea { font: inherit; color: inherit; }
|
||
code, .mono { font-family: var(--mono); }
|
||
:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
|
||
|
||
.topbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 2px 6px 10px; }
|
||
.topbar h1 { margin: 0; font-size: 18px; font-weight: 700; }
|
||
.topbar .seg button { height: 30px; padding: 0 12px; font-size: 13.5px; }
|
||
.card {
|
||
background: var(--card); border-radius: 18px; padding: 22px 24px;
|
||
box-shadow: 0 1px 2px rgba(23, 60, 70, .05), 0 8px 24px rgba(23, 60, 70, .06);
|
||
margin-bottom: 18px;
|
||
}
|
||
.grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 18px; align-items: start; }
|
||
.grid > .col > .card:last-child, .grid > .card { margin-bottom: 0; }
|
||
.grid { margin-bottom: 18px; }
|
||
@media (max-width: 980px) { .grid { grid-template-columns: minmax(0, 1fr); } }
|
||
|
||
/* 顶部配置 */
|
||
.config-grid { display: grid; grid-template-columns: 1.3fr 1.6fr 1fr; gap: 16px; }
|
||
@media (max-width: 860px) { .config-grid { grid-template-columns: 1fr; } }
|
||
.field label { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; font-size: 16px; }
|
||
.field label button { font-size: 13px; }
|
||
.control {
|
||
width: 100%; height: 46px; padding: 0 14px; border: 1px solid var(--line); border-radius: 12px;
|
||
background: #fff; display: flex; align-items: center; gap: 10px;
|
||
}
|
||
select.control { appearance: auto; }
|
||
input.control { font-family: var(--mono); }
|
||
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); flex: none; }
|
||
.dot.ok { background: var(--teal); }
|
||
.dot.wait { background: var(--warn); animation: blink 1.2s ease-in-out infinite; }
|
||
.dot.bad { background: var(--bad); }
|
||
@keyframes blink { 50% { opacity: .35; } }
|
||
@media (prefers-reduced-motion: reduce) { .dot.wait { animation: none; } }
|
||
.status-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.endpoint { margin-top: 14px; display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: baseline; font-size: 14px; }
|
||
.endpoint code { color: var(--ink-2); }
|
||
.hint { color: var(--ink-2); }
|
||
.hint.warn { color: var(--warn); }
|
||
.hint.bad { color: var(--bad); }
|
||
.versions { margin-top: 6px; font-size: 13px; color: var(--muted); }
|
||
.route { display: inline-flex; align-items: center; gap: 8px; color: var(--ink); }
|
||
.settings { margin-top: 14px; border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; background: #fafcfc; }
|
||
.settings h3 { margin: 0 0 4px; font-size: 15px; }
|
||
.settings p { margin: 0 0 12px; font-size: 13px; color: var(--ink-2); }
|
||
.set-grid { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 8px 14px; align-items: center; max-width: 760px; font-size: 14px; }
|
||
.set-grid > label { color: var(--ink-2); }
|
||
.set-grid input { height: 36px; padding: 0 10px; border: 1px solid var(--line); border-radius: 9px; font-family: var(--mono); font-size: 13px; width: 100%; background: #fff; }
|
||
.set-grid input.plain { font-family: var(--sans); font-size: 14px; }
|
||
.keyline { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; min-height: 36px; }
|
||
.keyline input { flex: 1 1 220px; width: auto; }
|
||
.keyline .mask { font-family: var(--mono); font-size: 13px; color: var(--ink); }
|
||
.keyline .none { color: var(--muted); }
|
||
.set-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 12px; }
|
||
.set-actions .spacer { flex: 1; }
|
||
.btn.danger { color: var(--bad); }
|
||
.btn.danger:hover { border-color: var(--bad); color: var(--bad); background: var(--bad-soft); }
|
||
.btn.primary { background: var(--teal); border-color: var(--teal); color: #fff; }
|
||
.btn.primary:hover { background: var(--teal-deep); border-color: var(--teal-deep); color: #fff; }
|
||
.prov-msg { font-size: 13px; color: var(--ink-2); margin: 8px 0 0; min-height: 1.2em; word-break: break-word; }
|
||
.prov-msg.bad { color: var(--bad); }
|
||
.prov-msg.ok { color: var(--teal-deep); }
|
||
.settings p.fine { margin: 12px 0 0; }
|
||
@media (max-width: 560px) { .set-grid { grid-template-columns: minmax(0, 1fr); gap: 4px; } .set-grid > label { margin-top: 6px; } }
|
||
.advanced { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 10px 22px; font-size: 14px; color: var(--ink-2); }
|
||
.advanced label { display: flex; align-items: center; gap: 8px; }
|
||
.advanced input { width: 96px; height: 32px; padding: 0 10px; border: 1px solid var(--line); border-radius: 8px; font-family: var(--mono); font-size: 13px; }
|
||
|
||
/* 说明 + 示例 */
|
||
.intro p { margin: 0 0 14px; font-size: 16px; }
|
||
.examples { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||
.examples .label { color: var(--ink-2); margin-right: 4px; }
|
||
#example-buttons { display: contents; }
|
||
.sep { width: 1px; height: 22px; background: var(--line); margin: 0 6px; }
|
||
.btn {
|
||
height: 36px; padding: 0 14px; border: 1px solid var(--line); border-radius: 10px; background: #fff;
|
||
cursor: pointer; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
|
||
}
|
||
.btn:hover { border-color: var(--teal); color: var(--teal-deep); }
|
||
.btn.ghost { border-color: transparent; background: transparent; }
|
||
.btn.small { height: 30px; padding: 0 10px; font-size: 13px; border-radius: 8px; }
|
||
.link { border: 0; background: none; padding: 0; color: var(--teal-deep); cursor: pointer; }
|
||
.link:hover { text-decoration: underline; }
|
||
.example-note { margin: 10px 0 -6px; font-size: 13px; color: var(--muted); min-height: 1.4em; }
|
||
|
||
/* 卡片标题行 */
|
||
.head { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
|
||
.head h2 { margin: 0; font-size: 18px; font-weight: 700; }
|
||
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; background: #fff; }
|
||
.seg button { border: 0; background: none; height: 34px; padding: 0 14px; cursor: pointer; }
|
||
.seg button[aria-pressed="true"] { background: var(--teal); color: #fff; }
|
||
.count { margin-top: 8px; font-size: 13px; color: var(--muted); }
|
||
|
||
textarea, .q input, .msg select {
|
||
width: 100%; border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; background: #fff;
|
||
}
|
||
textarea { resize: vertical; display: block; line-height: 1.6; }
|
||
textarea::placeholder, input::placeholder { color: var(--muted); }
|
||
#state-text, #state-batch { min-height: 220px; font-size: 16px; padding: 14px 16px; }
|
||
#state-json { min-height: 220px; font-family: var(--mono); font-size: 13.5px; }
|
||
.msg { display: grid; grid-template-columns: 112px 1fr 30px; gap: 8px; margin-bottom: 8px; align-items: start; }
|
||
.msg select { height: 42px; padding: 0 8px; }
|
||
.msg textarea { min-height: 42px; }
|
||
.x { width: 30px; height: 30px; border: 0; border-radius: 8px; background: none; color: var(--muted); cursor: pointer; font-size: 18px; line-height: 1; }
|
||
.x:hover { background: var(--bad-soft); color: var(--bad); }
|
||
|
||
/* 题目 */
|
||
.empty { text-align: center; color: var(--muted); padding: 30px 0 22px; }
|
||
.q { border: 1px solid var(--line); border-radius: 14px; padding: 14px; margin-bottom: 12px; }
|
||
.q-top { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||
.q-top input { flex: 1; height: 36px; padding: 0 10px; font-family: var(--mono); font-size: 13.5px; border-radius: 9px; }
|
||
.badge { flex: none; font-size: 12.5px; padding: 3px 9px; border-radius: 999px; background: var(--teal-soft); color: var(--teal-deep); }
|
||
.badge.choice { background: #eaf1ff; color: #2f5fc4; }
|
||
.badge.score { background: #f3ecff; color: #6b3fc0; }
|
||
.q textarea { min-height: 44px; }
|
||
.opts { margin-top: 10px; }
|
||
.opt { display: grid; grid-template-columns: 150px 1fr 30px; gap: 8px; margin-bottom: 6px; align-items: center; }
|
||
.opt.level { grid-template-columns: 30px 1fr 30px; }
|
||
.opt input { height: 36px; padding: 0 10px; border-radius: 9px; font-size: 14px; }
|
||
.opt .key { font-family: var(--mono); font-size: 13px; }
|
||
.opt .idx { text-align: center; color: var(--muted); font-family: var(--mono); font-size: 13px; }
|
||
@media (max-width: 560px) { .opt { grid-template-columns: 96px 1fr 30px; } }
|
||
|
||
/* 运行条 */
|
||
.runbar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 16px 18px; }
|
||
.run {
|
||
height: 52px; padding: 0 26px; border: 0; border-radius: 12px; background: var(--teal); color: #fff;
|
||
font-size: 17px; cursor: pointer; display: inline-flex; align-items: center; gap: 10px;
|
||
}
|
||
.run:hover:not(:disabled) { background: var(--teal-deep); }
|
||
.run:disabled { opacity: .5; cursor: not-allowed; }
|
||
.kbd { color: var(--muted); }
|
||
|
||
/* 结果 */
|
||
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); padding-bottom: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||
.tabs button { border: 0; background: none; height: 36px; padding: 0 14px; border-radius: 9px; cursor: pointer; color: var(--ink-2); }
|
||
.tabs button[aria-selected="true"] { background: var(--teal-soft); color: var(--teal-deep); }
|
||
.placeholder { text-align: center; color: var(--muted); padding: 90px 0; font-size: 16px; }
|
||
.placeholder small { font-size: 13.5px; }
|
||
.meta { font-size: 13.5px; color: var(--ink-2); margin-bottom: 14px; display: flex; flex-wrap: wrap; gap: 4px 18px; }
|
||
.meta .reason { flex-basis: 100%; color: var(--muted); }
|
||
.ans { border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; margin-bottom: 12px; }
|
||
.ans.low { border-color: #f1c58c; background: #fffaf2; }
|
||
.ans-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||
.ans-head code { font-size: 13.5px; }
|
||
.flag { font-size: 12.5px; padding: 2px 8px; border-radius: 999px; background: var(--warn-soft); color: var(--warn); }
|
||
.ans-q { margin: 4px 0 10px; font-size: 13.5px; color: var(--ink-2); }
|
||
.verdict { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
|
||
.verdict strong { font-size: 24px; line-height: 1.2; }
|
||
.verdict span { color: var(--ink-2); font-size: 14px; }
|
||
.row { display: grid; grid-template-columns: minmax(90px, 34%) 1fr 58px; gap: 10px; align-items: center; font-size: 13.5px; margin-bottom: 5px; }
|
||
.row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-2); }
|
||
.row.win .name { color: var(--ink); font-weight: 600; }
|
||
.row .pct { text-align: right; font-family: var(--mono); font-size: 12.5px; color: var(--ink-2); }
|
||
.bar { height: 8px; border-radius: 99px; background: #edf1f4; overflow: hidden; position: relative; }
|
||
.bar i { display: block; height: 100%; border-radius: 99px; background: #b9c6d1; }
|
||
.row.win .bar i { background: var(--teal); }
|
||
.bar.half::after { content: ""; position: absolute; left: 50%; top: 0; bottom: 0; width: 1px; background: #fff; }
|
||
.conf { margin-top: 8px; font-size: 12.5px; color: var(--muted); }
|
||
.errbox { border: 1px solid #f3c0c0; background: var(--bad-soft); color: #8f2626; border-radius: 12px; padding: 14px 16px; white-space: pre-wrap; word-break: break-word; }
|
||
.tools { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; align-items: center; }
|
||
pre.code {
|
||
margin: 0; padding: 14px 16px; border-radius: 12px; background: #16232b; color: #d9e7ea;
|
||
font: 13px/1.6 var(--mono); overflow: auto; max-height: 620px; white-space: pre; tab-size: 2;
|
||
}
|
||
.table-wrap { overflow-x: auto; }
|
||
table { border-collapse: collapse; width: 100%; font-size: 13.5px; }
|
||
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||
th { color: var(--ink-2); font-weight: 600; white-space: nowrap; }
|
||
th code { font-size: 12.5px; }
|
||
td.state { min-width: 220px; max-width: 420px; }
|
||
td.cell { white-space: nowrap; }
|
||
td.cell small { color: var(--muted); font-family: var(--mono); margin-left: 6px; }
|
||
td.yes { color: var(--teal-deep); font-weight: 600; }
|
||
td.lowc { background: #fffaf2; color: var(--warn); }
|
||
.hist { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; width: 100%; text-align: left; border: 1px solid var(--line); background: #fff; border-radius: 12px; padding: 10px 14px; margin-bottom: 8px; cursor: pointer; }
|
||
.hist:hover { border-color: var(--teal); }
|
||
.hist > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.hist .t { color: var(--muted); font-size: 12.5px; font-family: var(--mono); }
|
||
.hist .s { grid-column: 1 / -1; color: var(--ink-2); font-size: 13.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.hist .bad { color: var(--bad); }
|
||
|
||
/* 调试 */
|
||
details.debug summary { cursor: pointer; font-size: 17px; font-weight: 700; list-style-position: inside; }
|
||
details.debug summary #debug-count { color: var(--muted); font-weight: 400; font-size: 14px; margin-left: 8px; }
|
||
.dbg { margin-top: 14px; }
|
||
.dbg h3 { margin: 0 0 6px; font-size: 13.5px; font-weight: 600; color: var(--ink-2); font-family: var(--mono); }
|
||
.dbg-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||
@media (max-width: 860px) { .dbg-cols { grid-template-columns: 1fr; } }
|
||
.dbg pre.code { max-height: 320px; }
|
||
/* 视图切换 */
|
||
.topbar .nav { margin-right: auto; margin-left: 14px; }
|
||
@media (max-width: 720px) { .topbar { flex-wrap: wrap; } .topbar .nav { order: 3; margin-left: 0; width: 100%; } .topbar .nav button { flex: 1; } }
|
||
|
||
/* 视觉检测 / 训练 */
|
||
.vnote { margin: 10px 0 0; font-size: 13.5px; color: var(--ink-2); min-height: 1.4em; }
|
||
.vstatus { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 14px; margin-bottom: 12px; }
|
||
.vstatus .hint { font-size: 13.5px; }
|
||
a.btn { text-decoration: none; color: inherit; }
|
||
.btn.on { background: var(--teal-soft); border-color: var(--teal); color: var(--teal-deep); }
|
||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||
.drop { border: 2px dashed var(--line); border-radius: 14px; padding: 26px 16px; text-align: center; color: var(--ink-2); cursor: pointer; background: #fafcfc; }
|
||
.drop.over { border-color: var(--teal); background: var(--teal-soft); }
|
||
.drop small { display: block; margin-top: 4px; color: var(--muted); font-size: 13px; }
|
||
.imgbox { border-radius: 12px; overflow: hidden; background: #16232b; line-height: 0; text-align: center; }
|
||
.imgbox img, .imgbox video { max-width: 100%; max-height: 520px; }
|
||
.imgbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 10px; }
|
||
.imgbar .spacer { flex: 1; }
|
||
.legend { display: flex; gap: 6px 14px; flex-wrap: wrap; font-size: 13px; color: var(--ink-2); margin-top: 8px; }
|
||
.legend i { display: inline-block; width: 11px; height: 11px; border-radius: 3px; margin-right: 5px; vertical-align: -1px; }
|
||
.vfield { margin-bottom: 10px; }
|
||
.vfield > label { display: block; font-size: 13.5px; color: var(--ink-2); margin-bottom: 4px; }
|
||
.vfield input[type="text"], .vfield input[type="number"], .vfield select, .inline-input {
|
||
height: 36px; padding: 0 10px; border: 1px solid var(--line); border-radius: 9px; background: #fff; font-size: 14px; max-width: 100%;
|
||
}
|
||
.vfield input[type="text"], .vfield select { width: 100%; }
|
||
#v-context { min-height: 64px; font-size: 14.5px; }
|
||
#v-recipe-json { min-height: 320px; font-family: var(--mono); font-size: 12.5px; white-space: pre; overflow: auto; }
|
||
details.fold > summary { cursor: pointer; font-weight: 700; font-size: 16px; }
|
||
details.fold[open] > summary { margin-bottom: 12px; }
|
||
.check { display: inline-flex; align-items: center; gap: 7px; font-size: 14px; cursor: pointer; }
|
||
.check input { width: 16px; height: 16px; accent-color: var(--teal); }
|
||
.banner { border-radius: 12px; padding: 12px 16px; margin-bottom: 14px; font-size: 15px; }
|
||
.banner.ok { background: var(--teal-soft); color: #0a6e63; }
|
||
.banner.review { background: var(--warn-soft); color: #9a5406; }
|
||
.banner.info { background: #eef3f7; color: var(--ink-2); }
|
||
.banner ul { margin: 6px 0 0; padding-left: 20px; font-size: 13.5px; }
|
||
.banner .btn { margin-top: 8px; }
|
||
.src { font-size: 12.5px; padding: 2px 8px; border-radius: 999px; background: #eef3f7; color: var(--ink-2); }
|
||
.src.rule { background: #e8eefc; color: #2f5fc4; }
|
||
.flag.bad { background: var(--bad-soft); color: var(--bad); }
|
||
.flag.good { background: var(--teal-soft); color: var(--teal-deep); }
|
||
.cmp { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 12px; font-size: 13.5px; margin-top: 8px; }
|
||
.cmp dt { color: var(--ink-2); }
|
||
.cmp dd { margin: 0; word-break: break-word; }
|
||
.cmp dd small { color: var(--muted); }
|
||
.review-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 10px; font-size: 13px; color: var(--ink-2); }
|
||
.review-row select { height: 30px; border: 1px solid var(--line); border-radius: 8px; background: #fff; font-size: 13px; padding: 0 6px; }
|
||
td.num, th.num { text-align: right; font-family: var(--mono); font-size: 12.5px; white-space: nowrap; }
|
||
td.ok { color: var(--teal-deep); } td.no { color: var(--bad); } td.edge { color: var(--warn); }
|
||
.sub { margin: 18px 0 8px; font-size: 14.5px; font-weight: 700; }
|
||
.sub small { font-weight: 400; color: var(--muted); margin-left: 8px; }
|
||
.fine { font-size: 13px; color: var(--ink-2); margin: 8px 0; }
|
||
.progress { height: 8px; border-radius: 99px; background: #edf1f4; overflow: hidden; margin: 8px 0 4px; }
|
||
.progress i { display: block; height: 100%; background: var(--teal); border-radius: 99px; transition: width .3s; }
|
||
.cls { border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; margin-bottom: 10px; }
|
||
.cls.over { border-color: var(--teal); background: var(--teal-soft); }
|
||
.cls-head { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||
.cls-head strong { font-size: 15px; }
|
||
.cls-head .n { color: var(--muted); font-size: 13px; }
|
||
.cls-head .spacer { flex: 1; }
|
||
.thumbs { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 8px; }
|
||
.thumbs img { width: 46px; height: 46px; object-fit: cover; border-radius: 6px; background: #eef3f7; }
|
||
.rowline { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 10px; }
|
||
.rowline .grow { flex: 1 1 160px; min-width: 0; }
|
||
.rowline .inline-input { width: 150px; }
|
||
.rowline .inline-input.grow { width: auto; }
|
||
.checks { display: flex; gap: 6px 16px; flex-wrap: wrap; }
|
||
table.cm td, table.cm th { text-align: center; padding: 6px 8px; }
|
||
table.cm td.diag { font-weight: 700; }
|
||
.acc { font-size: 30px; font-weight: 700; line-height: 1.2; }
|
||
.acc small { font-size: 14px; font-weight: 400; color: var(--ink-2); margin-left: 8px; }
|
||
.model { border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; margin-bottom: 8px; }
|
||
.model-head { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
|
||
.model-head .spacer { flex: 1; }
|
||
.model .meta-line { font-size: 13px; color: var(--ink-2); margin-top: 2px; }
|
||
.toast { position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%); background: #16232b; color: #fff; padding: 9px 16px; border-radius: 10px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; }
|
||
.toast.on { opacity: 1; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="topbar">
|
||
<h1 data-i18n="title">laya-opencv</h1>
|
||
<div class="seg nav" id="view" role="group" data-i18n-aria="view.aria">
|
||
<button type="button" data-view="text" data-i18n="view.text">文本判定</button>
|
||
<button type="button" data-view="vision" data-i18n="view.vision">视觉检测</button>
|
||
<button type="button" data-view="train" data-i18n="view.train">模型训练</button>
|
||
</div>
|
||
<div class="seg" id="lang" role="group" aria-label="Language / 语言">
|
||
<button type="button" data-lang="zh" lang="zh-CN">中文</button>
|
||
<button type="button" data-lang="en" lang="en">English</button>
|
||
</div>
|
||
</div>
|
||
|
||
<header class="card" data-view="text vision">
|
||
<div class="config-grid">
|
||
<div class="field">
|
||
<label for="target"><span data-i18n="f.target">接口</span> <button class="link" id="toggle-settings" type="button" data-i18n="f.settings">接口设置</button></label>
|
||
<select class="control" id="target"></select>
|
||
</div>
|
||
<div class="field">
|
||
<label for="model"><span data-i18n="f.model">模型</span> <button class="link" id="refresh-models" type="button" data-i18n="f.refreshModels">刷新模型列表</button></label>
|
||
<input class="control" id="model" list="model-list" data-i18n-ph="f.modelPh" autocomplete="off" spellcheck="false">
|
||
<datalist id="model-list"></datalist>
|
||
</div>
|
||
<div class="field" id="mode-field">
|
||
<label for="mode" data-i18n="f.mode">请求方式</label>
|
||
<select class="control" id="mode">
|
||
<option value="single" data-i18n="mode.single">单条</option>
|
||
<option value="batch" data-i18n="mode.batch">批量</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="endpoint">
|
||
<span class="route" role="status"><span class="dot" id="status-dot"></span><span id="status-text"></span></span>
|
||
<code id="endpoint">POST /v1/systemone</code>
|
||
<span class="hint" id="hint"></span>
|
||
</div>
|
||
<div class="versions" id="versions" hidden></div>
|
||
<div class="settings" id="settings" hidden></div>
|
||
<div class="advanced">
|
||
<label><span data-i18n="adv.minConf">置信度阈值</span> <input id="min-conf" inputmode="decimal" data-i18n-ph="adv.notSet" autocomplete="off"></label>
|
||
<label>max_len <input id="max-len" inputmode="numeric" data-i18n-ph="adv.default" autocomplete="off"></label>
|
||
<span data-i18n="adv.note"></span>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="card intro" data-view="text">
|
||
<p data-i18n="intro"></p>
|
||
<div class="examples">
|
||
<span class="label" data-i18n="ex.label">示例:</span>
|
||
<span id="example-buttons"></span>
|
||
<span class="sep"></span>
|
||
<button class="btn" id="import" type="button" data-i18n="ex.import">导入 JSON</button>
|
||
<button class="btn ghost" id="reset" type="button" data-i18n="ex.reset">重置</button>
|
||
<input type="file" id="file" accept=".json,application/json" hidden>
|
||
</div>
|
||
<p class="example-note" id="example-note"></p>
|
||
</section>
|
||
|
||
<main class="grid" data-view="text">
|
||
<div class="col">
|
||
<section class="card">
|
||
<div class="head">
|
||
<h2 id="state-title"></h2>
|
||
<div class="seg" id="state-tabs" role="group" data-i18n-aria="state.format">
|
||
<button type="button" data-tab="text" data-i18n="state.text">文本</button>
|
||
<button type="button" data-tab="json" data-i18n="state.json">JSON 对象</button>
|
||
<button type="button" data-tab="messages" data-i18n="state.messages">消息列表</button>
|
||
</div>
|
||
</div>
|
||
<div id="state-body"></div>
|
||
<div class="count" id="state-count"></div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<div class="head">
|
||
<h2 id="q-title"></h2>
|
||
<div>
|
||
<button class="btn" type="button" data-add="noul" data-i18n="q.addNoul">+ 是非</button>
|
||
<button class="btn" type="button" data-add="choice" data-i18n="q.addChoice">+ 单选</button>
|
||
<button class="btn" type="button" data-add="score" data-i18n="q.addScore">+ 打分</button>
|
||
</div>
|
||
</div>
|
||
<div id="questions"></div>
|
||
</section>
|
||
|
||
<section class="card runbar">
|
||
<button class="run" id="run" type="button"><span aria-hidden="true">▷</span><span id="run-label"></span></button>
|
||
<span class="kbd">Ctrl / ⌘ + Enter</span>
|
||
<span class="hint warn" id="problem"></span>
|
||
</section>
|
||
</div>
|
||
|
||
<section class="card">
|
||
<div class="tabs" id="tabs" role="tablist">
|
||
<button type="button" role="tab" data-tab="result" data-i18n="tab.result">结果</button>
|
||
<button type="button" role="tab" data-tab="raw" data-i18n="tab.raw">原始响应</button>
|
||
<button type="button" role="tab" data-tab="request" data-i18n="tab.request">请求 JSON</button>
|
||
<button type="button" role="tab" data-tab="code" data-i18n="tab.code">代码片段</button>
|
||
<button type="button" role="tab" data-tab="history" data-i18n="tab.history">历史</button>
|
||
</div>
|
||
<div id="panel"></div>
|
||
</section>
|
||
</main>
|
||
|
||
<div id="vision-root" data-view="vision" hidden></div>
|
||
<div id="train-root" data-view="train" hidden></div>
|
||
|
||
<section class="card" data-view="text">
|
||
<details class="debug" id="debug">
|
||
<summary><span data-i18n="debug.title">调试</span><span id="debug-count">(0)</span></summary>
|
||
<div id="debug-body"></div>
|
||
</details>
|
||
</section>
|
||
|
||
<div class="toast" id="toast" role="status"></div>
|
||
|
||
<script>
|
||
"use strict";
|
||
|
||
/* ---------- 文案 / UI strings ---------- */
|
||
const I18N = {
|
||
zh: {
|
||
"title": "laya-opencv",
|
||
"view.aria": "功能", "view.text": "文本判定", "view.vision": "视觉检测", "view.train": "模型训练",
|
||
"f.target": "接口", "f.settings": "接口设置", "f.model": "模型(可直接输入自定义模型名)",
|
||
"f.refreshModels": "刷新模型列表", "f.modelPh": "留空 = 自动选择", "f.mode": "请求方式",
|
||
"mode.single": "单条", "mode.batch": "批量(每行一条)",
|
||
"adv.minConf": "置信度阈值", "adv.notSet": "不设置", "adv.default": "默认",
|
||
"adv.note": "阈值填 0~1,低于它的答案会标成「低置信度」;max_len 只对本地 Laya 生效,长文档可调到 8192。",
|
||
"intro": "Laya 和 Jev 都是判定模型:给它一段素材(state)和几道题,它返回每道题的概率分布,而不是生成文本。同一份请求可以发给本地 Laya,也可以发给 TypeSafe 的 Jev。",
|
||
"ex.label": "示例:", "ex.import": "导入 JSON", "ex.reset": "重置", "ex.none": "examples 文件夹里没有示例",
|
||
"ex.imported": "已导入 {name}", "ex.importFail": "导入失败:{msg}", "ex.note": "{title}:{note}",
|
||
"state.title": "素材(state)", "state.titleBatch": "素材(states,每行一条)", "state.format": "素材格式",
|
||
"state.text": "文本", "state.json": "JSON 对象", "state.messages": "消息列表",
|
||
"state.phText": "粘贴要判定的文本", "state.phBatch": "每行一条要判定的文本,最多 64 条",
|
||
"msg.role": "角色", "msg.ph": "消息内容", "msg.del": "删除这条消息", "msg.add": "+ 消息",
|
||
"count.chars": "{n} 字符", "count.items": "{n} 条", "count.msgs": "{n} 条消息",
|
||
"q.title": "题目 ({n})", "q.addNoul": "+ 是非", "q.addChoice": "+ 单选", "q.addScore": "+ 打分",
|
||
"type.noul": "是非", "type.choice": "单选", "type.score": "打分",
|
||
"q.empty": "点击右上角按钮添加题目,或先载入一个示例",
|
||
"q.idPh": "题目 ID,例如 department", "q.idAria": "题目 ID", "q.del": "删除这道题", "q.aria": "问题",
|
||
"q.phNoul": "写一个能用「是 / 否」回答的问题", "q.phChoice": "写出问题,下面列出可选项", "q.phScore": "写出问题,下面从低到高列出等级",
|
||
"opt.key": "标签", "opt.keyAria": "选项标签", "opt.desc": "这个选项的含义", "opt.descAria": "选项说明", "opt.del": "删除选项", "opt.add": "+ 选项",
|
||
"lvl.ph0": "最低等级的说明", "lvl.ph": "等级说明", "lvl.aria": "等级 {n} 的说明", "lvl.del": "删除等级", "lvl.add": "+ 等级",
|
||
"run": "运行", "run.running": "运行中…", "run.waiting": "等待模型加载…", "run.waitingNote": "模型加载完会自动运行,再点一次取消",
|
||
"tab.result": "结果", "tab.raw": "原始响应", "tab.request": "请求 JSON", "tab.code": "代码片段", "tab.history": "历史",
|
||
"debug.title": "调试:提交参数 / 返回结果", "toast.copied": "已复制",
|
||
"p.key": "请先在「接口设置」里填写「{name}」的密钥",
|
||
"p.localDisabled": "这台机器没有启用本地 Laya,请换一个接口",
|
||
"p.localExited": "本地 Laya 已退出,报错在启动窗口里,修好后重新启动",
|
||
"p.batchEmpty": "请填写素材,每行一条", "p.batchMax": "批量接口一次最多 64 条,现在是 {n} 条",
|
||
"p.stateEmpty": "请填写素材(state)", "p.badJson": "素材不是合法的 JSON:{msg}", "p.msgEmpty": "请至少填写一条消息",
|
||
"p.noQuestions": "请添加至少一道题目", "p.nth": "第 {n} 题", "p.noId": "第 {n} 题缺少题目 ID", "p.dupId": "题目 ID「{id}」重复了",
|
||
"p.noText": "「{name}」还没写问题", "p.opt2": "「{name}」至少需要 2 个选项", "p.optBlank": "「{name}」有选项没填标签",
|
||
"p.optDup": "「{name}」的选项标签有重复", "p.lvl2": "「{name}」至少需要 2 个等级", "p.lvlBlank": "「{name}」有等级没写说明",
|
||
"p.minConf": "置信度阈值要填 0 到 1 之间的数", "p.maxLen": "max_len 要填正整数",
|
||
"e.top": "JSON 顶层必须是一个对象", "e.noQ": "缺少 questions 对象",
|
||
"e.net": "网络请求没有发出去:{msg}\n工作台服务可能已经关闭,请重新运行 start.bat / start.sh。",
|
||
"r.running": "正在请求…", "r.waiting1": "本地模型还在加载,加载完成后会自动运行。",
|
||
"r.waiting2": "首次启动要下载模型(约 650 MB),下载进度在启动窗口里。",
|
||
"r.empty": "运行后,结果会显示在这里", "r.fail": "请求失败", "r.failedItem": "第 {n} 条素材失败:{msg}",
|
||
"tip.remoteAuth": "密钥无效,或者这个密钥没有该模型的权限。到「接口设置」里检查密钥,并确认模型名。",
|
||
"tip.remote404": "接口地址或模型名不对。到「接口设置」里点「测试连接」看看能取到哪些模型。",
|
||
"tip.remote429": "请求太频繁或额度用完了,稍后再试。",
|
||
"tip.remoteNet": "本机连不上这个接口。检查网络,或在 config.json 的 proxy 里指定代理。",
|
||
"tip.local503": "本地服务还没准备好或正忙。看一下启动窗口里的进度,稍后再试。",
|
||
"tip.local401": "本地服务设置了访问密钥,请在 config.json 的 api_key 里填上同一个密钥后重新启动。",
|
||
"tip.local500": "服务端推理出错,具体原因在启动窗口的日志里。",
|
||
"tip.bad": "这是请求内容的问题,按上面的提示修改即可。",
|
||
"r.noAnswers": "响应里没有 answers",
|
||
"r.batchNote": "是非题显示「是」的概率,单选题显示所选项的置信度。完整概率分布在「原始响应」里。",
|
||
"r.lowConf": "低置信度", "r.yes": "是", "r.no": "否", "r.yesProb": "「是」的概率 {p}", "r.nearest": "最接近:{text}",
|
||
"r.conf": "置信度 {v}", "r.gate": "门控: {v}", "gate.passed": "通过", "gate.abstained": "未达阈值", "gate.unevaluated": "无法评估",
|
||
"m.iface": "接口 {v}", "m.model": "模型 {v}", "m.infer": "推理 {v} ms", "m.round": "往返 {v} ms", "m.tokens": "输入 {v} tokens", "m.route": "路由:{v}",
|
||
"m.localGate": "置信度阈值由工作台在本地判断(远程接口没有这个参数)。",
|
||
"trail.fail": "{name} 失败({why})", "trail.ok": "{name} 成功", "trail.unreach": "连不上",
|
||
"th.material": "素材", "raw.empty": "运行后,这里显示服务返回的完整 JSON",
|
||
"btn.copy": "复制", "btn.download": "下载 request.json",
|
||
"h.empty": "还没有运行记录", "h.clear": "清空历史", "h.tip": "点一条记录,把它的请求载回左边", "h.loaded": "已载入",
|
||
"h.failed": "失败", "h.batch": "批量 {n} 条", "d.netErr": "网络错误",
|
||
"s.connecting": "正在连接…", "s.offline": "未连接到工作台服务", "s.keySet": "密钥已设置", "s.keyMissing": "未设置密钥",
|
||
"s.localReady": "本地 Laya 已就绪", "s.localDisabled": "本地 Laya 未启用", "s.localExited": "本地 Laya 已退出", "s.code": "(代码 {n})",
|
||
"s.localLoading": "本地 Laya 模型加载中…",
|
||
"hint.offline": "页面需要通过 start.bat / start.sh 启动后访问,直接打开 HTML 文件无法调用服务。",
|
||
"hint.needKey": "模型「{model}」要走远程接口,点右上方的「接口设置」填写密钥。",
|
||
"hint.via": "经本机转发到 {url}", "hint.backup": ",失败时自动改用 {names}",
|
||
"hint.batchRemote": "。远程接口没有批量端点,会逐条调用并按条计费",
|
||
"hint.localDisabled": "这台机器没有启用本地模型。换一个接口,或者安装本地 Laya 后重新启动。",
|
||
"hint.localExited": "本地 Laya 没有运行,报错信息在启动窗口里。远程接口仍然可用。",
|
||
"hint.loading": "模型还在加载。首次启动要下载模型,进度在启动窗口里,加载完会自动变成「已就绪」。",
|
||
"hint.autoRoute": "模型留空:由 Laya 自动路由,中文走 multilingual,英文走 english(没加载过的模型首次使用要先下载)。",
|
||
"hint.notLaya": "「{model}」不是 Laya 的模型名,本地服务会忽略它并自动路由。要调用 Jev,把接口换成「自动」或远程接口。",
|
||
"hint.notLoaded": "「{name}」还没有加载,首次调用会先下载并加载它,可能需要几分钟。",
|
||
"hint.addr": "服务地址 {url}",
|
||
"t.auto": "自动(推荐)", "t.local": "本地 Laya", "t.noKey": "(未设置密钥)",
|
||
"set.note": "密钥保存在本机的 config.json 里,请求由本机工作台转发,密钥不会出现在浏览器发出的请求中。",
|
||
"set.localNoKey": "本地 Laya 不需要密钥。",
|
||
"set.autoLocal": "当前模型由本地 Laya 处理,不需要密钥。要管理某个远程接口的密钥,先在「接口」里选中它。",
|
||
"set.autoUses": "「自动」当前会用这个接口。",
|
||
"set.name": "名称", "set.url": "接口地址", "set.urlPh": "https://example.com(不用带 /v1/systemone)",
|
||
"set.model": "默认模型", "set.modelPh": "例如 jev-latest,可留空", "set.key": "密钥",
|
||
"set.keyEnv": "来自环境变量 {name}",
|
||
"set.keyPh": "粘贴密钥", "set.keyPhEdit": "粘贴新的密钥", "set.keyOptionalPh": "粘贴密钥(这个接口不需要密钥就留空)",
|
||
"set.addKey": "添加密钥", "set.editKey": "修改", "set.delKey": "删除", "set.saveKey": "保存密钥", "set.cancel": "取消",
|
||
"set.confirmDelKey": "确认删除密钥", "set.keyEmpty": "请先粘贴密钥",
|
||
"set.keyAdded": "密钥已添加", "set.keyChanged": "密钥已修改", "set.keyDeleted": "密钥已删除",
|
||
"set.saveSettings": "保存设置", "set.delProv": "删除这个接口", "set.confirmDelProv": "确认删除「{name}」",
|
||
"set.provAdded": "接口已添加", "set.provDeleted": "接口已删除",
|
||
"set.addTitle": "添加第三方接口",
|
||
"set.addNote": "适用于任何提供 /v1/systemone 的服务。接口地址只填到域名或路径前缀,工作台会自动加上 /v1/systemone。",
|
||
"set.addBtn": "添加接口", "t.add": "+ 添加第三方接口…", "s.keyOptional": "无密钥",
|
||
"set.test": "测试连接", "set.saved": "已保存", "set.saveFail": "保存失败", "set.testing": "正在测试…",
|
||
"set.connFail": "连接失败:{msg}", "set.connOk": "连接正常,可用模型 {n} 个", "set.testFail": "测试失败:{msg}",
|
||
"set.rule": "「自动」的规则:模型名是 multilingual / english / typed-decisions(或留空)时用本地 Laya;其他模型名(如 jev-latest)按 config.json 里 auto_order 的顺序找第一个填了密钥的远程接口,连不上或被拒绝时自动换下一个。",
|
||
"models.partial": "部分接口没取到模型列表 — {errs}", "models.ok": "已刷新,共 {n} 个模型", "models.fail": "刷新失败:{msg}",
|
||
"sn.noBatch": "「{name}」没有批量端点,工作台是逐条调用 /v1/systemone 的。下面是第 1 条的写法,其余各条替换 state 即可。",
|
||
"sn.curl1": "1) 把请求内容保存为 request.json(UTF-8 编码),可以点上面的「下载 request.json」",
|
||
"sn.curl2": "2) 在同一个目录里执行(Windows PowerShell 里写 curl.exe,Linux 里写 curl)",
|
||
"sn.key": "你的密钥", "sn.noProxy": "本机请求不走代理",
|
||
"sn.js": "适用于 Node.js 18+ 或后端服务;密钥不要写进浏览器端代码",
|
||
"v.wrap": "({note})", "v.latest": "已是最新", "v.upgraded": "本次启动已从 {from} 自动升级",
|
||
"v.available": "有新版本 {v},自动升级已关闭", "v.failed": "新版本 {v} 自动升级失败,原因见启动窗口",
|
||
"v.offline": "这次没能检查更新", "v.rolledBack": "新版本 {v} 无法启动,已自动回退",
|
||
"v.skipped": "已跳过无法启动的 {v}", "v.running": "有新版本 {v},重新启动工作台后升级",
|
||
"v.model": "模型版本 {sha}", "v.modelUpdated": "本次启动已更新", "v.modelPending": "模型文件有更新,加载时自动下载",
|
||
"sep.list": "、", "sep.colon": ":", "sep.semi": ";", "sep.gap": " ", "r.failAt": "{name} 请求失败"
|
||
},
|
||
en: {
|
||
"title": "laya-opencv",
|
||
"view.aria": "Section", "view.text": "Text decisions", "view.vision": "Visual inspection", "view.train": "Model training",
|
||
"f.target": "Interface", "f.settings": "Interface settings", "f.model": "Model (any model name can be typed)",
|
||
"f.refreshModels": "Refresh model list", "f.modelPh": "Empty = choose automatically", "f.mode": "Request type",
|
||
"mode.single": "Single", "mode.batch": "Batch (one per line)",
|
||
"adv.minConf": "Confidence threshold", "adv.notSet": "none", "adv.default": "default",
|
||
"adv.note": "The threshold is between 0 and 1; answers below it are flagged as low confidence. max_len only applies to local Laya; raise it to 8192 for long documents.",
|
||
"intro": "Laya and Jev are decision models: give them some material (state) and a few questions, and they return a probability distribution for each question instead of generated text. The same request can go to local Laya or to TypeSafe's Jev.",
|
||
"ex.label": "Examples:", "ex.import": "Import JSON", "ex.reset": "Reset", "ex.none": "No examples in the examples folder",
|
||
"ex.imported": "Imported {name}", "ex.importFail": "Import failed: {msg}", "ex.note": "{title}: {note}",
|
||
"state.title": "Material (state)", "state.titleBatch": "Materials (states, one per line)", "state.format": "Material format",
|
||
"state.text": "Text", "state.json": "JSON object", "state.messages": "Messages",
|
||
"state.phText": "Paste the text to evaluate", "state.phBatch": "One text per line, up to 64",
|
||
"msg.role": "Role", "msg.ph": "Message content", "msg.del": "Remove this message", "msg.add": "+ Message",
|
||
"count.chars": "{n} characters", "count.items": "{n} items", "count.msgs": "{n} messages",
|
||
"q.title": "Questions ({n})", "q.addNoul": "+ Yes / No", "q.addChoice": "+ Choice", "q.addScore": "+ Score",
|
||
"type.noul": "Yes / No", "type.choice": "Choice", "type.score": "Score",
|
||
"q.empty": "Add a question with the buttons above, or load an example first",
|
||
"q.idPh": "Question ID, e.g. department", "q.idAria": "Question ID", "q.del": "Remove this question", "q.aria": "Question",
|
||
"q.phNoul": "Write a question that can be answered yes or no", "q.phChoice": "Write the question, then list the options below",
|
||
"q.phScore": "Write the question, then list the levels below from lowest to highest",
|
||
"opt.key": "Label", "opt.keyAria": "Option label", "opt.desc": "What this option means", "opt.descAria": "Option description", "opt.del": "Remove option", "opt.add": "+ Option",
|
||
"lvl.ph0": "Description of the lowest level", "lvl.ph": "Level description", "lvl.aria": "Description of level {n}", "lvl.del": "Remove level", "lvl.add": "+ Level",
|
||
"run": "Run", "run.running": "Running…", "run.waiting": "Waiting for the model…", "run.waitingNote": "Runs automatically once the model has loaded; click again to cancel",
|
||
"tab.result": "Result", "tab.raw": "Raw response", "tab.request": "Request JSON", "tab.code": "Code", "tab.history": "History",
|
||
"debug.title": "Debug: requests and responses", "toast.copied": "Copied",
|
||
"p.key": "Add the API key for \"{name}\" under Interface settings first",
|
||
"p.localDisabled": "Local Laya is not enabled on this machine; pick another interface",
|
||
"p.localExited": "Local Laya has exited; the error is in the launcher window. Fix it and restart",
|
||
"p.batchEmpty": "Enter the materials, one per line", "p.batchMax": "A batch takes at most 64 items; there are {n}",
|
||
"p.stateEmpty": "Enter the material (state)", "p.badJson": "The material is not valid JSON: {msg}", "p.msgEmpty": "Enter at least one message",
|
||
"p.noQuestions": "Add at least one question", "p.nth": "Question {n}", "p.noId": "Question {n} has no ID", "p.dupId": "Question ID \"{id}\" is used more than once",
|
||
"p.noText": "\"{name}\" has no question text", "p.opt2": "\"{name}\" needs at least 2 options", "p.optBlank": "\"{name}\" has an option without a label",
|
||
"p.optDup": "\"{name}\" has duplicate option labels", "p.lvl2": "\"{name}\" needs at least 2 levels", "p.lvlBlank": "\"{name}\" has a level without a description",
|
||
"p.minConf": "The confidence threshold must be a number between 0 and 1", "p.maxLen": "max_len must be a positive integer",
|
||
"e.top": "The JSON must be an object at the top level", "e.noQ": "The questions object is missing",
|
||
"e.net": "The request could not be sent: {msg}\nThe workbench service may have stopped; run start.bat / start.sh again.",
|
||
"r.running": "Requesting…", "r.waiting1": "The local model is still loading; the request will run automatically when it is ready.",
|
||
"r.waiting2": "The first start downloads the model (about 650 MB); progress is shown in the launcher window.",
|
||
"r.empty": "Results appear here after you run", "r.fail": "Request failed", "r.failedItem": "Item {n} failed: {msg}",
|
||
"tip.remoteAuth": "The key is invalid, or it has no access to this model. Check the key under Interface settings and confirm the model name.",
|
||
"tip.remote404": "The base URL or the model name is wrong. Use Test connection under Interface settings to see which models are available.",
|
||
"tip.remote429": "Too many requests, or the quota is used up. Try again later.",
|
||
"tip.remoteNet": "This machine cannot reach the interface. Check the network, or set a proxy in config.json.",
|
||
"tip.local503": "The local service is not ready or is busy. Check the progress in the launcher window and try again.",
|
||
"tip.local401": "The local service requires an access key. Put the same key in api_key in config.json and restart.",
|
||
"tip.local500": "Inference failed on the server; the cause is in the launcher window's log.",
|
||
"tip.bad": "The request content is the problem; fix it as described above.",
|
||
"r.noAnswers": "The response has no answers",
|
||
"r.batchNote": "Yes / No questions show the probability of yes; choice questions show the confidence of the chosen option. Full distributions are under Raw response.",
|
||
"r.lowConf": "Low confidence", "r.yes": "Yes", "r.no": "No", "r.yesProb": "Probability of yes: {p}", "r.nearest": "closest: {text}",
|
||
"r.conf": "Confidence {v}", "r.gate": "Gate: {v}", "gate.passed": "passed", "gate.abstained": "below threshold", "gate.unevaluated": "not evaluated",
|
||
"m.iface": "Interface: {v}", "m.model": "Model: {v}", "m.infer": "Inference {v} ms", "m.round": "Round trip {v} ms", "m.tokens": "Input {v} tokens", "m.route": "Routing: {v}",
|
||
"m.localGate": "The confidence threshold was applied locally by the workbench (remote interfaces have no such parameter).",
|
||
"trail.fail": "{name} failed ({why})", "trail.ok": "{name} succeeded", "trail.unreach": "unreachable",
|
||
"th.material": "Material", "raw.empty": "The full JSON returned by the service appears here after you run",
|
||
"btn.copy": "Copy", "btn.download": "Download request.json",
|
||
"h.empty": "No runs yet", "h.clear": "Clear history", "h.tip": "Click an entry to load its request back into the editor", "h.loaded": "Loaded",
|
||
"h.failed": "failed", "h.batch": "Batch of {n}", "d.netErr": "network error",
|
||
"s.connecting": "Connecting…", "s.offline": "Not connected to the workbench service", "s.keySet": "key saved", "s.keyMissing": "no key",
|
||
"s.localReady": "Local Laya ready", "s.localDisabled": "Local Laya not enabled", "s.localExited": "Local Laya exited", "s.code": " (code {n})",
|
||
"s.localLoading": "Local Laya is loading the model…",
|
||
"hint.offline": "Open this page through start.bat / start.sh; opening the HTML file directly cannot reach the service.",
|
||
"hint.needKey": "Model \"{model}\" goes to a remote interface. Add its key under Interface settings (top left).",
|
||
"hint.via": "Forwarded by this machine to {url}", "hint.backup": "; falls back to {names} on failure",
|
||
"hint.batchRemote": ". Remote interfaces have no batch endpoint, so items are sent one by one and billed per item",
|
||
"hint.localDisabled": "The local model is not enabled on this machine. Pick another interface, or install local Laya and restart.",
|
||
"hint.localExited": "Local Laya is not running; the error is in the launcher window. Remote interfaces still work.",
|
||
"hint.loading": "The model is still loading. The first start downloads it; progress is in the launcher window, and this changes to \"ready\" when done.",
|
||
"hint.autoRoute": "Model left empty: Laya routes automatically, English text to english and other languages to multilingual (a model not loaded yet is downloaded on first use).",
|
||
"hint.notLaya": "\"{model}\" is not a Laya model name; the local service ignores it and routes automatically. To call Jev, switch the interface to Auto or a remote one.",
|
||
"hint.notLoaded": "\"{name}\" is not loaded yet; the first call downloads and loads it, which can take a few minutes.",
|
||
"hint.addr": "Service address {url}",
|
||
"t.auto": "Auto (recommended)", "t.local": "Local Laya", "t.noKey": " (no key)",
|
||
"set.note": "Keys are stored in config.json on this machine and added by the local launcher; they never appear in requests sent by the browser.",
|
||
"set.localNoKey": "Local Laya needs no key.",
|
||
"set.autoLocal": "The current model is handled by local Laya, so no key is needed. To manage a remote interface's key, select that interface first.",
|
||
"set.autoUses": "Auto currently uses this interface.",
|
||
"set.name": "Name", "set.url": "Base URL", "set.urlPh": "https://example.com (without /v1/systemone)",
|
||
"set.model": "Default model", "set.modelPh": "e.g. jev-latest; may be empty", "set.key": "API key",
|
||
"set.keyEnv": "from the environment variable {name}",
|
||
"set.keyPh": "Paste the API key", "set.keyPhEdit": "Paste the new key", "set.keyOptionalPh": "Paste the API key (leave empty if this interface needs none)",
|
||
"set.addKey": "Add key", "set.editKey": "Change", "set.delKey": "Delete", "set.saveKey": "Save key", "set.cancel": "Cancel",
|
||
"set.confirmDelKey": "Confirm: delete the key", "set.keyEmpty": "Paste a key first",
|
||
"set.keyAdded": "Key added", "set.keyChanged": "Key changed", "set.keyDeleted": "Key deleted",
|
||
"set.saveSettings": "Save settings", "set.delProv": "Delete this interface", "set.confirmDelProv": "Confirm: delete \"{name}\"",
|
||
"set.provAdded": "Interface added", "set.provDeleted": "Interface deleted",
|
||
"set.addTitle": "Add a third-party interface",
|
||
"set.addNote": "For any service that offers /v1/systemone. Enter only the host or path prefix; the workbench appends /v1/systemone itself.",
|
||
"set.addBtn": "Add interface", "t.add": "+ Add a third-party interface…", "s.keyOptional": "no key",
|
||
"set.test": "Test connection", "set.saved": "Saved", "set.saveFail": "Save failed", "set.testing": "Testing…",
|
||
"set.connFail": "Connection failed: {msg}", "set.connOk": "Connected; {n} models available", "set.testFail": "Test failed: {msg}",
|
||
"set.rule": "How Auto works: when the model is multilingual / english / typed-decisions (or empty), local Laya is used. Any other model name (such as jev-latest) goes to the first remote interface in auto_order (config.json) that has a key; if it is unreachable or refuses the request, the next one is tried.",
|
||
"models.partial": "Some interfaces returned no model list — {errs}", "models.ok": "Refreshed: {n} models", "models.fail": "Refresh failed: {msg}",
|
||
"sn.noBatch": "\"{name}\" has no batch endpoint; the workbench calls /v1/systemone once per item. This is the first item; replace state for the others.",
|
||
"sn.curl1": "1) Save the request as request.json (UTF-8); the \"Download request.json\" button above does this",
|
||
"sn.curl2": "2) Run this in the same folder (curl.exe in Windows PowerShell, curl on Linux)",
|
||
"sn.key": "YOUR_API_KEY", "sn.noProxy": "bypass proxies for local requests",
|
||
"sn.js": "For Node.js 18+ or a backend service; never put the key in browser code",
|
||
"v.wrap": " ({note})", "v.latest": "up to date", "v.upgraded": "upgraded automatically from {from} at this start",
|
||
"v.available": "version {v} is available; automatic upgrades are off", "v.failed": "the automatic upgrade to {v} failed, see the launcher window",
|
||
"v.offline": "could not check for updates this time", "v.rolledBack": "version {v} failed to start and was rolled back",
|
||
"v.skipped": "skipping {v}, which failed to start", "v.running": "version {v} is available; restart the workbench to upgrade",
|
||
"v.model": "model revision {sha}", "v.modelUpdated": "updated at this start", "v.modelPending": "model files have an update; it downloads when the model loads",
|
||
"sep.list": ", ", "sep.colon": ": ", "sep.semi": "; ", "sep.gap": ", ", "r.failAt": "Request to {name} failed"
|
||
}
|
||
};
|
||
/* 浏览器里保存设置用的键名沿用项目的旧名字,改名后原来的语言、历史记录等设置还在 */
|
||
const LANG_KEY = "laya-workbench-lang";
|
||
let LANG = "zh", LANG_CHOSEN = false;
|
||
try {
|
||
const saved = localStorage.getItem(LANG_KEY);
|
||
if (saved === "zh" || saved === "en") { LANG = saved; LANG_CHOSEN = true; }
|
||
else LANG = /^zh/i.test(navigator.language || "") ? "zh" : "en";
|
||
} catch (e) { LANG = /^zh/i.test(navigator.language || "") ? "zh" : "en"; }
|
||
function t(key, vars) {
|
||
let text = I18N[LANG][key];
|
||
if (text == null) text = I18N.zh[key] != null ? I18N.zh[key] : key;
|
||
if (vars) for (const name in vars) text = text.split("{" + name + "}").join(String(vars[name]));
|
||
return text;
|
||
}
|
||
function applyStatic() {
|
||
document.documentElement.lang = LANG === "zh" ? "zh-CN" : "en";
|
||
document.title = t("title");
|
||
document.querySelectorAll("[data-i18n]").forEach(el => { el.textContent = t(el.dataset.i18n); });
|
||
document.querySelectorAll("[data-i18n-ph]").forEach(el => { el.placeholder = t(el.dataset.i18nPh); });
|
||
document.querySelectorAll("[data-i18n-aria]").forEach(el => { el.setAttribute("aria-label", t(el.dataset.i18nAria)); });
|
||
for (const b of $("#lang").children) b.setAttribute("aria-pressed", String(b.dataset.lang === LANG));
|
||
}
|
||
|
||
/* ---------- 小工具 ---------- */
|
||
function $(sel, root) { return (root || document).querySelector(sel); }
|
||
function h(tag, attrs) {
|
||
const el = document.createElement(tag);
|
||
for (const key in (attrs || {})) {
|
||
const value = attrs[key];
|
||
if (value == null || value === false) continue;
|
||
if (key === "class") el.className = value;
|
||
else if (key === "text") el.textContent = value;
|
||
else if (key.slice(0, 2) === "on") el.addEventListener(key.slice(2), value);
|
||
else if (key === "value") el.value = value;
|
||
else el.setAttribute(key, value === true ? "" : value);
|
||
}
|
||
for (let i = 2; i < arguments.length; i++) {
|
||
const kid = arguments[i];
|
||
if (kid == null || kid === false) continue;
|
||
el.append(kid.nodeType ? kid : document.createTextNode(String(kid)));
|
||
}
|
||
return el;
|
||
}
|
||
const pct = v => (Number(v) * 100).toFixed(1) + "%";
|
||
const pretty = obj => JSON.stringify(obj, null, 2);
|
||
let uidSeq = 0;
|
||
const uid = () => "q" + (++uidSeq);
|
||
/* 所有发给工作台的请求都带上界面语言,服务端的报错会用同一种语言 */
|
||
function api(path, options) {
|
||
options = options || {};
|
||
options.headers = Object.assign({ "X-WB-Lang": LANG }, options.headers || {});
|
||
return fetch(path, options);
|
||
}
|
||
|
||
function toast(text) {
|
||
const el = $("#toast");
|
||
el.textContent = text;
|
||
el.classList.add("on");
|
||
clearTimeout(toast.timer);
|
||
toast.timer = setTimeout(() => el.classList.remove("on"), 1800);
|
||
}
|
||
async function copyText(text) {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
} catch (e) {
|
||
const ta = h("textarea", { value: text });
|
||
document.body.append(ta);
|
||
ta.select();
|
||
try { document.execCommand("copy"); } catch (e2) { /* 复制不了就算了 */ }
|
||
ta.remove();
|
||
}
|
||
toast(t("toast.copied"));
|
||
}
|
||
function download(name, text) {
|
||
const url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
|
||
const a = h("a", { href: url, download: name });
|
||
document.body.append(a);
|
||
a.click();
|
||
a.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||
}
|
||
|
||
/* ---------- 状态 ---------- */
|
||
const S = {
|
||
mode: "single",
|
||
stateTab: "text",
|
||
text: "",
|
||
json: "{\n \n}",
|
||
messages: [{ role: "user", content: "" }],
|
||
batch: "",
|
||
questions: [],
|
||
target: "auto",
|
||
model: "multilingual",
|
||
modelOptions: null,
|
||
settingsOpen: false,
|
||
adding: false, // 正在填写「添加第三方接口」
|
||
keyEdit: false, // 正在修改密钥
|
||
confirm: null, // "key" / "provider":等待确认删除
|
||
setMsg: null, // 设置区的提示 {cls, text}
|
||
setShown: "", // 设置区当前显示的是哪个接口
|
||
minConf: "",
|
||
maxLen: "",
|
||
tab: "result",
|
||
snippet: "curl",
|
||
last: null,
|
||
running: false,
|
||
waiting: false, // 点了运行,但本地模型还在加载:加载完自动运行
|
||
debug: [],
|
||
history: [],
|
||
status: undefined, // undefined = 还在连接,null = 连接失败
|
||
view: "text", // text / vision / train
|
||
examples: [],
|
||
exampleFile: null, // 当前内容来自哪个示例文件
|
||
exampleSnap: "", // 载入示例时的内容,用来判断有没有被改过
|
||
note: "",
|
||
};
|
||
const HISTORY_KEY = "laya-workbench-history";
|
||
try { S.history = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]") || []; } catch (e) { S.history = []; }
|
||
const TARGET_KEY = "laya-workbench-target";
|
||
try { S.target = localStorage.getItem(TARGET_KEY) || "auto"; } catch (e) { S.target = "auto"; }
|
||
const VIEW_KEY = "laya-workbench-view";
|
||
try { const v = localStorage.getItem(VIEW_KEY); if (v === "vision" || v === "train") S.view = v; } catch (e) { /* 用默认视图 */ }
|
||
function applyView() {
|
||
document.querySelectorAll("[data-view]").forEach(el => {
|
||
if (el.parentElement && el.parentElement.id === "view") return;
|
||
el.hidden = !el.dataset.view.split(" ").includes(S.view);
|
||
});
|
||
for (const b of $("#view").children) b.setAttribute("aria-pressed", String(b.dataset.view === S.view));
|
||
$("#mode-field").hidden = S.view !== "text";
|
||
if (window.WBV) window.WBV.onView();
|
||
}
|
||
function setView(view) {
|
||
S.view = ["vision", "train"].includes(view) ? view : "text";
|
||
try { localStorage.setItem(VIEW_KEY, S.view); } catch (e) { /* 记不住也没关系 */ }
|
||
applyView();
|
||
}
|
||
function saveHistory() {
|
||
try { localStorage.setItem(HISTORY_KEY, JSON.stringify(S.history.slice(0, 30))); } catch (e) { /* 存不了不影响使用 */ }
|
||
}
|
||
|
||
/* ---------- 接口选择 ---------- */
|
||
const LAYA_NAMES = ["english", "multilingual", "typed-decisions", "en", "laya", "default", "multi", "ml",
|
||
"laya-multilingual", "typed", "typed_decisions", "laya-typed-decisions", "decisions"];
|
||
const LOCAL = { id: "local", local: true };
|
||
function pname(p) {
|
||
if (!p) return "";
|
||
if (p.local) return t("t.local");
|
||
return LANG === "en" ? (p.name_en || p.name) : p.name;
|
||
}
|
||
function isLayaModel(model) {
|
||
const name = String(model || "").trim().toLowerCase();
|
||
return !name || LAYA_NAMES.includes(name) || name.startsWith("convaiinnovations/");
|
||
}
|
||
function usable(p) { return !!p && (p.local || p.has_key || p.key_optional); }
|
||
function providers() { return (S.status && S.status.providers) || []; }
|
||
function providerById(id) { return providers().find(p => p.id === id); }
|
||
/* 当前这份请求实际会发给谁。规则和启动器里的 resolve() 一致。 */
|
||
function resolved() {
|
||
if (S.target === "local") return LOCAL;
|
||
if (S.target !== "auto") return providerById(S.target) || LOCAL;
|
||
if (isLayaModel(S.model)) return LOCAL;
|
||
const order = ((S.status && S.status.auto_order) || []).map(providerById).filter(Boolean);
|
||
return order.find(usable) || order[0] || LOCAL;
|
||
}
|
||
function autoBackups() {
|
||
if (S.target !== "auto" || isLayaModel(S.model)) return [];
|
||
const order = ((S.status && S.status.auto_order) || []).map(providerById).filter(p => p && usable(p));
|
||
return order.slice(1);
|
||
}
|
||
function fitModelToTarget() {
|
||
if (S.target === "local" && !isLayaModel(S.model)) S.model = "multilingual";
|
||
else if (S.target !== "auto" && S.target !== "local" && isLayaModel(S.model)) {
|
||
const p = providerById(S.target);
|
||
S.model = (p && p.default_model) || "jev-latest";
|
||
}
|
||
}
|
||
function autoLabel() {
|
||
return S.target === "auto" ? t("t.auto") + " → " + pname(resolved()) : t("t.auto");
|
||
}
|
||
function renderTargets() {
|
||
const sel = $("#target");
|
||
const options = [["auto", autoLabel()], ["local", t("t.local")]]
|
||
.concat(providers().map(p => [p.id, pname(p) + (usable(p) ? "" : t("t.noKey"))]));
|
||
if (!options.some(o => o[0] === S.target)) S.target = "auto";
|
||
options.push(["__add", t("t.add")]);
|
||
sel.textContent = "";
|
||
options.forEach(o => sel.append(h("option", { value: o[0] }, o[1])));
|
||
sel.value = S.adding ? "__add" : S.target;
|
||
}
|
||
function renderModelOptions() {
|
||
const list = $("#model-list");
|
||
list.textContent = "";
|
||
let items = S.modelOptions;
|
||
if (!items) {
|
||
items = [];
|
||
if (S.target === "local" || S.target === "auto") items.push("multilingual", "english", "typed-decisions");
|
||
if (S.target !== "local") items.push("jev-latest");
|
||
}
|
||
items.forEach(id => list.append(h("option", { value: id })));
|
||
}
|
||
async function refreshModels() {
|
||
try {
|
||
const resp = await api("/_wb/models?target=" + encodeURIComponent(S.target), { cache: "no-store" });
|
||
const data = await resp.json();
|
||
S.modelOptions = (data.models || []).map(m => m.id);
|
||
renderModelOptions();
|
||
const errs = Object.keys(data.errors || {}).map(id => (pname(providerById(id)) || id) + t("sep.colon") + data.errors[id]);
|
||
toast(errs.length ? t("models.partial", { errs: errs.join(t("sep.semi")) }) : t("models.ok", { n: S.modelOptions.length }));
|
||
} catch (e) { toast(t("models.fail", { msg: e.message })); }
|
||
}
|
||
/* 设置区只显示「接口」里选中的那一个接口 */
|
||
function settingsProvider() {
|
||
if (S.adding || S.target === "local") return null;
|
||
if (S.target === "auto") { const d = resolved(); return d.local ? null : d; }
|
||
return providerById(S.target) || null;
|
||
}
|
||
function settingsKey() {
|
||
if (S.adding) return "__add";
|
||
const p = settingsProvider();
|
||
return p ? p.id : "none:" + S.target;
|
||
}
|
||
function resetSettingsUi() { S.keyEdit = false; S.confirm = null; S.setMsg = null; }
|
||
async function postSettings(body) {
|
||
const resp = await api("/_wb/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||
const data = await resp.json();
|
||
if (!resp.ok) throw new Error(data.detail || t("set.saveFail"));
|
||
S.status = data; S.modelOptions = null;
|
||
return data;
|
||
}
|
||
function settingsDone(text) {
|
||
S.keyEdit = false; S.confirm = null;
|
||
S.setShown = settingsKey(); // 接口可能刚换过(新建 / 删除),别让 refresh 把提示清掉
|
||
S.setMsg = { cls: "ok", text: text };
|
||
renderTargets(); renderModelOptions(); renderStatus(); refresh(); renderSettings();
|
||
}
|
||
function settingsFail(e) { S.setMsg = { cls: "bad", text: e.message || String(e) }; renderSettings(); }
|
||
function setTarget(id) {
|
||
S.target = id;
|
||
try { localStorage.setItem(TARGET_KEY, S.target); } catch (err) { /* 记不住也没关系 */ }
|
||
S.modelOptions = null;
|
||
fitModelToTarget(); $("#model").value = S.model;
|
||
}
|
||
function renderAddForm(box) {
|
||
const name = h("input", { class: "plain", id: "add-name", autocomplete: "off" });
|
||
const base = h("input", { id: "add-url", placeholder: t("set.urlPh"), spellcheck: "false", autocomplete: "off" });
|
||
const key = h("input", { id: "add-key", type: "password", autocomplete: "off", placeholder: t("set.keyOptionalPh") });
|
||
const model = h("input", { id: "add-model", placeholder: t("set.modelPh"), spellcheck: "false", autocomplete: "off" });
|
||
const add = async () => {
|
||
try {
|
||
const data = await postSettings({ action: "add", name: name.value, base_url: base.value, api_key: key.value, default_model: model.value });
|
||
S.adding = false; S.settingsOpen = true;
|
||
setTarget(data.created);
|
||
settingsDone(t("set.provAdded"));
|
||
} catch (e) { settingsFail(e); }
|
||
};
|
||
const cancel = () => { S.adding = false; resetSettingsUi(); renderTargets(); renderSettings(); refresh(); };
|
||
box.append(h("h3", {}, t("set.addTitle")), h("p", {}, t("set.addNote")));
|
||
box.append(h("div", { class: "set-grid" },
|
||
h("label", { for: "add-name" }, t("set.name")), name,
|
||
h("label", { for: "add-url" }, t("set.url")), base,
|
||
h("label", { for: "add-key" }, t("set.key")), key,
|
||
h("label", { for: "add-model" }, t("set.model")), model));
|
||
box.append(h("div", { class: "set-actions" },
|
||
h("button", { class: "btn small primary", type: "button", onclick: add }, t("set.addBtn")),
|
||
h("button", { class: "btn small", type: "button", onclick: cancel }, t("set.cancel"))));
|
||
if (S.setMsg) box.append(h("div", { class: "prov-msg " + (S.setMsg.cls || "") }, S.setMsg.text));
|
||
}
|
||
function renderSettings() {
|
||
const box = $("#settings");
|
||
box.hidden = !S.settingsOpen && !S.adding;
|
||
S.setShown = settingsKey();
|
||
if (box.hidden) return;
|
||
box.textContent = "";
|
||
if (S.adding) return renderAddForm(box);
|
||
const p = settingsProvider();
|
||
if (!p) {
|
||
box.append(h("p", { style: "margin:0" }, S.target === "local" ? t("set.localNoKey") : t("set.autoLocal")));
|
||
if (S.target === "auto") box.append(h("p", { class: "fine" }, t("set.rule")));
|
||
return;
|
||
}
|
||
const say = (cls, text) => { S.setMsg = { cls: cls, text: text }; renderSettings(); };
|
||
box.append(h("h3", {}, pname(p)));
|
||
if (S.target === "auto") box.append(h("p", {}, t("set.autoUses")));
|
||
|
||
const name = p.custom ? h("input", { class: "plain", id: "set-name", value: p.name, autocomplete: "off" }) : null;
|
||
const base = h("input", { id: "set-url", value: p.base_url, placeholder: t("set.urlPh"), spellcheck: "false", autocomplete: "off" });
|
||
const model = h("input", { id: "set-model", value: p.default_model || "", placeholder: t("set.modelPh"), spellcheck: "false", autocomplete: "off" });
|
||
|
||
/* 密钥:没有 → 添加;已有 → 修改 / 删除 */
|
||
const keyCell = h("div", { class: "keyline" });
|
||
let keyInput = null;
|
||
const saveKey = async () => {
|
||
if (!keyInput.value.trim()) return say("bad", t("set.keyEmpty"));
|
||
const had = p.has_key && !p.key_env;
|
||
try { await postSettings({ action: "save", provider: p.id, api_key: keyInput.value.trim() }); settingsDone(had ? t("set.keyChanged") : t("set.keyAdded")); }
|
||
catch (e) { settingsFail(e); }
|
||
};
|
||
const mask = "····" + (p.key_hint || "");
|
||
if (p.key_env) {
|
||
keyCell.append(h("span", { class: "mask" }, mask), h("span", { class: "none" }, t("set.keyEnv", { name: p.key_env })));
|
||
} else if (p.has_key && S.confirm === "key") {
|
||
keyCell.append(h("span", { class: "mask" }, mask),
|
||
h("button", { class: "btn small danger", type: "button", onclick: async () => {
|
||
try { await postSettings({ action: "delete_key", provider: p.id }); settingsDone(t("set.keyDeleted")); } catch (e) { settingsFail(e); }
|
||
} }, t("set.confirmDelKey")),
|
||
h("button", { class: "btn small", type: "button", onclick: () => { S.confirm = null; renderSettings(); } }, t("set.cancel")));
|
||
} else if (p.has_key && !S.keyEdit) {
|
||
keyCell.append(h("span", { class: "mask" }, mask),
|
||
h("button", { class: "btn small", type: "button", onclick: () => { S.keyEdit = true; S.confirm = null; S.setMsg = null; renderSettings(); const el = $("#set-key"); if (el) el.focus(); } }, t("set.editKey")),
|
||
h("button", { class: "btn small danger", type: "button", onclick: () => { S.confirm = "key"; S.setMsg = null; renderSettings(); } }, t("set.delKey")));
|
||
} else {
|
||
keyInput = h("input", { id: "set-key", type: "password", autocomplete: "off",
|
||
placeholder: p.has_key ? t("set.keyPhEdit") : (p.key_optional ? t("set.keyOptionalPh") : t("set.keyPh")),
|
||
onkeydown: e => { if (e.key === "Enter") saveKey(); } });
|
||
keyCell.append(keyInput, h("button", { class: "btn small primary", type: "button", onclick: saveKey }, p.has_key ? t("set.saveKey") : t("set.addKey")));
|
||
if (p.has_key) keyCell.append(h("button", { class: "btn small", type: "button", onclick: () => { S.keyEdit = false; renderSettings(); } }, t("set.cancel")));
|
||
}
|
||
|
||
const grid = h("div", { class: "set-grid" });
|
||
if (name) grid.append(h("label", { for: "set-name" }, t("set.name")), name);
|
||
grid.append(h("label", { for: "set-url" }, t("set.url")), base,
|
||
h("label", { for: "set-model" }, t("set.model")), model,
|
||
h("label", { for: keyInput ? "set-key" : null }, t("set.key")), keyCell);
|
||
box.append(grid);
|
||
|
||
const saveSettings = async () => {
|
||
const body = { action: "save", provider: p.id, base_url: base.value, default_model: model.value };
|
||
if (name) body.name = name.value;
|
||
if (keyInput && keyInput.value.trim()) body.api_key = keyInput.value.trim();
|
||
try { await postSettings(body); settingsDone(t("set.saved")); } catch (e) { settingsFail(e); }
|
||
};
|
||
const test = async () => {
|
||
say("", t("set.testing"));
|
||
try {
|
||
const resp = await api("/_wb/models?target=" + encodeURIComponent(p.id), { cache: "no-store" });
|
||
const data = await resp.json();
|
||
if (data.errors && data.errors[p.id]) return say("bad", t("set.connFail", { msg: data.errors[p.id] }));
|
||
const ids = (data.models || []).map(m => m.id);
|
||
say("ok", t("set.connOk", { n: ids.length }) + (ids.length ? t("sep.colon") + ids.slice(0, 8).join(t("sep.list")) + (ids.length > 8 ? " …" : "") : ""));
|
||
} catch (e) { say("bad", t("set.testFail", { msg: e.message })); }
|
||
};
|
||
const actions = h("div", { class: "set-actions" },
|
||
h("button", { class: "btn small", type: "button", onclick: saveSettings }, t("set.saveSettings")),
|
||
h("button", { class: "btn small", type: "button", onclick: test }, t("set.test")));
|
||
if (p.custom) {
|
||
actions.append(h("span", { class: "spacer" }));
|
||
if (S.confirm === "provider") {
|
||
actions.append(h("button", { class: "btn small danger", type: "button", onclick: async () => {
|
||
try { await postSettings({ action: "delete", provider: p.id }); setTarget("auto"); settingsDone(t("set.provDeleted")); } catch (e) { settingsFail(e); }
|
||
} }, t("set.confirmDelProv", { name: pname(p) })),
|
||
h("button", { class: "btn small", type: "button", onclick: () => { S.confirm = null; renderSettings(); } }, t("set.cancel")));
|
||
} else {
|
||
actions.append(h("button", { class: "btn small danger", type: "button", onclick: () => { S.confirm = "provider"; S.setMsg = null; renderSettings(); } }, t("set.delProv")));
|
||
}
|
||
}
|
||
box.append(actions);
|
||
box.append(h("div", { class: "prov-msg " + ((S.setMsg && S.setMsg.cls) || "") }, (S.setMsg && S.setMsg.text) || ""));
|
||
box.append(h("p", { class: "fine" }, t("set.note")));
|
||
if (S.target === "auto") box.append(h("p", { class: "fine" }, t("set.rule")));
|
||
}
|
||
|
||
/* ---------- 请求体 <-> 界面 ---------- */
|
||
function questionFromSpec(id, spec) {
|
||
spec = spec && typeof spec === "object" ? spec : {};
|
||
const type = ["noul", "choice", "score"].includes(spec.type) ? spec.type : "noul";
|
||
const q = { uid: uid(), id: id, type: type, instructions: String(spec.instructions || ""), options: [], levels: [], extra: {} };
|
||
for (const key in spec) {
|
||
if (key === "type" || key === "instructions") continue;
|
||
if (key === "criteria" && type === "choice" && spec.criteria && typeof spec.criteria === "object") {
|
||
if (Array.isArray(spec.criteria)) q.options = spec.criteria.map(k => ({ key: String(k), desc: "" }));
|
||
else q.options = Object.keys(spec.criteria).map(k => ({ key: k, desc: String(spec.criteria[k] == null ? "" : spec.criteria[k]) }));
|
||
} else if (key === "criteria" && type === "score" && Array.isArray(spec.criteria)) {
|
||
q.levels = spec.criteria.map(v => typeof v === "string" ? v : JSON.stringify(v));
|
||
} else {
|
||
q.extra[key] = spec[key];
|
||
}
|
||
}
|
||
return q;
|
||
}
|
||
function newQuestion(type) {
|
||
const taken = new Set(S.questions.map(q => q.id));
|
||
let n = S.questions.length + 1;
|
||
while (taken.has("q" + n)) n++;
|
||
const q = { uid: uid(), id: "q" + n, type: type, instructions: "", options: [], levels: [], extra: {} };
|
||
if (type === "choice") q.options = [{ key: "a", desc: "" }, { key: "b", desc: "" }];
|
||
if (type === "score") q.levels = ["", "", ""];
|
||
return q;
|
||
}
|
||
function isMessageList(value) {
|
||
return Array.isArray(value) && value.length > 0 &&
|
||
value.every(m => m && typeof m === "object" && typeof m.content === "string" && typeof m.role === "string");
|
||
}
|
||
function loadRequest(body) {
|
||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error(t("e.top"));
|
||
if (!body.questions || typeof body.questions !== "object" || Array.isArray(body.questions)) throw new Error(t("e.noQ"));
|
||
if (Array.isArray(body.states)) {
|
||
S.mode = "batch";
|
||
S.batch = body.states.map(s => typeof s === "string" ? s.replace(/\r?\n/g, " ") : JSON.stringify(s)).join("\n");
|
||
} else {
|
||
S.mode = "single";
|
||
const st = body.state;
|
||
if (typeof st === "string") { S.stateTab = "text"; S.text = st; }
|
||
else if (isMessageList(st)) { S.stateTab = "messages"; S.messages = st.map(m => ({ role: m.role, content: m.content })); }
|
||
else if (st != null) { S.stateTab = "json"; S.json = pretty(st); }
|
||
else { S.stateTab = "text"; S.text = ""; }
|
||
}
|
||
S.questions = Object.keys(body.questions).map(id => questionFromSpec(id, body.questions[id]));
|
||
S.model = body.model == null ? "" : String(body.model);
|
||
fitModelToTarget();
|
||
S.minConf = body.min_confidence == null ? "" : String(body.min_confidence);
|
||
S.maxLen = body.max_len == null ? "" : String(body.max_len);
|
||
S.exampleFile = null; S.exampleSnap = "";
|
||
renderAll();
|
||
}
|
||
function resetAll() {
|
||
S.mode = "single"; S.stateTab = "text"; S.text = ""; S.json = "{\n \n}";
|
||
S.messages = [{ role: "user", content: "" }]; S.batch = ""; S.questions = [];
|
||
S.minConf = ""; S.maxLen = ""; S.last = null;
|
||
S.exampleFile = null; S.exampleSnap = ""; S.note = "";
|
||
renderAll();
|
||
}
|
||
|
||
function batchStates() {
|
||
return S.batch.split(/\r?\n/).map(s => s.trim()).filter(Boolean).map(line => {
|
||
if (line[0] === "{" || line[0] === "[") { try { return JSON.parse(line); } catch (e) { return line; } }
|
||
return line;
|
||
});
|
||
}
|
||
function buildRequest() {
|
||
const problems = [];
|
||
const body = {};
|
||
const dest = resolved();
|
||
if (S.model.trim()) body.model = S.model.trim();
|
||
else if (!dest.local) body.model = dest.default_model || "jev-latest";
|
||
if (!usable(dest)) problems.push(t("p.key", { name: pname(dest) }));
|
||
if (dest.local && S.status && !S.status.ready) {
|
||
if (S.status.service === "disabled") problems.push(t("p.localDisabled"));
|
||
else if (S.status.service === "exited") problems.push(t("p.localExited"));
|
||
}
|
||
|
||
if (S.mode === "batch") {
|
||
const states = batchStates();
|
||
if (!states.length) problems.push(t("p.batchEmpty"));
|
||
else if (states.length > 64) problems.push(t("p.batchMax", { n: states.length }));
|
||
body.states = states;
|
||
} else if (S.stateTab === "text") {
|
||
if (!S.text.trim()) problems.push(t("p.stateEmpty"));
|
||
body.state = S.text;
|
||
} else if (S.stateTab === "json") {
|
||
try {
|
||
body.state = JSON.parse(S.json);
|
||
if (body.state == null || (typeof body.state === "object" && !Object.keys(body.state).length)) problems.push(t("p.stateEmpty"));
|
||
} catch (e) {
|
||
problems.push(t("p.badJson", { msg: e.message }));
|
||
body.state = S.json;
|
||
}
|
||
} else {
|
||
const msgs = S.messages.filter(m => m.content.trim()).map(m => ({ role: m.role, content: m.content }));
|
||
if (!msgs.length) problems.push(t("p.msgEmpty"));
|
||
body.state = msgs;
|
||
}
|
||
|
||
body.questions = {};
|
||
const seen = new Set();
|
||
if (!S.questions.length) problems.push(t("p.noQuestions"));
|
||
S.questions.forEach((q, i) => {
|
||
const id = q.id.trim();
|
||
const name = id || t("p.nth", { n: i + 1 });
|
||
if (!id) problems.push(t("p.noId", { n: i + 1 }));
|
||
else if (seen.has(id)) problems.push(t("p.dupId", { id: id }));
|
||
seen.add(id);
|
||
if (!q.instructions.trim()) problems.push(t("p.noText", { name: name }));
|
||
const spec = Object.assign({ type: q.type, instructions: q.instructions }, q.extra);
|
||
if (q.type === "choice") {
|
||
const keys = q.options.map(o => o.key.trim());
|
||
if (keys.filter(Boolean).length < 2) problems.push(t("p.opt2", { name: name }));
|
||
else if (keys.some(k => !k)) problems.push(t("p.optBlank", { name: name }));
|
||
else if (new Set(keys).size !== keys.length) problems.push(t("p.optDup", { name: name }));
|
||
spec.criteria = {};
|
||
q.options.forEach(o => { if (o.key.trim()) spec.criteria[o.key.trim()] = o.desc; });
|
||
} else if (q.type === "score") {
|
||
if (q.levels.length < 2) problems.push(t("p.lvl2", { name: name }));
|
||
else if (q.levels.some(v => !v.trim())) problems.push(t("p.lvlBlank", { name: name }));
|
||
spec.criteria = q.levels.slice();
|
||
}
|
||
body.questions[id || "q" + (i + 1)] = spec;
|
||
});
|
||
|
||
if (S.minConf.trim()) {
|
||
const v = Number(S.minConf);
|
||
if (!isFinite(v) || v < 0 || v > 1) problems.push(t("p.minConf"));
|
||
else if (dest.local) body.min_confidence = v;
|
||
}
|
||
if (S.maxLen.trim()) {
|
||
const v = Number(S.maxLen);
|
||
if (!Number.isInteger(v) || v <= 0) problems.push(t("p.maxLen"));
|
||
else if (dest.local) body.max_len = v;
|
||
}
|
||
return { endpoint: S.mode === "batch" ? "/v1/systemone/batch" : "/v1/systemone", body: body, problems: problems, dest: dest };
|
||
}
|
||
function editorSnapshot() {
|
||
const body = buildRequest().body;
|
||
return JSON.stringify([body.states !== undefined ? body.states : body.state, body.questions]);
|
||
}
|
||
|
||
/* ---------- 渲染:素材 ---------- */
|
||
function renderState() {
|
||
const box = $("#state-body");
|
||
box.textContent = "";
|
||
$("#state-tabs").hidden = S.mode === "batch";
|
||
$("#state-title").textContent = S.mode === "batch" ? t("state.titleBatch") : t("state.title");
|
||
for (const b of $("#state-tabs").children) b.setAttribute("aria-pressed", String(b.dataset.tab === S.stateTab));
|
||
|
||
if (S.mode === "batch") {
|
||
box.append(h("textarea", { id: "state-batch", value: S.batch, placeholder: t("state.phBatch"), spellcheck: "false",
|
||
oninput: e => { S.batch = e.target.value; refresh(); } }));
|
||
} else if (S.stateTab === "text") {
|
||
box.append(h("textarea", { id: "state-text", value: S.text, placeholder: t("state.phText"),
|
||
oninput: e => { S.text = e.target.value; refresh(); } }));
|
||
} else if (S.stateTab === "json") {
|
||
box.append(h("textarea", { id: "state-json", value: S.json, spellcheck: "false", placeholder: '{"subject": "…", "body": "…"}',
|
||
oninput: e => { S.json = e.target.value; refresh(); } }));
|
||
} else {
|
||
S.messages.forEach((m, i) => {
|
||
const role = h("select", { "aria-label": t("msg.role"), onchange: e => { m.role = e.target.value; refresh(); } },
|
||
h("option", { value: "user" }, "user"), h("option", { value: "assistant" }, "assistant"), h("option", { value: "system" }, "system"));
|
||
if (!["user", "assistant", "system"].includes(m.role)) role.append(h("option", { value: m.role }, m.role));
|
||
role.value = m.role;
|
||
box.append(h("div", { class: "msg" }, role,
|
||
h("textarea", { rows: String(Math.min(8, Math.max(2, Math.ceil(m.content.length / (LANG === "zh" ? 26 : 52))))), value: m.content, placeholder: t("msg.ph"),
|
||
oninput: e => { m.content = e.target.value; refresh(); } }),
|
||
h("button", { class: "x", type: "button", title: t("msg.del"), "aria-label": t("msg.del"),
|
||
onclick: () => { S.messages.splice(i, 1); if (!S.messages.length) S.messages.push({ role: "user", content: "" }); renderState(); refresh(); } }, "×")));
|
||
});
|
||
box.append(h("button", { class: "btn small", type: "button", onclick: () => {
|
||
const lastRole = S.messages.length ? S.messages[S.messages.length - 1].role : "assistant";
|
||
S.messages.push({ role: lastRole === "user" ? "assistant" : "user", content: "" });
|
||
renderState(); refresh();
|
||
} }, t("msg.add")));
|
||
}
|
||
}
|
||
|
||
/* ---------- 渲染:题目 ---------- */
|
||
function renderQuestions() {
|
||
const box = $("#questions");
|
||
box.textContent = "";
|
||
if (!S.questions.length) {
|
||
box.append(h("div", { class: "empty" }, t("q.empty")));
|
||
return;
|
||
}
|
||
S.questions.forEach((q, qi) => {
|
||
const card = h("div", { class: "q" });
|
||
card.append(h("div", { class: "q-top" },
|
||
h("span", { class: "badge " + q.type }, t("type." + q.type)),
|
||
h("input", { value: q.id, placeholder: t("q.idPh"), "aria-label": t("q.idAria"), spellcheck: "false",
|
||
oninput: e => { q.id = e.target.value; refresh(); } }),
|
||
h("button", { class: "x", type: "button", title: t("q.del"), "aria-label": t("q.del"),
|
||
onclick: () => { S.questions.splice(qi, 1); renderQuestions(); refresh(); } }, "×")));
|
||
card.append(h("textarea", { rows: "2", value: q.instructions, "aria-label": t("q.aria"),
|
||
placeholder: q.type === "noul" ? t("q.phNoul") : q.type === "choice" ? t("q.phChoice") : t("q.phScore"),
|
||
oninput: e => { q.instructions = e.target.value; refresh(); } }));
|
||
|
||
if (q.type === "choice") {
|
||
const opts = h("div", { class: "opts" });
|
||
q.options.forEach((o, oi) => {
|
||
opts.append(h("div", { class: "opt" },
|
||
h("input", { class: "key", value: o.key, placeholder: t("opt.key"), "aria-label": t("opt.keyAria"), spellcheck: "false", oninput: e => { o.key = e.target.value; refresh(); } }),
|
||
h("input", { value: o.desc, placeholder: t("opt.desc"), "aria-label": t("opt.descAria"), oninput: e => { o.desc = e.target.value; refresh(); } }),
|
||
h("button", { class: "x", type: "button", title: t("opt.del"), "aria-label": t("opt.del"), onclick: () => { q.options.splice(oi, 1); renderQuestions(); refresh(); } }, "×")));
|
||
});
|
||
opts.append(h("button", { class: "btn small", type: "button", onclick: () => { q.options.push({ key: "", desc: "" }); renderQuestions(); refresh(); } }, t("opt.add")));
|
||
card.append(opts);
|
||
} else if (q.type === "score") {
|
||
const opts = h("div", { class: "opts" });
|
||
q.levels.forEach((text, li) => {
|
||
opts.append(h("div", { class: "opt level" },
|
||
h("span", { class: "idx" }, String(li)),
|
||
h("input", { value: text, placeholder: li === 0 ? t("lvl.ph0") : t("lvl.ph"), "aria-label": t("lvl.aria", { n: li }), oninput: e => { q.levels[li] = e.target.value; refresh(); } }),
|
||
h("button", { class: "x", type: "button", title: t("lvl.del"), "aria-label": t("lvl.del"), onclick: () => { q.levels.splice(li, 1); renderQuestions(); refresh(); } }, "×")));
|
||
});
|
||
opts.append(h("button", { class: "btn small", type: "button", onclick: () => { q.levels.push(""); renderQuestions(); refresh(); } }, t("lvl.add")));
|
||
card.append(opts);
|
||
}
|
||
box.append(card);
|
||
});
|
||
}
|
||
|
||
/* ---------- 渲染:结果区 ---------- */
|
||
function barRow(name, value, win, half) {
|
||
const fill = h("i");
|
||
fill.style.width = Math.max(0, Math.min(100, Number(value) * 100)).toFixed(1) + "%";
|
||
return h("div", { class: "row" + (win ? " win" : "") },
|
||
h("span", { class: "name", title: name }, name),
|
||
h("span", { class: "bar" + (half ? " half" : "") }, fill),
|
||
h("span", { class: "pct" }, pct(value)));
|
||
}
|
||
function confLine(a) {
|
||
const parts = [];
|
||
if (a.answer_confidence != null) parts.push(t("r.conf", { v: Number(a.answer_confidence).toFixed(3) }));
|
||
if (a.abstention) parts.push(t("r.gate", { v: ["passed", "abstained", "unevaluated"].includes(a.abstention) ? t("gate." + a.abstention) : a.abstention }));
|
||
return parts.length ? h("div", { class: "conf" }, parts.join(t("sep.gap"))) : null;
|
||
}
|
||
function answerCard(qid, a, spec) {
|
||
const type = a && a.type ? a.type : (a && "choice" in a ? "choice" : a && "score" in a ? "score" : "noul");
|
||
const card = h("div", { class: "ans" + (a.low_confidence ? " low" : "") });
|
||
card.append(h("div", { class: "ans-head" },
|
||
h("span", { class: "badge " + type }, ["noul", "choice", "score"].includes(type) ? t("type." + type) : type), h("code", {}, qid),
|
||
a.low_confidence ? h("span", { class: "flag" }, t("r.lowConf")) : null));
|
||
if (spec && spec.instructions) card.append(h("div", { class: "ans-q" }, spec.instructions));
|
||
|
||
if (type === "choice") {
|
||
const crit = spec && spec.criteria && !Array.isArray(spec.criteria) ? spec.criteria : {};
|
||
card.append(h("div", { class: "verdict" }, h("strong", {}, String(a.choice)), crit[a.choice] ? h("span", {}, crit[a.choice]) : null));
|
||
const probs = a.probabilities || {};
|
||
Object.keys(probs).forEach(k => card.append(barRow(k, probs[k], k === a.choice)));
|
||
} else if (type === "score") {
|
||
const probs = a.probabilities || {};
|
||
const keys = Object.keys(probs);
|
||
const max = Math.max(1, keys.length - 1);
|
||
const nearest = String(Math.round(Number(a.score)));
|
||
const legend = a.legend || {};
|
||
card.append(h("div", { class: "verdict" }, h("strong", {}, Number(a.score).toFixed(2)),
|
||
h("span", {}, "/ " + max + (legend[nearest] ? t("sep.gap") + t("r.nearest", { text: legend[nearest] }) : ""))));
|
||
keys.forEach(k => card.append(barRow(k + " " + (legend[k] || ""), probs[k], k === nearest)));
|
||
} else {
|
||
const yes = Number(a.noul) >= 0.5;
|
||
card.append(h("div", { class: "verdict" }, h("strong", {}, yes ? t("r.yes") : t("r.no")), h("span", {}, t("r.yesProb", { p: pct(a.noul) }))));
|
||
card.append(barRow(t("r.yes"), a.noul, yes, true));
|
||
}
|
||
const conf = confLine(a);
|
||
if (conf) card.append(conf);
|
||
return card;
|
||
}
|
||
function runProviderName(run) {
|
||
if (!run.providerId) return "";
|
||
return run.providerId === "local" ? t("t.local") : (pname(providerById(run.providerId)) || run.providerId);
|
||
}
|
||
function runTrail(run) {
|
||
if (!run.tried || run.tried.length < 2) return "";
|
||
return run.tried.map((item, i) => {
|
||
const name = pname(providerById(item[0])) || item[0];
|
||
const failed = i < run.tried.length - 1 || !run.ok;
|
||
return failed ? t("trail.fail", { name: name, why: item[1] === "0" ? t("trail.unreach") : "HTTP " + item[1] }) : t("trail.ok", { name: name });
|
||
}).join(" → ");
|
||
}
|
||
function metaLine(run, result) {
|
||
const meta = h("div", { class: "meta" });
|
||
const name = runProviderName(run);
|
||
if (name) meta.append(h("span", {}, t("m.iface", { v: name })));
|
||
if (result && result.model) meta.append(h("span", {}, t("m.model", { v: result.model })));
|
||
if (run.inferMs != null) meta.append(h("span", {}, t("m.infer", { v: Number(run.inferMs).toFixed(1) })));
|
||
meta.append(h("span", {}, t("m.round", { v: Math.round(run.ms) })));
|
||
const usage = result && (result.usage || result.total_usage);
|
||
if (usage && usage.input_tokens != null) meta.append(h("span", {}, t("m.tokens", { v: usage.input_tokens })));
|
||
if (result && result.routing && result.routing.reason) meta.append(h("span", { class: "reason" }, t("m.route", { v: result.routing.reason })));
|
||
const trail = runTrail(run);
|
||
if (trail) meta.append(h("span", { class: "reason" }, trail));
|
||
if (run.localGate) meta.append(h("span", { class: "reason" }, t("m.localGate")));
|
||
return meta;
|
||
}
|
||
function cellFor(a) {
|
||
if (!a) return h("td", { class: "cell" }, "-");
|
||
let text, small, yes = false;
|
||
if ("choice" in a) { text = String(a.choice); small = a.answer_confidence != null ? pct(a.answer_confidence) : ""; }
|
||
else if ("score" in a) { text = Number(a.score).toFixed(2); small = ""; }
|
||
else { yes = Number(a.noul) >= 0.5; text = yes ? t("r.yes") : t("r.no"); small = pct(a.noul); }
|
||
return h("td", { class: "cell" + (a.low_confidence ? " lowc" : yes ? " yes" : "") }, text, small ? h("small", {}, small) : null);
|
||
}
|
||
function renderResultTab(panel) {
|
||
const run = S.last;
|
||
if (S.running) { panel.append(h("div", { class: "placeholder" }, t("r.running"))); return; }
|
||
if (S.waiting) {
|
||
panel.append(h("div", { class: "placeholder" }, t("r.waiting1"), h("br"), h("small", {}, t("r.waiting2"))));
|
||
return;
|
||
}
|
||
if (!run) { panel.append(h("div", { class: "placeholder" }, t("r.empty"))); return; }
|
||
if (!run.ok) {
|
||
const b = run.body;
|
||
let detail = run.netError ? t("e.net", { msg: run.netError }) : (run.error || "");
|
||
if (b && typeof b === "object") {
|
||
const d = b.detail != null ? b.detail : (b.error && b.error.message != null ? b.error.message : (b.error != null ? b.error : b.message));
|
||
detail = d == null ? pretty(b) : (typeof d === "string" ? d : pretty(d));
|
||
if (b._failed_state_index != null) detail = t("r.failedItem", { n: b._failed_state_index + 1, msg: detail });
|
||
}
|
||
let tip = "";
|
||
if (run.remote) {
|
||
if (run.status === 401 || run.status === 403) tip = t("tip.remoteAuth");
|
||
else if (run.status === 404) tip = t("tip.remote404");
|
||
else if (run.status === 429) tip = t("tip.remote429");
|
||
else if (run.status === 502 || run.status === 0) tip = t("tip.remoteNet");
|
||
} else if (run.status === 503) tip = t("tip.local503");
|
||
else if (run.status === 401) tip = t("tip.local401");
|
||
else if (run.status === 500) tip = t("tip.local500");
|
||
if (!tip && (run.status === 422 || run.status === 400)) tip = t("tip.bad");
|
||
const name = runProviderName(run);
|
||
panel.append(h("div", { class: "errbox" }, (name ? t("r.failAt", { name: name }) : t("r.fail")) + (run.status ? " (HTTP " + run.status + ")" : "") + "\n" + detail + (tip ? "\n\n" + tip : "")));
|
||
const trail = runTrail(run);
|
||
if (trail) panel.append(h("div", { class: "conf" }, trail));
|
||
return;
|
||
}
|
||
const specs = run.request.questions || {};
|
||
if (Array.isArray(run.body.results)) {
|
||
panel.append(metaLine(run, run.body));
|
||
const qids = Object.keys(specs);
|
||
const table = h("table", {}, h("thead", {}, h("tr", {}, h("th", {}, "#"), h("th", {}, t("th.material")),
|
||
...qids.map(id => h("th", { title: specs[id].instructions || "" }, h("code", {}, id))))));
|
||
const tbody = h("tbody");
|
||
run.body.results.forEach((res, i) => {
|
||
const st = run.request.states[i];
|
||
tbody.append(h("tr", {}, h("td", {}, String(i + 1)),
|
||
h("td", { class: "state" }, typeof st === "string" ? st : JSON.stringify(st)),
|
||
...qids.map(id => cellFor((res.answers || {})[id]))));
|
||
});
|
||
table.append(tbody);
|
||
panel.append(h("div", { class: "table-wrap" }, table));
|
||
panel.append(h("div", { class: "conf" }, t("r.batchNote")));
|
||
} else {
|
||
panel.append(metaLine(run, run.body));
|
||
const answers = run.body.answers || {};
|
||
if (!Object.keys(answers).length) panel.append(h("div", { class: "placeholder" }, t("r.noAnswers")));
|
||
Object.keys(answers).forEach(id => panel.append(answerCard(id, answers[id], specs[id])));
|
||
}
|
||
}
|
||
function snippetBody(req) {
|
||
if (req.dest.local || !req.endpoint.endsWith("/batch")) return req.body;
|
||
const body = Object.assign({}, req.body, { state: (req.body.states || [])[0] });
|
||
delete body.states;
|
||
return body;
|
||
}
|
||
function snippetText(req) {
|
||
const dest = req.dest;
|
||
const remote = !dest.local;
|
||
const base = remote ? dest.base_url : ((S.status && S.status.laya_url) || "http://127.0.0.1:8000");
|
||
const auth = remote ? dest.has_key : (S.status && S.status.auth);
|
||
const split = remote && req.endpoint.endsWith("/batch");
|
||
const endpoint = split ? "/v1/systemone" : req.endpoint;
|
||
const note = split ? t("sn.noBatch", { name: pname(dest) }) : "";
|
||
const url = base + endpoint;
|
||
const json = pretty(snippetBody(req));
|
||
const key = t("sn.key");
|
||
if (S.snippet === "curl") {
|
||
return (note ? "# " + note + "\n" : "") + "# " + t("sn.curl1") + "\n# " + t("sn.curl2") + "\n" +
|
||
'curl.exe -s ' + url + ' -H "Content-Type: application/json"' +
|
||
(auth ? ' -H "Authorization: Bearer ' + key + '"' : "") + ' --data-binary "@request.json"';
|
||
}
|
||
if (S.snippet === "python") {
|
||
return (note ? "# " + note + "\n" : "") + 'import json\nimport urllib.request\n\npayload = json.loads(r"""\n' + json + '\n""")\n\n' +
|
||
'req = urllib.request.Request(\n "' + url + '",\n data=json.dumps(payload).encode("utf-8"),\n' +
|
||
' headers={"Content-Type": "application/json"' + (auth ? ', "Authorization": "Bearer ' + key + '"' : "") +
|
||
(remote ? ', "User-Agent": "laya-opencv"' : "") + '},\n)\n' +
|
||
(remote ? "opener = urllib.request.build_opener()\n" : "opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) # " + t("sn.noProxy") + "\n") +
|
||
'with opener.open(req, timeout=60) as resp:\n result = json.loads(resp.read().decode("utf-8"))\n\n' +
|
||
(endpoint.endsWith("/batch")
|
||
? 'for state, item in zip(payload["states"], result["results"]):\n print(state)\n for qid, answer in item["answers"].items():\n print(" ", qid, answer)\n'
|
||
: 'for qid, answer in result["answers"].items():\n print(qid, answer)\n');
|
||
}
|
||
return (note ? "// " + note + "\n" : "") + "// " + t("sn.js") + "\n" +
|
||
'const resp = await fetch("' + url + '", {\n method: "POST",\n headers: { "Content-Type": "application/json"' +
|
||
(auth ? ', "Authorization": "Bearer ' + key + '"' : "") + " },\n body: JSON.stringify(" + json.replace(/\n/g, "\n ") + "),\n});\n" +
|
||
"const result = await resp.json();\nconsole.log(result" + (endpoint.endsWith("/batch") ? ".results" : ".answers") + ");\n";
|
||
}
|
||
function renderPanel() {
|
||
const panel = $("#panel");
|
||
panel.textContent = "";
|
||
for (const b of $("#tabs").children) b.setAttribute("aria-selected", String(b.dataset.tab === S.tab));
|
||
const req = buildRequest();
|
||
|
||
if (S.tab === "result") return renderResultTab(panel);
|
||
if (S.tab === "raw") {
|
||
if (!S.last) { panel.append(h("div", { class: "placeholder" }, t("raw.empty"))); return; }
|
||
const text = S.last.raw != null ? pretty(S.last.raw) : (S.last.netError ? t("e.net", { msg: S.last.netError }) : (S.last.error || ""));
|
||
panel.append(h("div", { class: "tools" }, h("button", { class: "btn small", type: "button", onclick: () => copyText(text) }, t("btn.copy"))));
|
||
panel.append(h("pre", { class: "code" }, text));
|
||
return;
|
||
}
|
||
if (S.tab === "request") {
|
||
const text = pretty(req.body);
|
||
panel.append(h("div", { class: "tools" },
|
||
h("button", { class: "btn small", type: "button", onclick: () => copyText(text) }, t("btn.copy")),
|
||
h("button", { class: "btn small", type: "button", onclick: () => download("request.json", text) }, t("btn.download")),
|
||
h("code", {}, "POST " + req.endpoint + " → " + pname(req.dest))));
|
||
panel.append(h("pre", { class: "code" }, text));
|
||
return;
|
||
}
|
||
if (S.tab === "code") {
|
||
const seg = h("div", { class: "seg" });
|
||
[["curl", "curl"], ["python", "Python"], ["js", "JavaScript"]].forEach(pair => {
|
||
seg.append(h("button", { type: "button", "aria-pressed": String(S.snippet === pair[0]), onclick: () => { S.snippet = pair[0]; renderPanel(); } }, pair[1]));
|
||
});
|
||
const text = snippetText(req);
|
||
panel.append(h("div", { class: "tools" }, seg,
|
||
h("button", { class: "btn small", type: "button", onclick: () => copyText(text) }, t("btn.copy")),
|
||
S.snippet === "curl" ? h("button", { class: "btn small", type: "button", onclick: () => download("request.json", pretty(snippetBody(req))) }, t("btn.download")) : null));
|
||
panel.append(h("pre", { class: "code" }, text));
|
||
return;
|
||
}
|
||
if (!S.history.length) { panel.append(h("div", { class: "placeholder" }, t("h.empty"))); return; }
|
||
panel.append(h("div", { class: "tools" }, h("button", { class: "btn small", type: "button",
|
||
onclick: () => { S.history = []; saveHistory(); renderPanel(); } }, t("h.clear")), h("span", { class: "hint" }, t("h.tip"))));
|
||
S.history.forEach(item => {
|
||
panel.append(h("button", { class: "hist", type: "button", onclick: () => { try { loadRequest(item.request); S.note = ""; refresh(); toast(t("h.loaded")); } catch (e) { toast(e.message); } } },
|
||
h("span", {}, item.title), h("span", { class: "t" + (item.ok ? "" : " bad") }, (item.ok ? Math.round(item.ms) + " ms" : t("h.failed")) + t("sep.gap") + item.time),
|
||
h("span", { class: "s" }, item.summary)));
|
||
});
|
||
}
|
||
function renderDebug() {
|
||
$("#debug-count").textContent = "(" + S.debug.length + ")";
|
||
const box = $("#debug-body");
|
||
box.textContent = "";
|
||
S.debug.forEach(d => {
|
||
box.append(h("div", { class: "dbg" },
|
||
h("h3", {}, d.time + " " + d.provider + " POST " + d.endpoint + " → " + (d.status || t("d.netErr")) + " " + Math.round(d.ms) + " ms"),
|
||
h("div", { class: "dbg-cols" }, h("pre", { class: "code" }, pretty(d.request)), h("pre", { class: "code" }, d.response))));
|
||
});
|
||
}
|
||
|
||
/* ---------- 顶部状态 ---------- */
|
||
function renderStatus() {
|
||
const st = S.status, dot = $("#status-dot"), text = $("#status-text");
|
||
if (st === undefined) { dot.className = "dot"; text.textContent = t("s.connecting"); return; }
|
||
if (!st) { dot.className = "dot bad"; text.textContent = t("s.offline"); return; }
|
||
const dest = resolved();
|
||
if (!dest.local) {
|
||
dot.className = usable(dest) ? "dot ok" : "dot wait";
|
||
text.textContent = pname(dest) + t("sep.gap") + (dest.has_key ? t("s.keySet") + (dest.key_hint ? " ····" + dest.key_hint : "") : dest.key_optional ? t("s.keyOptional") : t("s.keyMissing"));
|
||
} else if (st.ready) {
|
||
const hl = st.health || {};
|
||
dot.className = "dot ok";
|
||
text.textContent = t("s.localReady") + (hl.device ? t("sep.gap") + hl.device : "") + (hl.loaded && hl.loaded.length ? t("sep.gap") + hl.loaded.join(", ") : "");
|
||
} else if (st.service === "disabled") {
|
||
dot.className = "dot bad"; text.textContent = t("s.localDisabled");
|
||
} else if (st.service === "exited") {
|
||
dot.className = "dot bad"; text.textContent = t("s.localExited") + (st.exit_code != null ? t("s.code", { n: st.exit_code }) : "");
|
||
} else {
|
||
dot.className = "dot wait"; text.textContent = t("s.localLoading");
|
||
}
|
||
}
|
||
function renderVersions() {
|
||
const el = $("#versions");
|
||
const u = S.status && S.status.update;
|
||
if (!u || !u.installed) { el.hidden = true; el.textContent = ""; return; }
|
||
const notes = { latest: "v.latest", upgraded: "v.upgraded", available: "v.available", failed: "v.failed", offline: "v.offline",
|
||
rolled_back: "v.rolledBack", skipped: "v.skipped", running: "v.running" };
|
||
const note = notes[u.status] ? t(notes[u.status], { v: u.latest || "", from: u.previous || "" }) : "";
|
||
const parts = ["Laya " + u.installed + (note ? t("v.wrap", { note: note }) : "")];
|
||
const revs = (S.status.health && S.status.health.revisions) || {};
|
||
const sha = Object.values(revs).find(Boolean);
|
||
if (sha) parts.push(t("v.model", { sha: String(sha).slice(0, 8) }) + (u.model_before && u.model_before !== sha ? t("v.wrap", { note: t("v.modelUpdated") }) : ""));
|
||
else if (u.model_before && u.model_latest && u.model_before !== u.model_latest) parts.push(t("v.modelPending"));
|
||
el.hidden = false;
|
||
el.textContent = parts.join(t("sep.gap"));
|
||
}
|
||
function renderHint() {
|
||
const st = S.status, el = $("#hint");
|
||
let cls = "hint", msg = "";
|
||
if (st === undefined) { msg = ""; }
|
||
else if (!st) { cls = "hint bad"; msg = t("hint.offline"); }
|
||
else {
|
||
const dest = resolved();
|
||
if (!dest.local) {
|
||
if (!usable(dest)) { cls = "hint warn"; msg = t("hint.needKey", { model: S.model.trim() || dest.default_model }); }
|
||
else {
|
||
msg = t("hint.via", { url: dest.base_url });
|
||
const backups = autoBackups();
|
||
if (backups.length) msg += t("hint.backup", { names: backups.map(pname).join(t("sep.list")) });
|
||
if (S.mode === "batch") { cls = "hint warn"; msg += t("hint.batchRemote"); }
|
||
}
|
||
} else if (st.service === "disabled") { cls = "hint bad"; msg = t("hint.localDisabled"); }
|
||
else if (!st.ready && st.service === "exited") { cls = "hint bad"; msg = t("hint.localExited"); }
|
||
else if (!st.ready) { cls = "hint warn"; msg = t("hint.loading"); }
|
||
else {
|
||
const loaded = (st.health && st.health.loaded) || [];
|
||
const model = S.model.trim().toLowerCase();
|
||
const alias = { en: "english", laya: "english", "default": "english", ml: "multilingual", multi: "multilingual", "laya-multilingual": "multilingual",
|
||
typed: "typed-decisions", typed_decisions: "typed-decisions", "laya-typed-decisions": "typed-decisions", decisions: "typed-decisions" };
|
||
const name = alias[model] || model;
|
||
if (!model) { cls = "hint warn"; msg = t("hint.autoRoute"); }
|
||
else if (!isLayaModel(model)) { cls = "hint warn"; msg = t("hint.notLaya", { model: S.model.trim() }); }
|
||
else if (loaded.length && !loaded.includes(name) && !name.includes("/")) { cls = "hint warn"; msg = t("hint.notLoaded", { name: name }); }
|
||
else msg = t("hint.addr", { url: st.laya_url });
|
||
}
|
||
}
|
||
el.className = cls; el.textContent = msg;
|
||
}
|
||
async function pollStatus() {
|
||
const first = S.status === undefined;
|
||
try {
|
||
const resp = await api("/_wb/status", { cache: "no-store" });
|
||
S.status = resp.ok ? await resp.json() : null;
|
||
} catch (e) { S.status = null; }
|
||
if (first && S.status && !LANG_CHOSEN && (S.status.language === "zh" || S.status.language === "en") && S.status.language !== LANG) {
|
||
setLang(S.status.language, false); // 没手动选过语言时,跟随 config.json / 系统语言
|
||
}
|
||
if (first) { fitModelToTarget(); $("#model").value = S.model; renderModelOptions(); }
|
||
if (document.activeElement !== $("#target")) renderTargets();
|
||
renderStatus(); refresh();
|
||
if (window.WBV) window.WBV.onStatus();
|
||
if (S.settingsOpen && first) renderSettings();
|
||
if (S.waiting) {
|
||
if (S.status && S.status.ready) { S.waiting = false; run(); }
|
||
else if (!S.status || S.status.service !== "starting") { S.waiting = false; refresh(); renderPanel(); }
|
||
}
|
||
clearTimeout(pollStatus.timer);
|
||
const waiting = S.status && !S.status.ready && S.status.service === "starting";
|
||
pollStatus.timer = setTimeout(pollStatus, !S.status || waiting ? 2500 : 15000);
|
||
}
|
||
|
||
/* ---------- 汇总刷新 ---------- */
|
||
function refresh() {
|
||
const req = buildRequest();
|
||
$("#q-title").textContent = t("q.title", { n: S.questions.length });
|
||
$("#endpoint").textContent = "POST " + (req.dest.local || S.mode !== "batch" ? req.endpoint : "/v1/systemone × " + batchStates().length);
|
||
const auto = $("#target").options[0];
|
||
if (auto) auto.textContent = autoLabel();
|
||
renderStatus();
|
||
let count;
|
||
if (S.mode === "batch") count = t("count.items", { n: batchStates().length });
|
||
else if (S.stateTab === "text") count = t("count.chars", { n: S.text.length });
|
||
else if (S.stateTab === "json") count = t("count.chars", { n: S.json.length });
|
||
else count = t("count.msgs", { n: S.messages.filter(m => m.content.trim()).length });
|
||
$("#state-count").textContent = count;
|
||
if (S.waiting && (!req.dest.local || req.problems.length)) S.waiting = false;
|
||
$("#problem").textContent = S.running ? "" : S.waiting ? t("run.waitingNote") : (req.problems[0] || "");
|
||
$("#run").disabled = S.running || req.problems.length > 0;
|
||
$("#run-label").textContent = S.running ? t("run.running") : S.waiting ? t("run.waiting") : t("run");
|
||
$("#example-note").textContent = S.note;
|
||
renderHint(); renderVersions();
|
||
if ((S.settingsOpen || S.adding) && settingsKey() !== S.setShown) { resetSettingsUi(); renderSettings(); }
|
||
if (S.tab === "request" || S.tab === "code") renderPanel();
|
||
}
|
||
function renderAll() {
|
||
renderTargets(); renderModelOptions(); renderSettings();
|
||
$("#mode").value = S.mode;
|
||
$("#model").value = S.model;
|
||
$("#min-conf").value = S.minConf;
|
||
$("#max-len").value = S.maxLen;
|
||
renderState(); renderQuestions(); refresh(); renderPanel();
|
||
}
|
||
|
||
/* ---------- 运行 ---------- */
|
||
function summarize(body) {
|
||
if (Array.isArray(body.results)) return t("h.batch", { n: body.results.length });
|
||
return Object.keys(body.answers || {}).map(id => {
|
||
const a = body.answers[id];
|
||
return id + "=" + ("choice" in a ? a.choice : "score" in a ? Number(a.score).toFixed(2) : (Number(a.noul) >= 0.5 ? t("r.yes") : t("r.no")));
|
||
}).join(t("sep.list"));
|
||
}
|
||
function gateLocally(result, min) {
|
||
Object.values((result && result.answers) || {}).forEach(a => {
|
||
if (!a || typeof a !== "object" || a.abstention) return;
|
||
if (a.answer_confidence == null) return;
|
||
a.abstention_threshold = min;
|
||
if (Number(a.answer_confidence) < min) { a.low_confidence = true; a.abstention = "abstained"; }
|
||
else a.abstention = "passed";
|
||
});
|
||
}
|
||
function fillConfidence(result) {
|
||
Object.values((result && result.answers) || {}).forEach(a => {
|
||
if (!a || typeof a !== "object" || a.answer_confidence != null) return;
|
||
if (a.probabilities && typeof a.probabilities === "object") {
|
||
const vals = Object.values(a.probabilities).map(Number).filter(isFinite);
|
||
if (vals.length) a.answer_confidence = Math.max.apply(null, vals);
|
||
} else if (a.noul != null) a.answer_confidence = Math.max(Number(a.noul), 1 - Number(a.noul));
|
||
});
|
||
}
|
||
async function run() {
|
||
const req = buildRequest();
|
||
if (S.running || req.problems.length) return;
|
||
if (req.dest.local && S.status && !S.status.ready && S.status.service === "starting") {
|
||
S.waiting = !S.waiting; // 模型没加载完:先排队,加载完由 pollStatus 触发
|
||
S.tab = "result"; refresh(); renderPanel();
|
||
if (S.waiting) pollStatus();
|
||
return;
|
||
}
|
||
S.waiting = false;
|
||
S.running = true; S.tab = "result"; refresh(); renderPanel();
|
||
const started = performance.now();
|
||
const record = { ok: false, status: 0, body: null, raw: null, error: "", netError: "", ms: 0, inferMs: null, request: req.body, endpoint: req.endpoint,
|
||
remote: !req.dest.local, providerId: req.dest.id, tried: [], localGate: false };
|
||
try {
|
||
const resp = await api(req.endpoint, { method: "POST",
|
||
headers: { "Content-Type": "application/json", "X-WB-Target": S.target }, body: JSON.stringify(req.body) });
|
||
record.status = resp.status;
|
||
const infer = resp.headers.get("X-Inference-Time-Ms");
|
||
if (infer) record.inferMs = Number(infer);
|
||
const used = resp.headers.get("X-WB-Provider");
|
||
if (used) { record.remote = used !== "local"; record.providerId = used; }
|
||
record.tried = (resp.headers.get("X-WB-Tried") || "").split(",").filter(Boolean).map(x => x.split("="));
|
||
const rawText = await resp.text();
|
||
try { record.body = JSON.parse(rawText); record.raw = JSON.parse(rawText); } catch (e) { record.error = rawText; }
|
||
record.ok = resp.ok && record.body != null;
|
||
if (record.ok) {
|
||
const items = Array.isArray(record.body.results) ? record.body.results : [record.body];
|
||
items.forEach(fillConfidence);
|
||
if (record.remote && S.minConf.trim() && isFinite(Number(S.minConf))) {
|
||
items.forEach(item => gateLocally(item, Number(S.minConf)));
|
||
record.localGate = true;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
record.netError = e.message || String(e);
|
||
}
|
||
record.ms = performance.now() - started;
|
||
S.running = false; S.last = record;
|
||
|
||
const time = new Date().toLocaleTimeString(LANG === "zh" ? "zh-CN" : "en-GB", { hour12: false });
|
||
const name = runProviderName(record);
|
||
S.debug.unshift({ time: time, endpoint: req.endpoint, provider: name, status: record.status, ms: record.ms, request: req.body,
|
||
response: record.raw != null ? pretty(record.raw) : (record.netError ? t("e.net", { msg: record.netError }) : record.error) });
|
||
S.debug = S.debug.slice(0, 20);
|
||
const first = S.mode === "batch" ? t("h.batch", { n: req.body.states.length }) :
|
||
typeof req.body.state === "string" ? req.body.state : JSON.stringify(req.body.state);
|
||
S.history.unshift({ time: time, ok: record.ok, ms: record.ms, title: first.slice(0, 60), request: req.body,
|
||
summary: name + t("sep.gap") + (record.ok ? summarize(record.body) : "HTTP " + (record.status || t("d.netErr"))) });
|
||
S.history = S.history.slice(0, 30);
|
||
saveHistory();
|
||
refresh(); renderPanel(); renderDebug();
|
||
}
|
||
|
||
/* ---------- 示例 ---------- */
|
||
function loadExample(ex) {
|
||
loadRequest(ex.request);
|
||
S.last = null;
|
||
S.exampleFile = ex.file;
|
||
S.exampleSnap = editorSnapshot();
|
||
S.note = ex.note ? t("ex.note", { title: ex.title, note: ex.note }) : "";
|
||
refresh(); renderPanel();
|
||
}
|
||
function renderExamples() {
|
||
const box = $("#example-buttons");
|
||
box.textContent = "";
|
||
S.examples.forEach(ex => {
|
||
box.append(h("button", { class: "btn", type: "button", title: ex.note,
|
||
onclick: () => { try { loadExample(ex); } catch (e) { toast(e.message); } } }, ex.title));
|
||
});
|
||
if (!S.examples.length) box.append(h("span", { class: "hint" }, t("ex.none")));
|
||
}
|
||
let exampleSeq = 0;
|
||
async function loadExamples() {
|
||
const seq = ++exampleSeq;
|
||
let list = [];
|
||
try {
|
||
const resp = await api("/_wb/examples?lang=" + LANG, { cache: "no-store" });
|
||
list = resp.ok ? await resp.json() : [];
|
||
} catch (e) { list = []; }
|
||
if (seq !== exampleSeq) return; // 期间又切了一次语言:以最新的那次为准
|
||
S.examples = list;
|
||
renderExamples();
|
||
/* 切换语言时:如果编辑区里还是没改过的示例,就换成同一个示例的另一种语言 */
|
||
const untouched = S.exampleFile && editorSnapshot() === S.exampleSnap ? S.exampleFile : null;
|
||
const same = untouched && S.examples.find(ex => ex.file === untouched);
|
||
if (same) loadExample(same);
|
||
else if (S.examples.length && !S.questions.length && !S.text) loadExample(S.examples[0]);
|
||
else if (S.exampleFile) { S.note = ""; refresh(); }
|
||
}
|
||
function setLang(lang, persist) {
|
||
LANG = lang === "en" ? "en" : "zh";
|
||
if (persist) { LANG_CHOSEN = true; try { localStorage.setItem(LANG_KEY, LANG); } catch (e) { /* 记不住也没关系 */ } }
|
||
S.setMsg = null;
|
||
applyStatic(); renderAll(); renderDebug();
|
||
loadExamples();
|
||
if (window.WBV) window.WBV.onLang();
|
||
}
|
||
|
||
/* ---------- 事件 ---------- */
|
||
$("#view").addEventListener("click", e => {
|
||
const view = e.target.dataset && e.target.dataset.view;
|
||
if (view) setView(view);
|
||
});
|
||
$("#lang").addEventListener("click", e => {
|
||
const lang = e.target.dataset && e.target.dataset.lang;
|
||
if (lang && lang !== LANG) setLang(lang, true);
|
||
});
|
||
$("#mode").addEventListener("change", e => { S.mode = e.target.value; renderState(); refresh(); });
|
||
$("#model").addEventListener("input", e => { S.model = e.target.value; refresh(); });
|
||
$("#target").addEventListener("change", e => {
|
||
resetSettingsUi();
|
||
if (e.target.value === "__add") { // 不是真正的接口:打开「添加第三方接口」表单
|
||
S.adding = true;
|
||
renderSettings();
|
||
const el = $("#add-name"); if (el) el.focus();
|
||
return;
|
||
}
|
||
S.adding = false;
|
||
setTarget(e.target.value);
|
||
if (!usable(resolved())) S.settingsOpen = true;
|
||
renderTargets(); renderModelOptions(); renderSettings(); refresh();
|
||
});
|
||
$("#toggle-settings").addEventListener("click", () => {
|
||
resetSettingsUi();
|
||
if (S.adding) { S.adding = false; S.settingsOpen = false; renderTargets(); }
|
||
else S.settingsOpen = !S.settingsOpen;
|
||
renderSettings();
|
||
});
|
||
$("#refresh-models").addEventListener("click", refreshModels);
|
||
$("#min-conf").addEventListener("input", e => { S.minConf = e.target.value; refresh(); });
|
||
$("#max-len").addEventListener("input", e => { S.maxLen = e.target.value; refresh(); });
|
||
$("#state-tabs").addEventListener("click", e => {
|
||
const tab = e.target.dataset && e.target.dataset.tab;
|
||
if (tab) { S.stateTab = tab; renderState(); refresh(); }
|
||
});
|
||
$("#tabs").addEventListener("click", e => {
|
||
const tab = e.target.dataset && e.target.dataset.tab;
|
||
if (tab) { S.tab = tab; renderPanel(); }
|
||
});
|
||
document.querySelectorAll("[data-add]").forEach(btn => btn.addEventListener("click", () => {
|
||
S.questions.push(newQuestion(btn.dataset.add));
|
||
renderQuestions(); refresh();
|
||
const inputs = document.querySelectorAll("#questions .q:last-child textarea");
|
||
if (inputs.length) inputs[0].focus();
|
||
}));
|
||
$("#run").addEventListener("click", run);
|
||
document.addEventListener("keydown", e => {
|
||
if (!(e.ctrlKey || e.metaKey) || e.key !== "Enter") return;
|
||
e.preventDefault();
|
||
if (S.view === "text") run();
|
||
else if (S.view === "vision" && window.WBV) window.WBV.run();
|
||
});
|
||
$("#reset").addEventListener("click", resetAll);
|
||
$("#import").addEventListener("click", () => $("#file").click());
|
||
$("#file").addEventListener("change", async e => {
|
||
const file = e.target.files && e.target.files[0];
|
||
e.target.value = "";
|
||
if (!file) return;
|
||
try {
|
||
const body = JSON.parse((await file.text()).replace(/^\uFEFF/, ""));
|
||
const clean = {};
|
||
Object.keys(body || {}).forEach(k => { if (k[0] !== "_") clean[k] = body[k]; });
|
||
loadRequest(clean);
|
||
S.last = null;
|
||
S.note = t("ex.imported", { name: file.name });
|
||
refresh(); renderPanel();
|
||
} catch (err) { toast(t("ex.importFail", { msg: err.message })); }
|
||
});
|
||
|
||
applyStatic(); renderAll(); renderDebug(); pollStatus(); loadExamples();
|
||
</script>
|
||
<script src="/_wb/vision.js"></script>
|
||
<script>applyView();</script>
|
||
</body>
|
||
</html>
|