mycomesh-consumer 0.2.1 → 0.4.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.
@@ -0,0 +1,282 @@
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
+ </nav>
60
+ <main>
61
+ <section id="tab-chat" class="active">
62
+ <div class="row"><label class="muted">模型</label><select id="model"></select>
63
+ <button class="ghost" id="clear">新对话</button><span class="muted" id="chat-hint"></span></div>
64
+ <div id="chat"></div>
65
+ <textarea id="prompt" placeholder="输入问题,Enter 发送,Shift+Enter 换行。请求在本机加密,Relay 看不到内容。"></textarea>
66
+ <div class="row"><button class="primary" id="send">发送</button><span class="muted">每个回答都会在本机解密,并与 Provider 签名的收据核对后才显示。</span></div>
67
+ </section>
68
+
69
+ <section id="tab-wallet">
70
+ <div id="no-wallet" style="display:none">
71
+ <h2>创建钱包</h2>
72
+ <p class="muted">钱包文件是标准的以太坊加密 keystore,保存在本机,可以导入 MetaMask。密码只用于解锁,不会保存或上传。</p>
73
+ <div class="row"><input type="password" id="new-pw" placeholder="钱包密码(至少 8 位)"><input type="password" id="new-pw2" placeholder="再输入一次">
74
+ <button class="primary" id="create-wallet">创建</button></div>
75
+ </div>
76
+ <div id="has-wallet" style="display:none">
77
+ <div class="cards">
78
+ <div class="card"><div class="label">托管押金</div><div class="value" id="deposit">—</div></div>
79
+ <div class="card"><div class="label">钱包里的 tUSDC</div><div class="value" id="tokens">—</div></div>
80
+ <div class="card"><div class="label">gas(ETH)</div><div class="value" id="eth">—</div></div>
81
+ <div class="card"><div class="label">单次请求上限</div><div class="value" id="grant">—</div></div>
82
+ </div>
83
+ <p class="muted">钱包地址 <span class="mono" id="owner"></span></p>
84
+ <h2>存入押金</h2>
85
+ <p class="muted">押金由结算合约托管,只能用于你本机签名授权的请求。测试网上,余额不足时会先自动从水龙头领取。</p>
86
+ <div class="row"><input id="dep-amount" placeholder="金额(tUSDC)" value="20" size="10">
87
+ <input id="dep-limit" placeholder="单次上限(tUSDC)" value="2" size="12">
88
+ <button class="primary" id="deposit-btn">存入</button>
89
+ <button class="ghost" id="faucet-btn">领取测试币</button></div>
90
+ <h2>取回押金</h2>
91
+ <p class="muted" id="withdraw-hint">先申请,等待期结束后再次点击即可到账。</p>
92
+ <div class="row"><input id="wd-amount" placeholder="金额(留空为全部)" size="16"><button class="ghost" id="withdraw-btn">取回</button></div>
93
+ </div>
94
+ </section>
95
+
96
+ <section id="tab-history">
97
+ <p class="muted">最近 3 天在本机记录的请求。明文只保存在这台机器上;24 小时争议窗口内可以把它公开给随机抽选的 Provider-AI 陪审团。</p>
98
+ <table><thead><tr><th>时间</th><th>问题</th><th>回答</th><th>费用</th><th>状态</th><th></th></tr></thead><tbody id="history"></tbody></table>
99
+ </section>
100
+
101
+ <section id="tab-network">
102
+ <p class="muted">Relay 来自内置清单和链上 Relay 目录,本机逐个核对签名身份。价格单位:每 1000 个 token 的 tUSDC。</p>
103
+ <div id="network"></div>
104
+ </section>
105
+ </main>
106
+
107
+ <dialog id="dialog"><form method="dialog" id="dialog-form">
108
+ <h3 id="dialog-title" style="margin-top:0"></h3><div id="dialog-body"></div>
109
+ <label>钱包密码</label><input type="password" id="dialog-pw" required>
110
+ <div class="row" style="justify-content:flex-end"><button class="ghost" value="cancel" formnovalidate>取消</button><button class="primary" value="ok">确认</button></div>
111
+ </form></dialog>
112
+ <div id="toast"></div>
113
+
114
+ <script>
115
+ const $ = (id) => document.getElementById(id);
116
+ const USDC = 1_000_000;
117
+ const fmt = (units) => (Number(units) / USDC).toLocaleString("zh-CN", { maximumFractionDigits: 6 });
118
+ const toUnits = (text) => String(Math.round(Number(text) * USDC));
119
+ const short = (value) => value ? `${value.slice(0, 8)}…${value.slice(-6)}` : "";
120
+ const STATUS = { pending: "托管中", released: "已结算", disputed: "争议中", confirmed: "欺诈确认·已退款", dismissed: "争议驳回",
121
+ timed_out: "陪审超时·已放款", jury_unavailable: "无陪审·已退款", voided: "已作废", none: "待上链", unknown: "未知" };
122
+
123
+ function toast(text, bad = false) {
124
+ const box = $("toast"); box.textContent = text; box.style.display = "block"; box.style.color = bad ? "var(--bad)" : "var(--text)";
125
+ clearTimeout(toast.timer); toast.timer = setTimeout(() => { box.style.display = "none"; }, 6000);
126
+ }
127
+ async function api(path, body) {
128
+ const response = await fetch(path, body === undefined ? {} : { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
129
+ const data = await response.json().catch(() => ({}));
130
+ if (!response.ok) throw new Error(typeof data.error === "string" ? data.error : data.error?.message || `HTTP ${response.status}`);
131
+ return data;
132
+ }
133
+ function ask(title, bodyHtml = "") {
134
+ return new Promise((resolve) => {
135
+ $("dialog-title").textContent = title; $("dialog-body").innerHTML = bodyHtml; $("dialog-pw").value = "";
136
+ $("dialog").onclose = () => resolve($("dialog").returnValue === "ok" ? { password: $("dialog-pw").value, form: $("dialog-body") } : null);
137
+ $("dialog").showModal();
138
+ });
139
+ }
140
+ async function busy(button, work) {
141
+ button.disabled = true; const label = button.textContent; button.textContent = "处理中…";
142
+ try { await work(); } catch (error) { toast(error.message, true); } finally { button.disabled = false; button.textContent = label; }
143
+ }
144
+
145
+ document.querySelectorAll("nav button").forEach((button) => button.onclick = () => {
146
+ history.replaceState(null, "", `#${button.dataset.tab}`);
147
+ document.querySelectorAll("nav button").forEach((b) => b.classList.toggle("active", b === button));
148
+ document.querySelectorAll("section").forEach((s) => s.classList.toggle("active", s.id === `tab-${button.dataset.tab}`));
149
+ ({ wallet: refreshWallet, history: refreshHistory, network: refreshNetwork })[button.dataset.tab]?.();
150
+ });
151
+
152
+ // ---------------- wallet ----------------
153
+ let status = null;
154
+ async function refreshWallet() {
155
+ status = await api("/api/status");
156
+ $("net").textContent = `${status.network.id} · 链 ${status.network.chain_id}`; $("net").className = "badge ok";
157
+ $("paykey").textContent = `付款 key ${short(status.payment_key)}`;
158
+ $("no-wallet").style.display = status.wallet ? "none" : "block"; $("has-wallet").style.display = status.wallet ? "block" : "none";
159
+ $("faucet-btn").style.display = status.network.faucet ? "" : "none";
160
+ const w = status.wallet; if (!w) return;
161
+ $("owner").textContent = w.owner; $("deposit").textContent = fmt(w.deposit); $("tokens").textContent = fmt(w.wallet_tokens);
162
+ $("eth").textContent = (Number(w.eth_wei) / 1e18).toFixed(4); $("grant").textContent = w.grant?.active ? fmt(w.grant.max_per_request) : "未授权";
163
+ const pending = BigInt(w.withdrawal.amount);
164
+ $("withdraw-hint").textContent = pending > 0n
165
+ ? (w.withdrawal.available_at * 1000 > Date.now() ? `已申请取回 ${fmt(w.withdrawal.amount)},${new Date(w.withdrawal.available_at * 1000).toLocaleString()} 之后可到账。`
166
+ : `已申请的 ${fmt(w.withdrawal.amount)} 可以到账了,点击“取回”。`) : "先申请,等待期结束后再次点击即可到账。";
167
+ $("chat-hint").textContent = BigInt(w.deposit) === 0n || !w.grant?.active ? "还没有押金:先到“钱包”存入。" : `押金 ${fmt(w.deposit)} tUSDC`;
168
+ }
169
+ $("create-wallet").onclick = () => busy($("create-wallet"), async () => {
170
+ if ($("new-pw").value !== $("new-pw2").value) throw new Error("两次输入的密码不一致");
171
+ await api("/api/wallet", { password: $("new-pw").value }); toast("钱包已创建"); await refreshWallet();
172
+ });
173
+ $("faucet-btn").onclick = () => busy($("faucet-btn"), async () => { await api("/api/faucet", {}); toast("已领取测试 ETH 和 tUSDC"); await refreshWallet(); });
174
+ $("deposit-btn").onclick = async () => {
175
+ const answer = await ask(`存入 ${$("dep-amount").value} tUSDC`, `<p class="muted">单次请求上限 ${$("dep-limit").value} tUSDC。需要几笔链上交易,约 1 分钟。</p>`);
176
+ if (!answer) return;
177
+ await busy($("deposit-btn"), async () => {
178
+ await api("/api/setup", { password: answer.password, deposit: toUnits($("dep-amount").value), max_per_request: toUnits($("dep-limit").value) });
179
+ toast("押金已存入"); await refreshWallet();
180
+ });
181
+ };
182
+ $("withdraw-btn").onclick = async () => {
183
+ const answer = await ask("取回押金"); if (!answer) return;
184
+ await busy($("withdraw-btn"), async () => {
185
+ const amount = $("wd-amount").value.trim();
186
+ const result = await api("/api/withdraw", { password: answer.password, ...(amount ? { amount: toUnits(amount) } : {}) });
187
+ toast(result.withdrawn ? `已取回 ${fmt(result.withdrawn)} tUSDC` : `已申请取回 ${fmt(result.requested)} tUSDC`); await refreshWallet();
188
+ });
189
+ };
190
+
191
+ // ---------------- chat ----------------
192
+ let conversation = [];
193
+ 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; }
194
+ $("clear").onclick = () => { conversation = []; $("chat").innerHTML = ""; };
195
+ $("prompt").onkeydown = (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); $("send").click(); } };
196
+ $("send").onclick = async () => {
197
+ const text = $("prompt").value.trim(); if (!text) return;
198
+ $("prompt").value = ""; conversation.push({ role: "user", content: text }); bubble("user", text);
199
+ const out = bubble("assistant", "…"); let answer = ""; $("send").disabled = true;
200
+ try {
201
+ const response = await fetch("/v1/chat/completions", { method: "POST", headers: { "content-type": "application/json" },
202
+ body: JSON.stringify({ model: $("model").value, stream: true, messages: conversation }) });
203
+ if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.error?.message || `HTTP ${response.status}`); }
204
+ const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = "";
205
+ for (;;) {
206
+ const { value, done } = await reader.read(); if (done) break;
207
+ buffer += decoder.decode(value, { stream: true });
208
+ let index;
209
+ while ((index = buffer.indexOf("\n\n")) >= 0) {
210
+ const event = buffer.slice(0, index); buffer = buffer.slice(index + 2);
211
+ const data = event.replace(/^data: /, ""); if (!data || data === "[DONE]") continue;
212
+ const chunk = JSON.parse(data);
213
+ if (chunk.error) throw new Error(chunk.error.message);
214
+ const delta = chunk.choices?.[0]?.delta?.content; if (delta) { answer += delta; out.textContent = answer; out.scrollIntoView({ block: "end" }); }
215
+ }
216
+ }
217
+ conversation.push({ role: "assistant", content: answer });
218
+ } catch (error) { out.className = "msg error"; out.textContent = error.message; conversation.pop(); }
219
+ finally { $("send").disabled = false; }
220
+ };
221
+ async function loadModels() {
222
+ const data = await fetch("/v1/models").then((r) => r.json());
223
+ $("model").innerHTML = data.data.map((m) => `<option>${m.id}</option>`).join("") || "<option>gpt-5.5</option>";
224
+ }
225
+
226
+ // ---------------- history ----------------
227
+ async function refreshHistory() {
228
+ const { entries } = await api("/api/history");
229
+ const now = Date.now() / 1000;
230
+ $("history").innerHTML = entries.length ? "" : `<tr><td colspan="6" class="muted">还没有请求。</td></tr>`;
231
+ for (const entry of entries) {
232
+ const row = document.createElement("tr");
233
+ const cells = [new Date(entry.recorded_at * 1000).toLocaleString(), entry.prompt, entry.answer, `${fmt(entry.fee)}`];
234
+ cells.forEach((text, i) => {
235
+ const td = document.createElement("td");
236
+ td.textContent = (i === 1 || i === 2) && text.length > 140 ? `${text.slice(0, 140)}…` : text;
237
+ if (i === 1 || i === 2) { td.style.maxWidth = "280px"; td.title = text; }
238
+ row.append(td);
239
+ });
240
+ const state = document.createElement("td"); state.innerHTML = `<span class="chip ${entry.status}">${STATUS[entry.status] || entry.status}</span>`; row.append(state);
241
+ const action = document.createElement("td");
242
+ if (entry.status === "pending" && entry.release_at > now) {
243
+ const button = document.createElement("button"); button.className = "ghost"; button.textContent = "争议";
244
+ button.onclick = async () => {
245
+ const answer = await ask("发起争议", `<p class="muted">会公开这次的问题和回答给随机抽选的 Provider-AI 陪审团,并锁定 1 tUSDC 保证金;确认欺诈后退款并返还保证金。</p>
246
+ <label>说明</label><textarea id="statement" placeholder="例如:回答与问题完全无关"></textarea>`);
247
+ if (!answer) return;
248
+ await busy(button, async () => {
249
+ const result = await api("/api/dispute", { password: answer.password, settlement_key: entry.settlement_key, statement: answer.form.querySelector("#statement").value });
250
+ toast(`争议已提交,${result.relays_accepted.length} 个 Relay 接收了证据`); await refreshHistory();
251
+ });
252
+ };
253
+ action.append(button);
254
+ }
255
+ row.append(action); $("history").append(row);
256
+ }
257
+ }
258
+
259
+ // ---------------- network ----------------
260
+ async function refreshNetwork() {
261
+ const { relays } = await api("/api/network");
262
+ $("relays").textContent = `Relay ${relays.filter((r) => r.healthy).length}/${relays.length}`;
263
+ $("relays").className = relays.some((r) => r.healthy) ? "badge ok" : "badge";
264
+ $("network").innerHTML = "";
265
+ for (const relay of relays) {
266
+ const card = document.createElement("div"); card.className = "card"; card.style.marginBottom = "12px";
267
+ card.innerHTML = `<div class="row" style="margin:0"><strong>${relay.url}</strong><span class="chip ${relay.healthy ? "released" : "disputed"}">${relay.healthy ? "在线" : "离线"}</span>
268
+ <span class="muted mono">签名 ${short(relay.signer)}</span></div>
269
+ <table style="margin-top:8px"><thead><tr><th>Provider</th><th>模型</th><th>输入</th><th>输出</th><th>最低</th></tr></thead><tbody>${relay.providers.map((p) =>
270
+ `<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></tr>`).join("")
271
+ || `<tr><td colspan="5" class="muted">没有已连接的 Provider</td></tr>`}</tbody></table>`;
272
+ $("network").append(card);
273
+ }
274
+ }
275
+
276
+ refreshWallet().catch((error) => toast(error.message, true));
277
+ refreshNetwork().catch(() => {});
278
+ loadModels().catch(() => {});
279
+ document.querySelector(`nav button[data-tab="${location.hash.slice(1)}"]`)?.click();
280
+ </script>
281
+ </body>
282
+ </html>