mycomesh-consumer 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -13
- package/networks/mycomesh-v11-sepolia.json +1 -0
- package/package.json +2 -1
- package/src/account.mjs +93 -0
- package/src/cli.mjs +81 -53
- package/src/console.mjs +104 -0
- package/src/consumer.mjs +72 -18
- package/src/disputes.mjs +29 -1
- package/src/probes.mjs +70 -0
- package/src/reputation.mjs +86 -0
- package/src/tenants.mjs +69 -0
- package/src/tlspin.mjs +36 -0
- package/src/wallet.mjs +10 -2
- package/src/web/console.html +360 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>MycoMesh 本地节点</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { --bg:#0f1115; --panel:#171a21; --line:#262b36; --text:#e6e8ee; --dim:#8b93a7; --accent:#5fd4a4; --warn:#f2b35b; --bad:#f07178; }
|
|
9
|
+
* { box-sizing:border-box; }
|
|
10
|
+
body { margin:0; font:14px/1.55 -apple-system,BlinkMacSystemFont,"PingFang SC","Noto Sans SC","Segoe UI",sans-serif; background:var(--bg); color:var(--text); }
|
|
11
|
+
header { display:flex; align-items:center; gap:16px; padding:14px 24px; border-bottom:1px solid var(--line); }
|
|
12
|
+
header h1 { font-size:16px; margin:0; letter-spacing:.3px; }
|
|
13
|
+
header h1 span { color:var(--accent); }
|
|
14
|
+
.badge { padding:2px 10px; border:1px solid var(--line); border-radius:999px; color:var(--dim); font-size:12px; }
|
|
15
|
+
.badge.ok { color:var(--accent); border-color:#2e5a49; }
|
|
16
|
+
nav { display:flex; gap:4px; padding:0 20px; border-bottom:1px solid var(--line); }
|
|
17
|
+
nav button { background:none; border:0; color:var(--dim); padding:12px 14px; cursor:pointer; font-size:14px; border-bottom:2px solid transparent; }
|
|
18
|
+
nav button.active { color:var(--text); border-bottom-color:var(--accent); }
|
|
19
|
+
main { max-width:1080px; margin:0 auto; padding:24px; }
|
|
20
|
+
section { display:none; } section.active { display:block; }
|
|
21
|
+
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(200px,1fr)); gap:12px; margin-bottom:20px; }
|
|
22
|
+
.card { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:14px 16px; }
|
|
23
|
+
.card .label { color:var(--dim); font-size:12px; } .card .value { font-size:20px; margin-top:4px; font-variant-numeric:tabular-nums; }
|
|
24
|
+
.row { display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin:10px 0; }
|
|
25
|
+
input, select, textarea { background:#0c0e12; color:var(--text); border:1px solid var(--line); border-radius:8px; padding:9px 11px; font:inherit; }
|
|
26
|
+
textarea { width:100%; resize:vertical; min-height:70px; }
|
|
27
|
+
button.primary, button.ghost { border-radius:8px; padding:9px 16px; cursor:pointer; font:inherit; }
|
|
28
|
+
button.primary { background:var(--accent); color:#06231a; border:0; font-weight:600; }
|
|
29
|
+
button.ghost { background:none; color:var(--text); border:1px solid var(--line); }
|
|
30
|
+
button:disabled { opacity:.5; cursor:default; }
|
|
31
|
+
h2 { font-size:15px; margin:24px 0 8px; }
|
|
32
|
+
.muted { color:var(--dim); } .mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; }
|
|
33
|
+
table { width:100%; border-collapse:collapse; } th, td { text-align:left; padding:9px 8px; border-bottom:1px solid var(--line); vertical-align:top; }
|
|
34
|
+
th { color:var(--dim); font-weight:500; font-size:12px; }
|
|
35
|
+
.chip { display:inline-block; padding:1px 8px; border-radius:999px; font-size:12px; border:1px solid var(--line); }
|
|
36
|
+
.chip.pending { color:var(--warn); } .chip.released { color:var(--accent); } .chip.disputed, .chip.confirmed { color:var(--bad); }
|
|
37
|
+
#chat { display:flex; flex-direction:column; gap:12px; min-height:320px; margin-bottom:14px; }
|
|
38
|
+
.msg { max-width:80%; padding:10px 14px; border-radius:12px; white-space:pre-wrap; word-wrap:break-word; }
|
|
39
|
+
.msg.user { align-self:flex-end; background:#1f3a31; } .msg.assistant { align-self:flex-start; background:var(--panel); border:1px solid var(--line); }
|
|
40
|
+
.msg.error { align-self:flex-start; color:var(--bad); border:1px solid #5a2e33; }
|
|
41
|
+
#toast { position:fixed; right:20px; bottom:20px; max-width:420px; padding:12px 16px; border-radius:10px; background:var(--panel); border:1px solid var(--line); display:none; }
|
|
42
|
+
dialog { background:var(--panel); color:var(--text); border:1px solid var(--line); border-radius:12px; padding:20px; width:min(440px,92vw); }
|
|
43
|
+
dialog::backdrop { background:rgba(0,0,0,.55); } dialog label { display:block; margin:10px 0 4px; color:var(--dim); font-size:12px; }
|
|
44
|
+
dialog input, dialog textarea { width:100%; }
|
|
45
|
+
</style>
|
|
46
|
+
</head>
|
|
47
|
+
<body>
|
|
48
|
+
<header>
|
|
49
|
+
<h1><span>●</span> MycoMesh 本地节点</h1>
|
|
50
|
+
<span class="badge" id="net">连接中…</span>
|
|
51
|
+
<span class="badge" id="relays">Relay —</span>
|
|
52
|
+
<span class="muted mono" id="paykey" style="margin-left:auto"></span>
|
|
53
|
+
</header>
|
|
54
|
+
<nav>
|
|
55
|
+
<button data-tab="chat" class="active">对话</button>
|
|
56
|
+
<button data-tab="wallet">钱包</button>
|
|
57
|
+
<button data-tab="history">记录</button>
|
|
58
|
+
<button data-tab="network">网络</button>
|
|
59
|
+
<button data-tab="tenants">租户</button>
|
|
60
|
+
</nav>
|
|
61
|
+
<main>
|
|
62
|
+
<section id="tab-chat" class="active">
|
|
63
|
+
<div class="card" id="welcome" style="display:none; margin-bottom:18px">
|
|
64
|
+
<h2 style="margin-top:0">欢迎使用 MycoMesh</h2>
|
|
65
|
+
<p class="muted">两步就能开始:在本机创建一个加密钱包,然后一键领取测试币并存入押金。之后每个请求都在本机签名、加密,Relay 看不到内容。</p>
|
|
66
|
+
<div id="welcome-create" class="row"><input type="password" id="w-pw" placeholder="设置钱包密码(至少 8 位)">
|
|
67
|
+
<input type="password" id="w-pw2" placeholder="再输入一次"><button class="primary" id="w-create">① 创建钱包</button></div>
|
|
68
|
+
<div id="welcome-fund" class="row" style="display:none"><input type="password" id="w-fund-pw" placeholder="钱包密码">
|
|
69
|
+
<button class="primary" id="w-fund">② 领取测试币并存入 20 tUSDC</button><span class="muted">约 1 分钟,需要几笔链上交易。</span></div>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="row"><label class="muted">模型</label><select id="model"></select>
|
|
72
|
+
<label class="muted">模式</label><select id="speed"><option value="low">快速</option><option value="medium">均衡</option><option value="high">深度</option></select>
|
|
73
|
+
<button class="ghost" id="clear">新对话</button><span class="muted" id="chat-hint"></span></div>
|
|
74
|
+
<div id="chat"></div>
|
|
75
|
+
<textarea id="prompt" placeholder="输入问题,Enter 发送,Shift+Enter 换行。请求在本机加密,Relay 看不到内容。"></textarea>
|
|
76
|
+
<div class="row"><button class="primary" id="send">发送</button><span class="muted">每个回答都会在本机解密,并与 Provider 签名的收据核对后才显示。</span></div>
|
|
77
|
+
</section>
|
|
78
|
+
|
|
79
|
+
<section id="tab-wallet">
|
|
80
|
+
<div id="no-wallet" style="display:none">
|
|
81
|
+
<h2>创建钱包</h2>
|
|
82
|
+
<p class="muted">钱包文件是标准的以太坊加密 keystore,保存在本机,可以导入 MetaMask。密码只用于解锁,不会保存或上传。</p>
|
|
83
|
+
<div class="row"><input type="password" id="new-pw" placeholder="钱包密码(至少 8 位)"><input type="password" id="new-pw2" placeholder="再输入一次">
|
|
84
|
+
<button class="primary" id="create-wallet">创建</button></div>
|
|
85
|
+
</div>
|
|
86
|
+
<div id="has-wallet" style="display:none">
|
|
87
|
+
<div class="cards">
|
|
88
|
+
<div class="card"><div class="label">托管押金</div><div class="value" id="deposit">—</div></div>
|
|
89
|
+
<div class="card"><div class="label">钱包里的 tUSDC</div><div class="value" id="tokens">—</div></div>
|
|
90
|
+
<div class="card"><div class="label">gas(ETH)</div><div class="value" id="eth">—</div></div>
|
|
91
|
+
<div class="card"><div class="label">单次请求上限</div><div class="value" id="grant">—</div></div>
|
|
92
|
+
</div>
|
|
93
|
+
<p class="muted">钱包地址 <span class="mono" id="owner"></span></p>
|
|
94
|
+
<h2>存入押金</h2>
|
|
95
|
+
<p class="muted">押金由结算合约托管,只能用于你本机签名授权的请求。测试网上,余额不足时会先自动从水龙头领取。</p>
|
|
96
|
+
<div class="row"><input id="dep-amount" placeholder="金额(tUSDC)" value="20" size="10">
|
|
97
|
+
<input id="dep-limit" placeholder="单次上限(tUSDC)" value="2" size="12">
|
|
98
|
+
<button class="primary" id="deposit-btn">存入</button>
|
|
99
|
+
<button class="ghost" id="faucet-btn">领取测试币</button></div>
|
|
100
|
+
<h2>取回押金</h2>
|
|
101
|
+
<p class="muted" id="withdraw-hint">先申请,等待期结束后再次点击即可到账。</p>
|
|
102
|
+
<div class="row"><input id="wd-amount" placeholder="金额(留空为全部)" size="16"><button class="ghost" id="withdraw-btn">取回</button></div>
|
|
103
|
+
</div>
|
|
104
|
+
</section>
|
|
105
|
+
|
|
106
|
+
<section id="tab-history">
|
|
107
|
+
<p class="muted">最近 3 天在本机记录的请求。明文只保存在这台机器上;24 小时争议窗口内可以把它公开给随机抽选的 Provider-AI 陪审团。</p>
|
|
108
|
+
<table><thead><tr><th>时间</th><th>问题</th><th>回答</th><th>费用</th><th>状态</th><th></th></tr></thead><tbody id="history"></tbody></table>
|
|
109
|
+
</section>
|
|
110
|
+
|
|
111
|
+
<section id="tab-tenants">
|
|
112
|
+
<p class="muted">多租户:一笔押金服务多个用户或团队。每个租户有自己的付款 key 和 API key,链上限定单次上限和总预算,花完即停,互不影响。
|
|
113
|
+
租户用 API key 调用本机的 <span class="mono">/v1</span> 接口(可以用 <span class="mono">--host 0.0.0.0</span> 对外提供)。</p>
|
|
114
|
+
<div class="row"><input id="t-name" placeholder="租户名称" size="14"><input id="t-budget" placeholder="总预算(tUSDC)" size="14" value="10">
|
|
115
|
+
<input id="t-limit" placeholder="单次上限(tUSDC)" size="14" value="1"><button class="primary" id="t-add">添加租户</button></div>
|
|
116
|
+
<p class="mono" id="t-created" style="display:none"></p>
|
|
117
|
+
<table><thead><tr><th>租户</th><th>付款 key</th><th>已用 / 预算</th><th>单次上限</th><th>状态</th><th></th></tr></thead><tbody id="tenants"></tbody></table>
|
|
118
|
+
</section>
|
|
119
|
+
|
|
120
|
+
<section id="tab-network">
|
|
121
|
+
<p class="muted">Relay 来自内置清单和链上 Relay 目录,本机逐个核对签名身份;自签证书的 Relay 按链上登记的指纹核验,不依赖证书机构。
|
|
122
|
+
选择 Provider 时先看链上能证明的:近期是否被确认欺诈、经本机重新判分的探针失败率、累计干净成交,最后才看价格。价格单位:每 1000 个 token 的 tUSDC。</p>
|
|
123
|
+
<div id="network"></div>
|
|
124
|
+
</section>
|
|
125
|
+
</main>
|
|
126
|
+
|
|
127
|
+
<dialog id="dialog"><form method="dialog" id="dialog-form">
|
|
128
|
+
<h3 id="dialog-title" style="margin-top:0"></h3><div id="dialog-body"></div>
|
|
129
|
+
<label>钱包密码</label><input type="password" id="dialog-pw" required>
|
|
130
|
+
<div class="row" style="justify-content:flex-end"><button class="ghost" value="cancel" formnovalidate>取消</button><button class="primary" value="ok">确认</button></div>
|
|
131
|
+
</form></dialog>
|
|
132
|
+
<div id="toast"></div>
|
|
133
|
+
|
|
134
|
+
<script>
|
|
135
|
+
const $ = (id) => document.getElementById(id);
|
|
136
|
+
const USDC = 1_000_000;
|
|
137
|
+
const fmt = (units) => (Number(units) / USDC).toLocaleString("zh-CN", { maximumFractionDigits: 6 });
|
|
138
|
+
const toUnits = (text) => String(Math.round(Number(text) * USDC));
|
|
139
|
+
const short = (value) => value ? `${value.slice(0, 8)}…${value.slice(-6)}` : "";
|
|
140
|
+
const STATUS = { pending: "托管中", released: "已结算", disputed: "争议中", confirmed: "欺诈确认·已退款", dismissed: "争议驳回",
|
|
141
|
+
timed_out: "陪审超时·已放款", jury_unavailable: "无陪审·已退款", voided: "已作废", none: "待上链", unknown: "未知" };
|
|
142
|
+
|
|
143
|
+
function toast(text, bad = false) {
|
|
144
|
+
const box = $("toast"); box.textContent = text; box.style.display = "block"; box.style.color = bad ? "var(--bad)" : "var(--text)";
|
|
145
|
+
clearTimeout(toast.timer); toast.timer = setTimeout(() => { box.style.display = "none"; }, 6000);
|
|
146
|
+
}
|
|
147
|
+
async function api(path, body) {
|
|
148
|
+
const response = await fetch(path, body === undefined ? {} : { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
149
|
+
const data = await response.json().catch(() => ({}));
|
|
150
|
+
if (!response.ok) throw new Error(typeof data.error === "string" ? data.error : data.error?.message || `HTTP ${response.status}`);
|
|
151
|
+
return data;
|
|
152
|
+
}
|
|
153
|
+
function ask(title, bodyHtml = "") {
|
|
154
|
+
return new Promise((resolve) => {
|
|
155
|
+
$("dialog-title").textContent = title; $("dialog-body").innerHTML = bodyHtml; $("dialog-pw").value = "";
|
|
156
|
+
$("dialog").onclose = () => resolve($("dialog").returnValue === "ok" ? { password: $("dialog-pw").value, form: $("dialog-body") } : null);
|
|
157
|
+
$("dialog").showModal();
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
async function busy(button, work) {
|
|
161
|
+
button.disabled = true; const label = button.textContent; button.textContent = "处理中…";
|
|
162
|
+
try { await work(); } catch (error) { toast(error.message, true); } finally { button.disabled = false; button.textContent = label; }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
document.querySelectorAll("nav button").forEach((button) => button.onclick = () => {
|
|
166
|
+
history.replaceState(null, "", `#${button.dataset.tab}`);
|
|
167
|
+
document.querySelectorAll("nav button").forEach((b) => b.classList.toggle("active", b === button));
|
|
168
|
+
document.querySelectorAll("section").forEach((s) => s.classList.toggle("active", s.id === `tab-${button.dataset.tab}`));
|
|
169
|
+
({ wallet: refreshWallet, history: refreshHistory, network: refreshNetwork, tenants: refreshTenants })[button.dataset.tab]?.();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ---------------- wallet ----------------
|
|
173
|
+
let status = null;
|
|
174
|
+
async function refreshWallet() {
|
|
175
|
+
status = await api("/api/status");
|
|
176
|
+
$("net").textContent = `${status.network.id} · 链 ${status.network.chain_id}`; $("net").className = "badge ok";
|
|
177
|
+
$("paykey").textContent = `付款 key ${short(status.payment_key)}`;
|
|
178
|
+
$("no-wallet").style.display = status.wallet ? "none" : "block"; $("has-wallet").style.display = status.wallet ? "block" : "none";
|
|
179
|
+
$("faucet-btn").style.display = status.network.faucet ? "" : "none";
|
|
180
|
+
const w = status.wallet; if (!w) return;
|
|
181
|
+
$("owner").textContent = w.owner; $("deposit").textContent = fmt(w.deposit); $("tokens").textContent = fmt(w.wallet_tokens);
|
|
182
|
+
$("eth").textContent = (Number(w.eth_wei) / 1e18).toFixed(4); $("grant").textContent = w.grant?.active ? fmt(w.grant.max_per_request) : "未授权";
|
|
183
|
+
const pending = BigInt(w.withdrawal.amount);
|
|
184
|
+
$("withdraw-hint").textContent = pending > 0n
|
|
185
|
+
? (w.withdrawal.available_at * 1000 > Date.now() ? `已申请取回 ${fmt(w.withdrawal.amount)},${new Date(w.withdrawal.available_at * 1000).toLocaleString()} 之后可到账。`
|
|
186
|
+
: `已申请的 ${fmt(w.withdrawal.amount)} 可以到账了,点击“取回”。`) : "先申请,等待期结束后再次点击即可到账。";
|
|
187
|
+
$("chat-hint").textContent = BigInt(w.deposit) === 0n || !w.grant?.active ? "还没有押金。" : `押金 ${fmt(w.deposit)} tUSDC`;
|
|
188
|
+
}
|
|
189
|
+
function renderWelcome() {
|
|
190
|
+
const ready = status?.wallet && BigInt(status.wallet.deposit) > 0n && status.wallet.grant?.active;
|
|
191
|
+
$("welcome").style.display = ready ? "none" : "block";
|
|
192
|
+
$("welcome-create").style.display = status?.wallet ? "none" : "flex";
|
|
193
|
+
$("welcome-fund").style.display = status?.wallet && !ready ? "flex" : "none";
|
|
194
|
+
}
|
|
195
|
+
$("w-create").onclick = () => busy($("w-create"), async () => {
|
|
196
|
+
if ($("w-pw").value !== $("w-pw2").value) throw new Error("两次输入的密码不一致");
|
|
197
|
+
await api("/api/wallet", { password: $("w-pw").value }); $("w-fund-pw").value = $("w-pw").value;
|
|
198
|
+
await refreshWallet(); renderWelcome(); toast("钱包已创建,下一步:领取测试币并存入押金");
|
|
199
|
+
});
|
|
200
|
+
$("w-fund").onclick = () => busy($("w-fund"), async () => {
|
|
201
|
+
await api("/api/setup", { password: $("w-fund-pw").value, deposit: toUnits(20), max_per_request: toUnits(2) });
|
|
202
|
+
$("w-fund-pw").value = ""; await refreshWallet(); renderWelcome(); toast("准备好了,开始提问吧"); $("prompt").focus();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ---------------- tenants ----------------
|
|
206
|
+
async function refreshTenants() {
|
|
207
|
+
const { tenants } = await api("/api/tenants");
|
|
208
|
+
$("tenants").innerHTML = tenants.length ? "" : `<tr><td colspan="6" class="muted">还没有租户。</td></tr>`;
|
|
209
|
+
for (const t of tenants) {
|
|
210
|
+
const row = document.createElement("tr");
|
|
211
|
+
row.innerHTML = `<td>${t.name}</td><td class="mono">${short(t.key)}</td>
|
|
212
|
+
<td>${fmt(t.spent)} / ${BigInt(t.budget) === 0n ? "不限" : fmt(t.budget)}</td><td>${fmt(t.max_per_request)}</td>
|
|
213
|
+
<td><span class="chip ${t.revoked ? "disputed" : "released"}">${t.revoked ? "已撤销" : "有效"}</span></td><td></td>`;
|
|
214
|
+
if (!t.revoked) {
|
|
215
|
+
const cell = row.lastElementChild;
|
|
216
|
+
for (const [label, action] of [["调整预算", "budget"], ["撤销", "revoke"]]) {
|
|
217
|
+
const button = document.createElement("button"); button.className = "ghost"; button.textContent = label; button.style.marginRight = "6px";
|
|
218
|
+
button.onclick = async () => {
|
|
219
|
+
const answer = await ask(`${label}:${t.name}`, action === "budget" ? `<label>新的总预算(tUSDC)</label><input id="new-budget" value="${fmt(t.budget)}">` : "");
|
|
220
|
+
if (!answer) return;
|
|
221
|
+
await busy(button, async () => {
|
|
222
|
+
await api(`/api/tenants/${action}`, { password: answer.password, name: t.name,
|
|
223
|
+
...(action === "budget" ? { budget: toUnits(answer.form.querySelector("#new-budget").value) } : {}) });
|
|
224
|
+
toast("已更新"); await refreshTenants();
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
cell.append(button);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
$("tenants").append(row);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
$("t-add").onclick = async () => {
|
|
234
|
+
const answer = await ask(`添加租户 ${$("t-name").value}`); if (!answer) return;
|
|
235
|
+
await busy($("t-add"), async () => {
|
|
236
|
+
const created = await api("/api/tenants", { password: answer.password, name: $("t-name").value.trim(),
|
|
237
|
+
budget: toUnits($("t-budget").value), max_per_request: toUnits($("t-limit").value) });
|
|
238
|
+
$("t-created").style.display = "block";
|
|
239
|
+
$("t-created").textContent = `租户 ${created.name} 的 API key(只显示这一次):${created.api_key}`;
|
|
240
|
+
await refreshTenants();
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
$("create-wallet").onclick = () => busy($("create-wallet"), async () => {
|
|
244
|
+
if ($("new-pw").value !== $("new-pw2").value) throw new Error("两次输入的密码不一致");
|
|
245
|
+
await api("/api/wallet", { password: $("new-pw").value }); toast("钱包已创建"); await refreshWallet();
|
|
246
|
+
});
|
|
247
|
+
$("faucet-btn").onclick = () => busy($("faucet-btn"), async () => { await api("/api/faucet", {}); toast("已领取测试 ETH 和 tUSDC"); await refreshWallet(); });
|
|
248
|
+
$("deposit-btn").onclick = async () => {
|
|
249
|
+
const answer = await ask(`存入 ${$("dep-amount").value} tUSDC`, `<p class="muted">单次请求上限 ${$("dep-limit").value} tUSDC。需要几笔链上交易,约 1 分钟。</p>`);
|
|
250
|
+
if (!answer) return;
|
|
251
|
+
await busy($("deposit-btn"), async () => {
|
|
252
|
+
await api("/api/setup", { password: answer.password, deposit: toUnits($("dep-amount").value), max_per_request: toUnits($("dep-limit").value) });
|
|
253
|
+
toast("押金已存入"); await refreshWallet();
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
$("withdraw-btn").onclick = async () => {
|
|
257
|
+
const answer = await ask("取回押金"); if (!answer) return;
|
|
258
|
+
await busy($("withdraw-btn"), async () => {
|
|
259
|
+
const amount = $("wd-amount").value.trim();
|
|
260
|
+
const result = await api("/api/withdraw", { password: answer.password, ...(amount ? { amount: toUnits(amount) } : {}) });
|
|
261
|
+
toast(result.withdrawn ? `已取回 ${fmt(result.withdrawn)} tUSDC` : `已申请取回 ${fmt(result.requested)} tUSDC`); await refreshWallet();
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// ---------------- chat ----------------
|
|
266
|
+
let conversation = [];
|
|
267
|
+
function bubble(role, text) { const div = document.createElement("div"); div.className = `msg ${role}`; div.textContent = text; $("chat").append(div); div.scrollIntoView({ block: "end" }); return div; }
|
|
268
|
+
$("clear").onclick = () => { conversation = []; $("chat").innerHTML = ""; };
|
|
269
|
+
$("prompt").onkeydown = (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); $("send").click(); } };
|
|
270
|
+
$("send").onclick = async () => {
|
|
271
|
+
const text = $("prompt").value.trim(); if (!text) return;
|
|
272
|
+
$("prompt").value = ""; conversation.push({ role: "user", content: text }); bubble("user", text);
|
|
273
|
+
const out = bubble("assistant", "…"); let answer = ""; $("send").disabled = true;
|
|
274
|
+
try {
|
|
275
|
+
const response = await fetch("/v1/chat/completions", { method: "POST", headers: { "content-type": "application/json" },
|
|
276
|
+
body: JSON.stringify({ model: $("model").value, stream: true, messages: conversation, reasoning_effort: $("speed").value }) });
|
|
277
|
+
if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.error?.message || `HTTP ${response.status}`); }
|
|
278
|
+
const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = "";
|
|
279
|
+
for (;;) {
|
|
280
|
+
const { value, done } = await reader.read(); if (done) break;
|
|
281
|
+
buffer += decoder.decode(value, { stream: true });
|
|
282
|
+
let index;
|
|
283
|
+
while ((index = buffer.indexOf("\n\n")) >= 0) {
|
|
284
|
+
const event = buffer.slice(0, index); buffer = buffer.slice(index + 2);
|
|
285
|
+
const data = event.replace(/^data: /, ""); if (!data || data === "[DONE]") continue;
|
|
286
|
+
const chunk = JSON.parse(data);
|
|
287
|
+
if (chunk.error) throw new Error(chunk.error.message);
|
|
288
|
+
const delta = chunk.choices?.[0]?.delta?.content; if (delta) { answer += delta; out.textContent = answer; out.scrollIntoView({ block: "end" }); }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
conversation.push({ role: "assistant", content: answer });
|
|
292
|
+
} catch (error) { out.className = "msg error"; out.textContent = error.message; conversation.pop(); }
|
|
293
|
+
finally { $("send").disabled = false; }
|
|
294
|
+
};
|
|
295
|
+
async function loadModels() {
|
|
296
|
+
const data = await fetch("/v1/models").then((r) => r.json());
|
|
297
|
+
$("model").innerHTML = data.data.map((m) => `<option>${m.id}</option>`).join("") || "<option>gpt-5.5</option>";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---------------- history ----------------
|
|
301
|
+
async function refreshHistory() {
|
|
302
|
+
const { entries } = await api("/api/history");
|
|
303
|
+
const now = Date.now() / 1000;
|
|
304
|
+
$("history").innerHTML = entries.length ? "" : `<tr><td colspan="6" class="muted">还没有请求。</td></tr>`;
|
|
305
|
+
for (const entry of entries) {
|
|
306
|
+
const row = document.createElement("tr");
|
|
307
|
+
const cells = [new Date(entry.recorded_at * 1000).toLocaleString(), entry.prompt, entry.answer, `${fmt(entry.fee)}`];
|
|
308
|
+
cells.forEach((text, i) => {
|
|
309
|
+
const td = document.createElement("td");
|
|
310
|
+
td.textContent = (i === 1 || i === 2) && text.length > 140 ? `${text.slice(0, 140)}…` : text;
|
|
311
|
+
if (i === 1 || i === 2) { td.style.maxWidth = "280px"; td.title = text; }
|
|
312
|
+
row.append(td);
|
|
313
|
+
});
|
|
314
|
+
const state = document.createElement("td"); state.innerHTML = `<span class="chip ${entry.status}">${STATUS[entry.status] || entry.status}</span>`; row.append(state);
|
|
315
|
+
const action = document.createElement("td");
|
|
316
|
+
if (entry.status === "pending" && entry.release_at > now) {
|
|
317
|
+
const button = document.createElement("button"); button.className = "ghost"; button.textContent = "争议";
|
|
318
|
+
button.onclick = async () => {
|
|
319
|
+
const answer = await ask("发起争议", `<p class="muted">会公开这次的问题和回答给随机抽选的 Provider-AI 陪审团,并锁定 1 tUSDC 保证金;确认欺诈后退款并返还保证金。</p>
|
|
320
|
+
<label>说明</label><textarea id="statement" placeholder="例如:回答与问题完全无关"></textarea>`);
|
|
321
|
+
if (!answer) return;
|
|
322
|
+
await busy(button, async () => {
|
|
323
|
+
const result = await api("/api/dispute", { password: answer.password, settlement_key: entry.settlement_key, statement: answer.form.querySelector("#statement").value });
|
|
324
|
+
toast(`争议已提交,${result.relays_accepted.length} 个 Relay 接收了证据`); await refreshHistory();
|
|
325
|
+
});
|
|
326
|
+
};
|
|
327
|
+
action.append(button);
|
|
328
|
+
}
|
|
329
|
+
row.append(action); $("history").append(row);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---------------- network ----------------
|
|
334
|
+
async function refreshNetwork() {
|
|
335
|
+
const { relays } = await api("/api/network");
|
|
336
|
+
$("relays").textContent = `Relay ${relays.filter((r) => r.healthy).length}/${relays.length}`;
|
|
337
|
+
$("relays").className = relays.some((r) => r.healthy) ? "badge ok" : "badge";
|
|
338
|
+
$("network").innerHTML = "";
|
|
339
|
+
for (const relay of relays) {
|
|
340
|
+
const card = document.createElement("div"); card.className = "card"; card.style.marginBottom = "12px";
|
|
341
|
+
const reputation = (r) => r ? `${fmt(r.counted_volume)} · ${r.counterparties} 个付款方` : "—";
|
|
342
|
+
const probes = (r) => r ? `通过 ${r.probes_passed} · 验证失败 ${r.probes_failed_verified}` : "—";
|
|
343
|
+
const fraud = (r) => r && r.last_fraud_at && Date.now() / 1000 < r.last_fraud_at + 30 * 86400 ? `<span class="chip disputed">近期欺诈</span>` : "无";
|
|
344
|
+
card.innerHTML = `<div class="row" style="margin:0"><strong>${relay.url}</strong><span class="chip ${relay.healthy ? "released" : "disputed"}">${relay.healthy ? "在线" : "离线"}</span>
|
|
345
|
+
<span class="muted mono">签名 ${short(relay.signer)}</span>${relay.pinned ? `<span class="chip">证书指纹已上链</span>` : ""}</div>
|
|
346
|
+
<table style="margin-top:8px"><thead><tr><th>Provider</th><th>模型</th><th>输入</th><th>输出</th><th>最低</th><th>信誉(计入成交)</th><th>探针</th><th>欺诈</th></tr></thead><tbody>${relay.providers.map((p) =>
|
|
347
|
+
`<tr><td class="mono">${short(p.signer)}</td><td>${p.models.join(", ")}</td><td>${fmt(p.prices.input_per_1k)}</td><td>${fmt(p.prices.output_per_1k)}</td><td>${fmt(p.prices.minimum_fee)}</td>
|
|
348
|
+
<td>${reputation(p.reputation)}</td><td>${probes(p.reputation)}</td><td>${fraud(p.reputation)}</td></tr>`).join("")
|
|
349
|
+
|| `<tr><td colspan="8" class="muted">没有已连接的 Provider</td></tr>`}</tbody></table>`;
|
|
350
|
+
$("network").append(card);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
refreshWallet().then(renderWelcome).catch((error) => toast(error.message, true));
|
|
355
|
+
refreshNetwork().catch(() => {});
|
|
356
|
+
loadModels().catch(() => {});
|
|
357
|
+
document.querySelector(`nav button[data-tab="${location.hash.slice(1)}"]`)?.click();
|
|
358
|
+
</script>
|
|
359
|
+
</body>
|
|
360
|
+
</html>
|