mycomesh-consumer 0.1.49 → 0.1.51

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 CHANGED
@@ -25,16 +25,19 @@ The command starts the Consumer API on `127.0.0.1:8110`, prints and optionally
25
25
  opens the local credentials page, and stays available for any
26
26
  OpenAI-compatible client. It does not start or configure Codex.
27
27
 
28
- The page exposes only the local API URL, payment key/export, prepaid balance,
29
- key operations, and local consumption history. It has no conversation or
30
- request-session UI. The payment key is generated once and stored at
28
+ The page starts with a wallet signature check. The selected wallet must match
29
+ the local payment key's active on-chain grant before the API key/export and
30
+ inference are enabled. It exposes only the local API URL, payment key/export,
31
+ prepaid balance, key operations, and local consumption history. It has no
32
+ conversation or request-session UI. The payment key is generated once and stored at
31
33
  `~/.mycomesh/consumer/payment-key` with mode `0600`; set
32
34
  `MYCOMESH_CONSUMER_DATA_DIR` to choose another directory.
33
35
 
34
36
  ## Connect a client
35
37
 
36
- The default command is already service-only. Load the export block into a
37
- shell and then use Codex or another compatible client:
38
+ The default command is already service-only. Sign in on the local page first,
39
+ then load the export block into a shell and use Codex or another compatible
40
+ client:
38
41
 
39
42
  ```sh
40
43
  mycomesh-consumer --no-browser
@@ -63,7 +66,9 @@ export OPENAI_API_KEY='myco_sk_...'
63
66
  mycomesh responses --input 'hello' --model mycomesh-codex-standard-v1
64
67
  ```
65
68
 
66
- The browser's export block is the canonical way to obtain both values.
69
+ The browser's export block is the canonical way to obtain both values after
70
+ wallet verification. `--codex` waits for that verification before launching
71
+ Codex.
67
72
 
68
73
  ## Temporary sharing
69
74
 
@@ -81,8 +86,9 @@ The Consumer checks each configured Relay's V8 health and automatically tries
81
86
  the next Relay after a health, timeout, or retryable HTTP failure. A single
82
87
  request ID is preserved across failover. Each attempt carries a fresh V8
83
88
  EIP-712 payment authorization signed by the persisted key; the Relay resolves
84
- the key address to its on-chain grant and settles the signed receipt. No wallet
85
- signature or Consumer-managed conversation state is involved in inference.
89
+ the key address to its on-chain grant and settles the signed receipt. A wallet
90
+ signature unlocks each Consumer process once; inference has no per-request
91
+ wallet signature or Consumer-managed conversation state.
86
92
 
87
93
  Configure multiple Relay origins and an optional outbound proxy:
88
94
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mycomesh-consumer",
3
- "version": "0.1.49",
3
+ "version": "0.1.51",
4
4
  "description": "Native Node.js MycoMesh Consumer and OpenAI-compatible request CLI.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -88,6 +88,7 @@ const TUNNEL_STOP_TIMEOUT_MS = 2_000;
88
88
  const RELAY_HEALTH_CACHE_MS = 30_000;
89
89
  const RELAY_HEALTH_STALE_MS = 10 * 60_000;
90
90
  const RELAY_HEALTH_RETRY_DELAY_MS = 250;
91
+ const WALLET_CHALLENGE_TTL_MS = 5 * 60_000;
91
92
 
92
93
  const DEFAULT_NETWORK = Object.freeze({
93
94
  chain_id: DEFAULT_CHAIN_ID,
@@ -275,6 +276,12 @@ function recoverAddress(digest, signatureValue) {
275
276
  return `0x${bytesToHex(keccak_256(publicKey).slice(-20))}`;
276
277
  }
277
278
 
279
+ export function walletMessageDigest(message) {
280
+ const body = Buffer.from(String(message), "utf8");
281
+ const prefix = Buffer.from(`\x19Ethereum Signed Message:\n${body.length}`, "utf8");
282
+ return Uint8Array.from(keccak_256(Buffer.concat([prefix, body])));
283
+ }
284
+
278
285
  function typedDigest(structHash, chainId, contract) {
279
286
  const domain = keccak_256(
280
287
  Buffer.concat([
@@ -631,6 +638,10 @@ export class NativeConsumerState {
631
638
  this.tunnelCommand = options.tunnelCommand || env.MYCOMESH_CONSUMER_TUNNEL_COMMAND || "cloudflared";
632
639
  this.tunnelSpawn = options.tunnelSpawn || defaultSpawn;
633
640
  this.share = null;
641
+ this.walletChallenge = null;
642
+ this.managementToken = null;
643
+ this.unlockedWallet = null;
644
+ this.paymentUnlocked = false;
634
645
  }
635
646
 
636
647
  loadPaymentKey(configured) {
@@ -667,10 +678,87 @@ export class NativeConsumerState {
667
678
  relay_urls: this.relayUrls,
668
679
  payment_key_address: this.paymentAddress,
669
680
  payment_key_persisted: !this.paymentKeyFromEnv,
681
+ wallet_unlocked: this.paymentUnlocked,
670
682
  responses_transports: ["http", "sse"],
671
683
  };
672
684
  }
673
685
 
686
+ walletAuthPayload() {
687
+ return {
688
+ authenticated: Boolean(this.managementToken && this.unlockedWallet),
689
+ wallet: this.unlockedWallet,
690
+ key_ready: this.paymentUnlocked,
691
+ };
692
+ }
693
+
694
+ createWalletChallenge(walletValue) {
695
+ const wallet = normalizeAddress(walletValue, "wallet");
696
+ const issuedAt = Date.now();
697
+ const expiresAt = issuedAt + WALLET_CHALLENGE_TTL_MS;
698
+ const nonce = randomBytes(24).toString("base64url");
699
+ const message = [
700
+ "MycoMesh Consumer wallet login",
701
+ `Wallet: ${wallet}`,
702
+ `Payment key: ${this.paymentAddress}`,
703
+ `Nonce: ${nonce}`,
704
+ `Issued at: ${new Date(issuedAt).toISOString()}`,
705
+ "This signature unlocks this local Consumer process only.",
706
+ ].join("\n");
707
+ this.walletChallenge = { wallet, message, expiresAt };
708
+ return { wallet, key_address: this.paymentAddress, message, expires_at: Math.floor(expiresAt / 1000) };
709
+ }
710
+
711
+ async authenticateWallet(raw) {
712
+ const wallet = normalizeAddress(raw?.wallet, "wallet");
713
+ const challenge = this.walletChallenge;
714
+ if (!challenge || challenge.wallet !== wallet || challenge.expiresAt < Date.now()) {
715
+ this.walletChallenge = null;
716
+ throw new Error("wallet login challenge is missing or expired");
717
+ }
718
+ this.walletChallenge = null;
719
+ const recovered = recoverAddress(walletMessageDigest(challenge.message), raw?.signature);
720
+ if (recovered !== wallet) throw new Error("wallet signature does not match the selected account");
721
+ const grant = await this.keyGrant(this.paymentAddress);
722
+ if (grant.owner !== ZERO_ADDRESS && grant.owner !== wallet) {
723
+ throw new Error("this local payment key belongs to a different wallet");
724
+ }
725
+ this.unlockedWallet = wallet;
726
+ this.managementToken = `myco_local_${randomBytes(32).toString("base64url")}`;
727
+ this.paymentUnlocked = grant.active && grant.owner === wallet;
728
+ return { ok: true, token: this.managementToken, auth: this.walletAuthPayload(), grant };
729
+ }
730
+
731
+ authorizeManagement(authorization) {
732
+ return Boolean(this.managementToken && sameSecret(authorization, `Bearer ${this.managementToken}`));
733
+ }
734
+
735
+ assertUnlockedWallet(walletValue = this.unlockedWallet) {
736
+ const wallet = normalizeAddress(walletValue, "wallet");
737
+ if (!this.managementToken || wallet !== this.unlockedWallet) {
738
+ throw new Error("sign in with the payment-key owner wallet first");
739
+ }
740
+ return wallet;
741
+ }
742
+
743
+ async activateCurrentPaymentKey() {
744
+ const wallet = this.assertUnlockedWallet();
745
+ const grant = await this.keyGrant(this.paymentAddress);
746
+ if (!grant.active || grant.owner !== wallet) {
747
+ throw new Error("the payment key is not active for this wallet on-chain");
748
+ }
749
+ this.paymentUnlocked = true;
750
+ return { ok: true, auth: this.walletAuthPayload(), grant };
751
+ }
752
+
753
+ async lockWallet() {
754
+ this.walletChallenge = null;
755
+ this.managementToken = null;
756
+ this.unlockedWallet = null;
757
+ this.paymentUnlocked = false;
758
+ await this.stopShare();
759
+ return { ok: true, auth: this.walletAuthPayload() };
760
+ }
761
+
674
762
  history(limit = 100) {
675
763
  if (!existsSync(this.historyPath)) return [];
676
764
  const lines = readFileSync(this.historyPath, "utf8").split("\n").filter(Boolean);
@@ -680,7 +768,7 @@ export class NativeConsumerState {
680
768
  }).filter((value) => value && typeof value === "object").reverse();
681
769
  }
682
770
 
683
- recordReceipt(relayUrl, endpoint, model, settlement) {
771
+ recordReceipt(relayUrl, endpoint, model, settlement, routeModel = model) {
684
772
  const signed = settlement?.signed_receipt;
685
773
  const receipt = signed?.receipt;
686
774
  const auth = signed?.authorization?.authorization;
@@ -693,6 +781,7 @@ export class NativeConsumerState {
693
781
  accepted: Boolean(settlement.accepted),
694
782
  endpoint,
695
783
  model,
784
+ route_model: routeModel,
696
785
  relay_url: relayUrl,
697
786
  provider: String(receipt.provider || ""),
698
787
  input_tokens: Number(receipt.input_tokens || 0),
@@ -745,36 +834,45 @@ export class NativeConsumerState {
745
834
  return BigInt(output || "0x0").toString();
746
835
  }
747
836
 
748
- async dashboardPayload(wallet) {
837
+ async dashboardPayload(managementAuthorized = false) {
838
+ const authenticated = managementAuthorized && Boolean(this.unlockedWallet);
749
839
  const allHistory = this.history(0);
840
+ const pending = authenticated ? this.pendingPaymentKey() : null;
750
841
  const payload = {
751
842
  ok: true,
752
843
  protocol_version: 8,
753
844
  runtime: "node-native",
754
- credentials: { base_url: this.baseUrl, api_key: this.paymentKey, export: this.credentialsText() },
755
- key: { address: this.paymentAddress, max_fee_units: this.maxFeeUnits, pending: this.pendingPaymentKey() },
845
+ auth: authenticated ? this.walletAuthPayload() : { authenticated: false, wallet: null, key_ready: false },
846
+ credentials: authenticated && this.paymentUnlocked
847
+ ? { base_url: this.baseUrl, api_key: this.paymentKey, export: this.credentialsText() }
848
+ : null,
849
+ key: {
850
+ address: this.paymentAddress,
851
+ max_fee_units: this.maxFeeUnits,
852
+ pending: pending ? { payment_key_address: pending.payment_key_address } : null,
853
+ },
756
854
  settlement: this.network,
757
- history: allHistory.slice(0, 100),
855
+ history: authenticated ? allHistory.slice(0, 100) : [],
758
856
  usage: {
759
- request_count: allHistory.length,
760
- total_spent_units: allHistory.reduce((total, item) => total + Number(item.actual_fee_units || 0), 0),
761
- input_tokens: allHistory.reduce((total, item) => total + Number(item.input_tokens || 0), 0),
762
- output_tokens: allHistory.reduce((total, item) => total + Number(item.output_tokens || 0), 0),
857
+ request_count: authenticated ? allHistory.length : 0,
858
+ total_spent_units: authenticated ? allHistory.reduce((total, item) => total + Number(item.actual_fee_units || 0), 0) : 0,
859
+ input_tokens: authenticated ? allHistory.reduce((total, item) => total + Number(item.input_tokens || 0), 0) : 0,
860
+ output_tokens: authenticated ? allHistory.reduce((total, item) => total + Number(item.output_tokens || 0), 0) : 0,
763
861
  },
764
- share: this.sharePayload(),
862
+ share: authenticated ? this.sharePayload() : { active: false },
765
863
  };
766
864
  try {
767
865
  const grant = await this.keyGrant(this.paymentAddress);
768
866
  payload.key.grant = grant;
769
- if (grant.owner !== ZERO_ADDRESS) {
867
+ if (authenticated && grant.owner !== ZERO_ADDRESS) {
770
868
  payload.account = { owner: grant.owner, available_balance_units: await this.accountBalance(grant.owner) };
771
869
  }
772
870
  } catch (error) {
773
871
  payload.chain_error = error.message;
774
872
  }
775
- if (wallet) {
873
+ if (authenticated) {
776
874
  try {
777
- const address = normalizeAddress(wallet);
875
+ const address = this.unlockedWallet;
778
876
  const token = await this.rpcValue((rpc) => this.contractCall(rpc, this.network.stablecoin, "balanceOf(address)", [address]));
779
877
  const allowance = await this.rpcValue((rpc) => this.contractCall(rpc, this.network.stablecoin, "allowance(address,address)", [address, this.network.settlement_contract]));
780
878
  payload.wallet = { address, token_balance_units: BigInt(token || "0x0").toString(), allowance_units: BigInt(allowance || "0x0").toString() };
@@ -804,12 +902,13 @@ export class NativeConsumerState {
804
902
  }
805
903
 
806
904
  authorizeBearer(authorization, { shareOnly = false } = {}) {
807
- if (!shareOnly && sameSecret(authorization, `Bearer ${this.paymentKey}`)) return true;
905
+ if (!shareOnly && this.paymentUnlocked && sameSecret(authorization, `Bearer ${this.paymentKey}`)) return true;
808
906
  const share = this.activeShare();
809
907
  return Boolean(share && sameSecret(authorization, `Bearer ${share.apiKey}`));
810
908
  }
811
909
 
812
910
  async startShare(minutesValue) {
911
+ if (!this.paymentUnlocked) throw new Error("sign in and activate the payment key before sharing");
813
912
  const minutes = Number(minutesValue);
814
913
  if (!Number.isInteger(minutes) || minutes < 1 || minutes > MAX_SHARE_MINUTES) {
815
914
  throw new Error(`share duration must be between 1 and ${MAX_SHARE_MINUTES} minutes`);
@@ -888,7 +987,7 @@ export class NativeConsumerState {
888
987
  }
889
988
 
890
989
  async activatePendingPaymentKey(wallet) {
891
- const owner = normalizeAddress(wallet);
990
+ const owner = this.assertUnlockedWallet(wallet);
892
991
  const pending = this.pendingPaymentKey();
893
992
  if (!pending) throw new Error("no pending payment key exists");
894
993
  const grant = await this.keyGrant(pending.payment_key_address);
@@ -900,12 +999,13 @@ export class NativeConsumerState {
900
999
  unlinkSync(this.pendingKeyPath);
901
1000
  this.paymentKey = pending.payment_key;
902
1001
  this.paymentAddress = pending.payment_key_address;
903
- return { payment_key: this.paymentKey, payment_key_address: this.paymentAddress, previous_key_address: previous };
1002
+ this.paymentUnlocked = true;
1003
+ return { payment_key_address: this.paymentAddress, previous_key_address: previous };
904
1004
  }
905
1005
 
906
1006
  async transactionPlan(raw) {
907
1007
  const action = String(raw?.action || "");
908
- const wallet = normalizeAddress(raw?.wallet, "wallet");
1008
+ const wallet = this.assertUnlockedWallet(raw?.wallet);
909
1009
  const settlement = this.network.settlement_contract;
910
1010
  const token = this.network.stablecoin;
911
1011
  if (action === "top_up") {
@@ -1048,7 +1148,13 @@ export class NativeConsumerState {
1048
1148
  const paymentResponse = response.headers.get("PAYMENT-RESPONSE");
1049
1149
  if (paymentResponse) {
1050
1150
  const settlement = decodePaymentResponse(paymentResponse);
1051
- this.recordReceipt(selected.relayUrl, path, payment.model, settlement);
1151
+ this.recordReceipt(
1152
+ selected.relayUrl,
1153
+ path,
1154
+ String(body.model || payment.model),
1155
+ settlement,
1156
+ payment.model,
1157
+ );
1052
1158
  headers["PAYMENT-RESPONSE"] = paymentResponse;
1053
1159
  }
1054
1160
  return { payload: restoreClientResponse(payload, body), status: 200, headers };
@@ -1308,6 +1414,7 @@ export function createConsumerServer(state, { host = "127.0.0.1", port = 8110, p
1308
1414
  return;
1309
1415
  }
1310
1416
  if (request.method === "GET" && (path === "/credentials" || path === "/codex-env")) {
1417
+ if (!state.paymentUnlocked) { writeJson(response, 423, { ok: false, error: "sign in with the payment-key owner wallet first" }); return; }
1311
1418
  response.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }); response.end(`${state.credentialsText()}\n`); return;
1312
1419
  }
1313
1420
  if (request.method === "GET" && (path === "/models" || path === "/v1/models" || path === "/backend-api/codex/models")) {
@@ -1315,31 +1422,54 @@ export function createConsumerServer(state, { host = "127.0.0.1", port = 8110, p
1315
1422
  catch (error) { writeJson(response, 503, openaiError(error.message, "relay_unavailable")); }
1316
1423
  return;
1317
1424
  }
1318
- if (request.method === "GET" && path === "/v1/mycomesh/local/dashboard") { writeJson(response, 200, await state.dashboardPayload(url.searchParams.get("wallet"))); return; }
1425
+ if (request.method === "POST" && path === "/v1/mycomesh/local/wallet/challenge") {
1426
+ try { const value = await decodeRequestBody(request); writeJson(response, 200, state.createWalletChallenge(value.wallet)); }
1427
+ catch (error) { writeJson(response, 400, { ok: false, error: error.message }); }
1428
+ return;
1429
+ }
1430
+ if (request.method === "POST" && path === "/v1/mycomesh/local/wallet/authenticate") {
1431
+ try { writeJson(response, 200, await state.authenticateWallet(await decodeRequestBody(request))); }
1432
+ catch (error) { writeJson(response, 401, { ok: false, error: error.message }); }
1433
+ return;
1434
+ }
1435
+ if (request.method === "GET" && path === "/v1/mycomesh/local/dashboard") {
1436
+ writeJson(response, 200, await state.dashboardPayload(state.authorizeManagement(String(request.headers.authorization || "")))); return;
1437
+ }
1438
+ if (request.method === "POST" && path === "/v1/mycomesh/local/wallet/activate") {
1439
+ if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
1440
+ try { writeJson(response, 200, await state.activateCurrentPaymentKey()); }
1441
+ catch (error) { writeJson(response, 400, { ok: false, error: error.message }); }
1442
+ return;
1443
+ }
1444
+ if (request.method === "POST" && path === "/v1/mycomesh/local/wallet/lock") {
1445
+ if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
1446
+ writeJson(response, 200, await state.lockWallet()); return;
1447
+ }
1319
1448
  if (request.method === "POST" && path === "/v1/mycomesh/local/transactions") {
1449
+ if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
1320
1450
  try { writeJson(response, 200, await state.transactionPlan(await decodeRequestBody(request))); }
1321
1451
  catch (error) { writeJson(response, 400, { ok: false, error: error.message }); }
1322
1452
  return;
1323
1453
  }
1324
1454
  if (request.method === "POST" && path === "/v1/mycomesh/local/key/prepare") {
1325
- if (!sameSecret(String(request.headers.authorization || ""), `Bearer ${state.paymentKey}`)) { writeJson(response, 401, { ok: false, error: "invalid local payment key" }); return; }
1455
+ if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
1326
1456
  try { writeJson(response, 200, state.preparePaymentKey()); } catch (error) { writeJson(response, 400, { ok: false, error: error.message }); }
1327
1457
  return;
1328
1458
  }
1329
1459
  if (request.method === "POST" && path === "/v1/mycomesh/local/key/activate") {
1330
- if (!sameSecret(String(request.headers.authorization || ""), `Bearer ${state.paymentKey}`)) { writeJson(response, 401, { ok: false, error: "invalid local payment key" }); return; }
1460
+ if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
1331
1461
  try { const value = await decodeRequestBody(request); writeJson(response, 200, await state.activatePendingPaymentKey(value.wallet)); }
1332
1462
  catch (error) { writeJson(response, 400, { ok: false, error: error.message }); }
1333
1463
  return;
1334
1464
  }
1335
1465
  if (request.method === "POST" && path === "/v1/mycomesh/local/share/start") {
1336
- if (!sameSecret(String(request.headers.authorization || ""), `Bearer ${state.paymentKey}`)) { writeJson(response, 401, { ok: false, error: "invalid local payment key" }); return; }
1466
+ if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
1337
1467
  try { const value = await decodeRequestBody(request); writeJson(response, 200, { ok: true, share: await state.startShare(value.minutes) }); }
1338
1468
  catch (error) { writeJson(response, 400, { ok: false, error: error.message }); }
1339
1469
  return;
1340
1470
  }
1341
1471
  if (request.method === "POST" && path === "/v1/mycomesh/local/share/stop") {
1342
- if (!sameSecret(String(request.headers.authorization || ""), `Bearer ${state.paymentKey}`)) { writeJson(response, 401, { ok: false, error: "invalid local payment key" }); return; }
1472
+ if (!state.authorizeManagement(String(request.headers.authorization || ""))) { writeJson(response, 401, { ok: false, error: "wallet login required" }); return; }
1343
1473
  writeJson(response, 200, { ok: true, share: await state.stopShare() });
1344
1474
  return;
1345
1475
  }
@@ -1465,44 +1595,48 @@ export function chatCompletionSse(payload, includeUsage = false) {
1465
1595
 
1466
1596
  function consumerHtml() {
1467
1597
  return `<!doctype html>
1468
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
1469
- <title>MycoMesh Consumer</title>
1598
+ <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light">
1599
+ <title>Consumer | MycoMesh</title>
1470
1600
  <style>
1471
- body{font:15px system-ui;max-width:960px;margin:32px auto;padding:0 18px;color:#17211d;background:#f5f7f5}
1472
- h1{margin-bottom:4px}.muted{color:#68736e}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px}
1473
- .panel{background:white;border:1px solid #d9e0dc;border-radius:6px;padding:18px;margin:14px 0}.value{word-break:break-all;background:#f1f4f2;padding:10px;border-radius:4px;font:12px monospace;white-space:pre-wrap}
1474
- button{padding:9px 12px;border:1px solid #177b57;border-radius:4px;background:#177b57;color:#fff;cursor:pointer}input,select{padding:9px;border:1px solid #c7d0ca;border-radius:4px;background:#fff}.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
1475
- table{width:100%;border-collapse:collapse}td,th{padding:8px;border-bottom:1px solid #e1e7e3;text-align:left;font-size:13px}
1601
+ :root{--ink:#17211d;--muted:#68736e;--line:#d8dfdb;--soft:#f2f5f3;--paper:#fff;--green:#147553;--green-dark:#0d5b40;--amber:#9a6413;--red:#ae3d38;--blue:#365d86}*{box-sizing:border-box}[hidden]{display:none!important}html{background:#edf1ee}body{min-width:320px;margin:0;color:var(--ink);background:#edf1ee;font:14px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:0}button,input,select{font:inherit;letter-spacing:0}button{cursor:pointer}.shell{min-height:100vh}.topbar{position:sticky;z-index:10;top:0;display:flex;min-height:60px;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);background:rgba(255,255,255,.97);padding:0 max(18px,env(safe-area-inset-left))}.brand{display:flex;align-items:center;gap:10px;font-weight:780}.mark{display:grid;width:30px;height:30px;place-items:center;border-radius:6px;background:var(--ink);color:#fff;font-size:12px}.network{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px}.dot{width:7px;height:7px;border-radius:50%;background:#9ba49f}.dot.ok{background:var(--green)}.workspace{width:min(760px,100%);margin:0 auto;background:var(--paper);min-height:calc(100vh - 60px)}.locked{display:grid;min-height:calc(100vh - 60px);align-content:center;padding:42px 24px 88px}.locked-inner{width:min(420px,100%);margin:0 auto}.locked-badge{display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:999px;padding:5px 10px;color:var(--muted);font-size:12px}.locked h1{margin:20px 0 9px;font-size:30px;line-height:1.16}.locked p{margin:0 0 26px;color:var(--muted)}.key-preview{border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:14px 0;margin:0 0 22px}.label{display:block;color:var(--muted);font-size:11px;font-weight:700;text-transform:uppercase}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}.key-preview .mono{display:block;margin-top:5px;font-size:12px}.button{display:inline-flex;min-height:42px;align-items:center;justify-content:center;border:1px solid var(--line);border-radius:6px;background:var(--paper);padding:0 15px;color:var(--ink);font-weight:700}.button:hover{border-color:#95a39b;background:#f8faf9}.button.primary{border-color:var(--green);background:var(--green);color:#fff}.button.primary:hover{background:var(--green-dark)}.button.danger{border-color:#e5b6b3;color:var(--red)}.button.small{min-height:34px;padding:0 11px;font-size:12px}.button:disabled{cursor:not-allowed;opacity:.52}.locked .button{width:100%;min-height:48px}.app-head{padding:24px 20px 17px}.app-head-row{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.app-head h1{margin:0;font-size:22px}.wallet-button{max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.balance{margin-top:22px}.balance strong{display:block;margin-top:4px;font-size:31px;font-weight:760}.balance-meta{display:flex;gap:16px;margin-top:8px;color:var(--muted);font-size:12px}.tabs{position:sticky;z-index:8;top:60px;display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:rgba(255,255,255,.97);padding:0 12px}.tab{min-width:0;border:0;border-bottom:2px solid transparent;background:transparent;padding:12px 4px;color:var(--muted);font-weight:650}.tab.active{border-color:var(--green);color:var(--ink)}.view{padding:2px 20px 84px}.band{border-top:1px solid var(--line);padding:23px 0}.band:first-child{border-top:0}.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}.section-head h2{margin:0;font-size:16px}.section-head p{margin:3px 0 0;color:var(--muted);font-size:12px}.status{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:999px;padding:4px 9px;color:var(--muted);font-size:11px;white-space:nowrap}.status:before{width:6px;height:6px;border-radius:50%;background:var(--amber);content:""}.status.ok:before{background:var(--green)}.field+.field{margin-top:13px}.field-row{display:flex;align-items:stretch;gap:7px;margin-top:6px}.value{min-width:0;flex:1;border:1px solid var(--line);border-radius:5px;background:var(--soft);padding:10px 11px;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}.value.exports{min-height:76px;white-space:pre-wrap}.metrics{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--line);border-radius:6px;overflow:hidden}.metric{min-width:0;padding:14px 10px;border-right:1px solid var(--line)}.metric:last-child{border-right:0}.metric span{display:block;color:var(--muted);font-size:11px}.metric strong{display:block;overflow:hidden;margin-top:4px;font-size:17px;text-overflow:ellipsis}.list{margin:0}.list div{display:grid;grid-template-columns:104px minmax(0,1fr);gap:12px;border-bottom:1px solid var(--line);padding:12px 0}.list div:last-child{border-bottom:0}.list dt{color:var(--muted)}.list dd{overflow:hidden;margin:0;text-align:right;text-overflow:ellipsis;white-space:nowrap}.actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}.topup{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px}.input,.select{width:100%;min-height:42px;border:1px solid var(--line);border-radius:5px;background:#fff;padding:8px 10px;color:var(--ink)}.input:focus,.select:focus{border-color:var(--green);outline:2px solid rgba(20,117,83,.13)}.share-output{margin-top:16px}.empty{padding:34px 0;color:var(--muted);text-align:center}.table-wrap{overflow-x:auto;border-top:1px solid var(--line)}table{width:100%;min-width:620px;border-collapse:collapse}th,td{padding:11px 8px;border-bottom:1px solid var(--line);text-align:left;font-size:12px}th{color:var(--muted);font-weight:650}.notice{border-left:3px solid var(--amber);background:#fff8e9;padding:10px 12px;color:#76511c;font-size:12px}.notice.error{border-color:var(--red);background:#fff3f2;color:#892f2b}.toast{position:fixed;z-index:30;right:16px;bottom:calc(18px + env(safe-area-inset-bottom));left:16px;max-width:520px;margin:auto;border-radius:6px;background:var(--ink);padding:11px 14px;color:#fff;box-shadow:0 10px 30px rgba(23,33,29,.2);text-align:center}.toast.error{background:#7e2925}@media(min-width:761px){body{padding:22px}.shell{border:1px solid var(--line);border-radius:8px;overflow:hidden;box-shadow:0 18px 50px rgba(23,33,29,.08)}.workspace{min-height:calc(100vh - 106px)}.topbar{position:relative}.tabs{top:0}.view,.app-head{padding-right:28px;padding-left:28px}.toast{bottom:28px}}@media(max-width:420px){.balance strong{font-size:27px}.metrics{grid-template-columns:1fr}.metric{display:flex;align-items:center;justify-content:space-between;border-right:0;border-bottom:1px solid var(--line)}.metric:last-child{border-bottom:0}.metric strong{margin:0}.field-row{flex-direction:column}.field-row .button{width:100%}.topup{grid-template-columns:1fr}.topup .button{width:100%}}
1476
1602
  </style></head><body>
1477
- <h1>MycoMesh Consumer V8</h1><p class="muted">Native Node runtime. This page manages credentials, prepaid balance, payment keys, and local consumption history.</p>
1478
- <div class="grid"><section class="panel"><h2>Credentials</h2><p>API URL</p><div id="url" class="value">Loading...</div><p>Payment key</p><div id="key" class="value"></div><p>Shell export</p><div id="export" class="value"></div><button id="copy" type="button">Copy export</button></section>
1479
- <section class="panel"><h2>Usage</h2><p id="usage">Loading...</p><p id="balance"></p><p>Key address</p><div id="address" class="value"></div><div class="row"><button id="register" type="button">Register key</button><button id="rotate" type="button">Rotate key</button></div></section></div>
1480
- <section class="panel"><h2>Prepaid top-up</h2><div class="row"><button id="connect" type="button">Connect wallet</button><input id="wallet" placeholder="Wallet address"><input id="amount" placeholder="10.00"><button id="topup" type="button">Top up</button></div><pre id="plan" class="value"></pre></section>
1481
- <section class="panel"><h2>Temporary share</h2><div class="row"><select id="shareMinutes"><option value="10">10 minutes</option><option value="30" selected>30 minutes</option><option value="60">1 hour</option><option value="360">6 hours</option></select><button id="shareStart" type="button">Start sharing</button><button id="shareStop" type="button">Stop</button></div><p>API URL</p><div id="shareUrl" class="value">Inactive</div><p>Temporary key</p><div id="shareKey" class="value"></div><p id="shareExpiry" class="muted"></p><button id="shareCopy" type="button">Copy export</button></section>
1482
- <section class="panel"><h2>Consumption history</h2><table><thead><tr><th>Time</th><th>Model</th><th>Tokens</th><th>Fee</th><th>Relay</th></tr></thead><tbody id="history"></tbody></table></section>
1603
+ <div class="shell"><header class="topbar"><div class="brand"><span class="mark">M</span><span>MycoMesh</span></div><div class="network"><span id="networkDot" class="dot"></span><span id="networkName">V8 Consumer</span></div></header><main class="workspace">
1604
+ <div id="locked" class="locked"><div class="locked-inner"><span class="locked-badge">Consumer V8</span><h1>连接钱包以继续</h1><p>签名后读取并核对当前 Key 的链上归属。</p><div class="key-preview"><span class="label">本地 Key 地址</span><span id="lockedKey" class="mono">读取中...</span></div><button id="login" class="button primary" type="button">连接钱包并签名</button><p id="loginError" class="notice error" hidden></p></div></div>
1605
+ <div id="app" hidden><div class="app-head"><div class="app-head-row"><div><span class="label">预付账户</span><h1>Consumer</h1></div><button id="walletButton" class="button small wallet-button" type="button">退出</button></div><div class="balance"><span class="label">可用余额</span><strong id="balance">--</strong><div class="balance-meta"><span id="keyStatus">Key 状态 --</span><span id="requestCount">0 次请求</span></div></div></div>
1606
+ <nav class="tabs" aria-label="Consumer navigation"><button class="tab active" data-view="overview" type="button">概览</button><button class="tab" data-view="wallet" type="button">钱包</button><button class="tab" data-view="activity" type="button">记录</button><button class="tab" data-view="share" type="button">分享</button></nav>
1607
+ <div id="view-overview" class="view"><div class="band"><div class="section-head"><div><h2>访问凭证</h2><p id="credentialState">等待 Key 激活</p></div><span id="credentialBadge" class="status">锁定</span></div><div id="credentials" hidden><div class="field"><span class="label">API URL</span><div class="field-row"><div id="url" class="value"></div><button class="button small copy" data-copy="url" type="button">复制</button></div></div><div class="field"><span class="label">Key</span><div class="field-row"><div id="key" class="value"></div><button class="button small copy" data-copy="key" type="button">复制</button></div></div><div class="field"><span class="label">Export</span><div class="field-row"><div id="export" class="value exports"></div><button class="button small copy" data-copy="export" type="button">复制</button></div></div></div><div id="inactiveKey" class="notice">当前 Key 尚未在链上激活。</div></div><div class="band"><div class="section-head"><h2>本地用量</h2></div><div class="metrics"><div class="metric"><span>累计消费</span><strong id="spent">--</strong></div><div class="metric"><span>输入 Tokens</span><strong id="inputTokens">0</strong></div><div class="metric"><span>输出 Tokens</span><strong id="outputTokens">0</strong></div></div></div></div>
1608
+ <div id="view-wallet" class="view" hidden><div class="band"><div class="section-head"><h2>钱包与 Key</h2><span id="chainStatus" class="status">读取中</span></div><dl class="list"><div><dt>钱包</dt><dd id="walletAddress" class="mono"></dd></div><div><dt>Key 地址</dt><dd id="keyAddress" class="mono"></dd></div><div><dt>单次上限</dt><dd id="keyLimit"></dd></div><div><dt>有效期</dt><dd id="keyValidity"></dd></div></dl><p id="chainError" class="notice error" hidden></p><div class="actions"><button id="activate" class="button primary" type="button">激活 Key</button><button id="rotate" class="button danger" type="button" hidden>更换 Key</button></div></div><div class="band"><div class="section-head"><div><h2>充值</h2><p id="walletBalance">钱包余额 --</p></div></div><div class="topup"><input id="amount" class="input" inputmode="decimal" placeholder="10.00 USDC"><button id="topup" class="button primary" type="button">充值</button></div></div></div>
1609
+ <div id="view-activity" class="view" hidden><div class="band"><div class="section-head"><div><h2>消费记录</h2><p>当前设备已确认的推理账单</p></div><button id="refresh" class="button small" type="button">刷新</button></div><div id="historyEmpty" class="empty">暂无消费记录</div><div id="historyTable" class="table-wrap" hidden><table><thead><tr><th>时间</th><th>模型</th><th>Tokens</th><th>费用</th><th>Provider</th></tr></thead><tbody id="history"></tbody></table></div></div></div>
1610
+ <div id="view-share" class="view" hidden><div class="band"><div class="section-head"><div><h2>临时分享</h2><p>到期后自动关闭</p></div><span id="shareStatus" class="status">未启用</span></div><div class="topup"><select id="shareMinutes" class="select"><option value="10">10 分钟</option><option value="30" selected>30 分钟</option><option value="60">1 小时</option><option value="360">6 小时</option></select><button id="shareStart" class="button primary" type="button">开始分享</button></div><div id="shareOutput" class="share-output" hidden><div class="field"><span class="label">API URL</span><div class="field-row"><div id="shareUrl" class="value"></div><button class="button small copy" data-copy="shareUrl" type="button">复制</button></div></div><div class="field"><span class="label">临时 Key</span><div class="field-row"><div id="shareKey" class="value"></div><button class="button small copy" data-copy="shareKey" type="button">复制</button></div></div><p id="shareExpiry" class="notice"></p><div class="actions"><button id="shareStop" class="button danger" type="button">停止分享</button></div></div></div></div></div>
1611
+ </main></div><div id="toast" class="toast" hidden></div>
1483
1612
  <script>
1484
- let state;let wallet;const $=id=>document.getElementById(id);
1485
- async function api(path,options={}){const r=await fetch(path,options);const v=await r.json();if(!r.ok)throw Error(typeof v.error==='string'?v.error:(v.error?.message||'request failed'));return v}
1486
- function render(){
1487
- $('url').textContent=state.credentials.base_url;$('key').textContent=state.credentials.api_key;$('export').textContent=state.credentials.export;$('address').textContent=state.key.address;
1488
- $('usage').textContent=state.usage.request_count+' requests, '+state.usage.total_spent_units+' units';$('balance').textContent='Available balance: '+(state.account?.available_balance_units||'unknown');
1489
- $('history').innerHTML=(state.history||[]).map(x=>'<tr><td>'+new Date(x.timestamp*1000).toLocaleString()+'</td><td>'+x.model+'</td><td>'+x.input_tokens+' / '+x.output_tokens+'</td><td>'+x.actual_fee_units+'</td><td>'+x.relay_url+'</td></tr>').join('')||'<tr><td colspan="5">No consumption recorded.</td></tr>';
1490
- $('shareUrl').textContent=state.share?.base_url||'Inactive';$('shareKey').textContent=state.share?.api_key||'';$('shareExpiry').textContent=state.share?.expires_at?'Expires '+new Date(state.share.expires_at*1000).toLocaleString():'';
1491
- }
1613
+ let state=null,wallet=null,managementToken=null,busy=false;const $=id=>document.getElementById(id);const short=value=>value?value.slice(0,6)+'...'+value.slice(-4):'--';
1614
+ function units(value,decimals=6){const raw=BigInt(value||0),base=10n**BigInt(decimals),whole=raw/base,fraction=(raw%base).toString().padStart(decimals,'0').replace(/0+$/,'');return whole.toLocaleString()+(fraction?'.'+fraction.slice(0,4):'')}
1615
+ function authHeaders(headers={}){const next=new Headers(headers);if(managementToken)next.set('authorization','Bearer '+managementToken);return next}
1616
+ async function api(path,options={}){const response=await fetch(path,{...options,headers:authHeaders(options.headers)}),data=await response.json();if(!response.ok)throw new Error(typeof data.error==='string'?data.error:(data.error?.message||'请求失败'));return data}
1617
+ function toast(message,error=false){const node=$('toast');node.textContent=message;node.className='toast'+(error?' error':'');node.hidden=false;clearTimeout(toast.timer);toast.timer=setTimeout(()=>node.hidden=true,3200)}
1618
+ function setBusy(value){busy=value;for(const button of document.querySelectorAll('button'))button.disabled=value}
1619
+ async function run(task){if(busy)return;setBusy(true);try{await task()}catch(error){toast(error?.message||String(error),true)}finally{setBusy(false)}}
1492
1620
  async function load(){state=await api('/v1/mycomesh/local/dashboard');render()}
1493
- async function connectWallet(){if(!window.ethereum)throw Error('No injected wallet found');const accounts=await window.ethereum.request({method:'eth_requestAccounts'});wallet=accounts[0];if(!wallet)throw Error('Wallet was not connected');$('wallet').value=wallet;$('connect').textContent=wallet.slice(0,6)+'...'+wallet.slice(-4)}
1494
- async function requireWallet(){if(!wallet)await connectWallet();return wallet}
1495
- async function waitReceipt(hash){for(let i=0;i<120;i++){const receipt=await window.ethereum.request({method:'eth_getTransactionReceipt',params:[hash]});if(receipt){if(receipt.status!=='0x1')throw Error('Chain transaction failed');return}await new Promise(resolve=>setTimeout(resolve,1500))}throw Error('Timed out waiting for chain confirmation')}
1496
- async function sendPlan(plan){for(const transaction of plan.transactions){const hash=await window.ethereum.request({method:'eth_sendTransaction',params:[{from:wallet,to:transaction.to,data:transaction.data}]});await waitReceipt(hash)}return plan}
1497
- async function showPlan(action,send=false){try{await requireWallet();const plan=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action,wallet,key_address:state.key.address,amount_usdc:$('amount').value})});$('plan').textContent=JSON.stringify(plan,null,2);if(send){await sendPlan(plan);await load();$('plan').textContent='Transactions confirmed. '+JSON.stringify(plan,null,2)}}catch(error){$('plan').textContent=error.message}}
1498
- $('copy').onclick=()=>navigator.clipboard?.writeText($('export').textContent);
1499
- $('connect').onclick=()=>connectWallet().catch(error=>$('plan').textContent=error.message);
1500
- $('register').onclick=()=>showPlan('register_key',true);$('topup').onclick=()=>showPlan('top_up',true);
1501
- $('rotate').onclick=async()=>{try{await requireWallet();const oldAddress=state.key.address;const pending=await api('/v1/mycomesh/local/key/prepare',{method:'POST',headers:{authorization:'Bearer '+state.credentials.api_key}});const plan=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'register_key',wallet})});await sendPlan(plan);let activated;for(let i=0;i<8&&!activated;i++){try{activated=await api('/v1/mycomesh/local/key/activate',{method:'POST',headers:{authorization:'Bearer '+state.credentials.api_key,'content-type':'application/json'},body:JSON.stringify({wallet})})}catch(error){if(i===7)throw error;await new Promise(resolve=>setTimeout(resolve,1800))}}const revoke=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'revoke_key',wallet,key_address:oldAddress})});await sendPlan(revoke);$('plan').textContent=JSON.stringify({pending,activated,revoke},null,2);await load()}catch(error){$('plan').textContent=error.message}};
1502
- $('shareStart').onclick=async()=>{const button=$('shareStart');button.disabled=true;button.textContent='Starting...';try{const v=await api('/v1/mycomesh/local/share/start',{method:'POST',headers:{authorization:'Bearer '+state.credentials.api_key,'content-type':'application/json'},body:JSON.stringify({minutes:Number($('shareMinutes').value)})});state.share=v.share;render()}catch(error){$('shareExpiry').textContent=error.message}finally{button.disabled=false;button.textContent='Start sharing'}};
1503
- $('shareStop').onclick=async()=>{try{const v=await api('/v1/mycomesh/local/share/stop',{method:'POST',headers:{authorization:'Bearer '+state.credentials.api_key}});state.share=v.share;render()}catch(error){$('shareExpiry').textContent=error.message}};
1504
- $('shareCopy').onclick=()=>navigator.clipboard?.writeText("export OPENAI_BASE_URL='"+$('shareUrl').textContent+"'\\nexport OPENAI_API_KEY='"+$('shareKey').textContent+"'");
1505
- load().catch(error=>$('plan').textContent=error.message);
1621
+ function render(){const authenticated=Boolean(managementToken&&state.auth?.authenticated),ready=authenticated&&state.auth.key_ready,grant=state.key.grant||{},decimals=state.settlement?.stablecoin_decimals||6,symbol=state.settlement?.stablecoin_symbol||'USDC';$('networkName').textContent=state.chain_error?'链上不可用':(state.settlement?.network_name||'V8 Consumer');$('networkDot').className='dot'+(state.chain_error?'':' ok');$('lockedKey').textContent=state.key.address;$('locked').hidden=authenticated;$('app').hidden=!authenticated;if(!authenticated)return;$('walletButton').textContent=short(state.auth.wallet);$('walletAddress').textContent=state.auth.wallet;$('keyAddress').textContent=state.key.address;$('balance').textContent=state.account?units(state.account.available_balance_units,decimals)+' '+symbol:'--';$('requestCount').textContent=state.usage.request_count+' 次请求';$('keyStatus').textContent=ready?'Key 已激活':'Key 待激活';$('credentialBadge').className='status'+(ready?' ok':'');$('credentialBadge').textContent=ready?'可用':'待激活';$('credentialState').textContent=ready?'仅在本机显示':'链上确认后显示';$('credentials').hidden=!ready;$('inactiveKey').hidden=ready;if(ready){$('url').textContent=state.credentials.base_url;$('key').textContent=state.credentials.api_key;$('export').textContent=state.credentials.export}$('spent').textContent=units(state.usage.total_spent_units,decimals)+' '+symbol;$('inputTokens').textContent=Number(state.usage.input_tokens||0).toLocaleString();$('outputTokens').textContent=Number(state.usage.output_tokens||0).toLocaleString();$('keyLimit').textContent=grant.max_per_request?units(grant.max_per_request,decimals)+' '+symbol:'--';$('keyValidity').textContent=grant.valid_until?new Date(grant.valid_until*1000).toLocaleString():'长期有效';$('chainStatus').className='status'+(grant.active?' ok':'');$('chainStatus').textContent=grant.active?'链上有效':'等待激活';$('activate').hidden=ready;$('rotate').hidden=!ready;$('chainError').hidden=!state.chain_error;$('chainError').textContent=state.chain_error||'';$('walletBalance').textContent=state.wallet?'钱包余额 '+units(state.wallet.token_balance_units,decimals)+' '+symbol:'钱包余额 --';renderHistory();renderShare()}
1622
+ function renderHistory(){const body=$('history'),items=state.history||[];body.replaceChildren();$('historyEmpty').hidden=items.length>0;$('historyTable').hidden=items.length===0;for(const item of items){const row=document.createElement('tr');for(const value of [new Date(item.timestamp*1000).toLocaleString(),item.model,(item.input_tokens||0)+' / '+(item.output_tokens||0),units(item.actual_fee_units,state.settlement?.stablecoin_decimals||6),short(item.provider)]){const cell=document.createElement('td');cell.textContent=value;row.appendChild(cell)}body.appendChild(row)}}
1623
+ function renderShare(){const share=state.share||{};$('shareStatus').className='status'+(share.active?' ok':'');$('shareStatus').textContent=share.active?'分享中':'未启用';$('shareOutput').hidden=!share.active;if(share.active){$('shareUrl').textContent=share.base_url;$('shareKey').textContent=share.api_key;$('shareExpiry').textContent='到期时间 '+new Date(share.expires_at*1000).toLocaleString()}}
1624
+ function walletMessage(value){return '0x'+Array.from(new TextEncoder().encode(value),byte=>byte.toString(16).padStart(2,'0')).join('')}
1625
+ async function login(){if(!window.ethereum)throw new Error('未检测到浏览器钱包');const accounts=await window.ethereum.request({method:'eth_requestAccounts'});wallet=String(accounts[0]||'').toLowerCase();if(!wallet)throw new Error('钱包未连接');const challenge=await api('/v1/mycomesh/local/wallet/challenge',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({wallet})});const signature=await window.ethereum.request({method:'personal_sign',params:[walletMessage(challenge.message),wallet]});const result=await api('/v1/mycomesh/local/wallet/authenticate',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({wallet,signature})});managementToken=result.token;await load();toast(state.auth.key_ready?'钱包验证完成':'钱包验证完成,请激活 Key')}
1626
+ async function ensureChain(){const expected='0x'+Number(state.settlement.chain_id).toString(16),current=await window.ethereum.request({method:'eth_chainId'});if(current.toLowerCase()!==expected.toLowerCase())await window.ethereum.request({method:'wallet_switchEthereumChain',params:[{chainId:expected}]})}
1627
+ async function waitReceipt(hash){for(let count=0;count<120;count++){const receipt=await window.ethereum.request({method:'eth_getTransactionReceipt',params:[hash]});if(receipt){if(receipt.status!=='0x1')throw new Error('链上交易失败');return receipt}await new Promise(resolve=>setTimeout(resolve,1500))}throw new Error('等待链上确认超时')}
1628
+ async function sendPlan(plan){await ensureChain();for(const transaction of plan.transactions){toast(transaction.label);const hash=await window.ethereum.request({method:'eth_sendTransaction',params:[{from:wallet,to:transaction.to,data:transaction.data}]});await waitReceipt(hash)}}
1629
+ async function activateCurrent(){const plan=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'register_key',wallet})});await sendPlan(plan);for(let count=0;count<10;count++){try{await api('/v1/mycomesh/local/wallet/activate',{method:'POST'});await load();toast('Key 已激活');return}catch(error){if(count===9)throw error;await new Promise(resolve=>setTimeout(resolve,1600))}}}
1630
+ async function rotateKey(){const oldAddress=state.key.address;await api('/v1/mycomesh/local/key/prepare',{method:'POST'});const register=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'register_key',wallet})});await sendPlan(register);for(let count=0;count<10;count++){try{await api('/v1/mycomesh/local/key/activate',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({wallet})});break}catch(error){if(count===9)throw error;await new Promise(resolve=>setTimeout(resolve,1600))}}const revoke=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'revoke_key',wallet,key_address:oldAddress})});await sendPlan(revoke);await load();toast('新 Key 已启用,旧 Key 已撤销')}
1631
+ document.querySelectorAll('.tab').forEach(tab=>tab.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(item=>item.classList.toggle('active',item===tab));document.querySelectorAll('.view').forEach(view=>view.hidden=view.id!=='view-'+tab.dataset.view)}));
1632
+ document.addEventListener('click',event=>{const button=event.target.closest('.copy');if(button)navigator.clipboard.writeText($(button.dataset.copy).textContent).then(()=>toast('已复制')).catch(()=>toast('复制失败',true))});
1633
+ $('login').onclick=()=>run(login);$('activate').onclick=()=>run(activateCurrent);$('rotate').onclick=()=>run(rotateKey);$('refresh').onclick=()=>run(load);
1634
+ $('topup').onclick=()=>run(async()=>{const plan=await api('/v1/mycomesh/local/transactions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:'top_up',wallet,amount_usdc:$('amount').value.trim()})});await sendPlan(plan);$('amount').value='';await load();toast('充值已确认')});
1635
+ $('shareStart').onclick=()=>run(async()=>{const value=await api('/v1/mycomesh/local/share/start',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({minutes:Number($('shareMinutes').value)})});state.share=value.share;renderShare();toast('临时分享已开启')});
1636
+ $('shareStop').onclick=()=>run(async()=>{const value=await api('/v1/mycomesh/local/share/stop',{method:'POST'});state.share=value.share;renderShare();toast('临时分享已停止')});
1637
+ $('walletButton').onclick=()=>run(async()=>{await api('/v1/mycomesh/local/wallet/lock',{method:'POST'});managementToken=null;wallet=null;await load()});
1638
+ window.ethereum?.on?.('accountsChanged',()=>{if(managementToken)api('/v1/mycomesh/local/wallet/lock',{method:'POST'}).catch(()=>{}).finally(()=>{managementToken=null;wallet=null;load().catch(error=>toast(error.message,true))})});
1639
+ load().catch(error=>{$('loginError').hidden=false;$('loginError').textContent=error.message});
1506
1640
  </script></body></html>`;
1507
1641
  }
1508
1642
 
package/src/consumer.mjs CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  createConsumerServer,
12
12
  } from "./consumer-runtime.mjs";
13
13
 
14
- export const CONSUMER_RELEASE_VERSION = "0.1.49";
14
+ export const CONSUMER_RELEASE_VERSION = "0.1.51";
15
15
  export const API_COMMANDS = new Set(["health", "models", "responses", "chat"]);
16
16
  const API_VALUE_OPTIONS = new Set([
17
17
  "--base-url",
@@ -52,9 +52,9 @@ Options:
52
52
  -h, --help Show this help
53
53
  -v, --version Show the package version
54
54
 
55
- The browser page displays only the export URL/key, prepaid balance, key
56
- operations, and local consumption history. No browser conversation state is
57
- created.`;
55
+ Each Consumer process starts locked. Open the browser page, sign with the
56
+ wallet that owns the on-chain payment-key grant, then use the displayed export.
57
+ The page has no browser conversation state.`;
58
58
 
59
59
  class ConsumerCliError extends Error {
60
60
  constructor(message, exitCode = 1) {
@@ -105,7 +105,7 @@ export async function main(argv, dependencies = {}) {
105
105
  const rootUrl = `${parsed.scheme}://${parsed.hostForUrl}:${parsed.port}`;
106
106
  const credentialsUrl = `${rootUrl}/`;
107
107
  writePid(parsed.dataDir);
108
- stdout.write(`MycoMesh Consumer credentials: ${credentialsUrl}\n`);
108
+ stdout.write(`MycoMesh Consumer setup: ${credentialsUrl}\n`);
109
109
  stdout.write(`OpenAI API: ${state.baseUrl}\n`);
110
110
  if (!parsed.noBrowser) openBrowser(credentialsUrl, stderr);
111
111
 
@@ -119,6 +119,7 @@ export async function main(argv, dependencies = {}) {
119
119
  return 0;
120
120
  }
121
121
  await waitUntilReady(state, parsed.readyTimeout, stdout);
122
+ await waitUntilUnlocked(state, parsed.readyTimeout, stdout);
122
123
  const code = await runCodex(parsed, state, dependencies.spawn ?? defaultSpawn, stdout, stderr);
123
124
  await shutdown();
124
125
  return code;
@@ -221,6 +222,17 @@ async function waitUntilReady(state, timeoutSeconds, stdout) {
221
222
  }
222
223
  }
223
224
 
225
+ async function waitUntilUnlocked(state, timeoutSeconds, stdout) {
226
+ const started = Date.now();
227
+ stdout.write("Waiting for wallet verification in the local Consumer page...\n");
228
+ while (!state.paymentUnlocked) {
229
+ if (Date.now() - started >= timeoutSeconds * 1000) {
230
+ throw new ConsumerCliError("timed out waiting for the payment-key owner wallet to unlock Consumer");
231
+ }
232
+ await new Promise((resolve) => setTimeout(resolve, 1000));
233
+ }
234
+ }
235
+
224
236
  function runCodex(parsed, state, spawn, stdout, stderr) {
225
237
  return new Promise((resolve) => {
226
238
  const args = [