mycomesh-consumer 0.3.0 → 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.
- package/README.md +19 -13
- package/package.json +2 -1
- package/src/account.mjs +93 -0
- package/src/cli.mjs +36 -52
- package/src/console.mjs +86 -0
- package/src/consumer.mjs +17 -2
- package/src/disputes.mjs +29 -1
- package/src/wallet.mjs +10 -2
- package/src/web/console.html +282 -0
package/README.md
CHANGED
|
@@ -8,26 +8,32 @@ Requires Node.js 20 or newer. No Docker or Python.
|
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
npm install --global mycomesh-consumer
|
|
11
|
-
mycomesh-consumer init
|
|
12
|
-
mycomesh-consumer setup --
|
|
13
|
-
mycomesh-consumer request "What is MycoMesh?"
|
|
14
|
-
mycomesh-consumer serve
|
|
11
|
+
mycomesh-consumer init # payment key + password-protected owner wallet
|
|
12
|
+
mycomesh-consumer setup --deposit 20000000
|
|
13
|
+
mycomesh-consumer request --stream "What is MycoMesh?"
|
|
14
|
+
mycomesh-consumer serve # http://127.0.0.1:8110/v1 (responses, chat/completions, models; live streaming)
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
- `init` creates the payment key in `~/.mycomesh/v11
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
- `init` creates the payment key and an owner wallet in `~/.mycomesh/v11`. The wallet is an
|
|
18
|
+
Ethereum V3 keystore (MetaMask can import it) protected by a password from
|
|
19
|
+
`MYCOMESH_WALLET_PASSWORD` or a terminal prompt; `--owner-key-file` uses your own key instead.
|
|
20
|
+
- `setup --deposit UNITS` deposits tUSDC and authorizes the payment key up to `--max-per-request`
|
|
21
|
+
units per request. On the testnet it first tops the wallet up from the faucet (gas ETH and tUSDC);
|
|
22
|
+
`faucet` does that on its own.
|
|
23
|
+
- `balance` shows the deposit.
|
|
22
24
|
- `request "..." [--model gpt-5.5] [--provider SIGNER]` prints the verified answer,
|
|
23
25
|
the fee and the settlement key.
|
|
24
|
-
- `serve` runs
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
- `serve` runs the local web console at `http://127.0.0.1:8110/` (chat, wallet, deposit and
|
|
27
|
+
withdrawal, request history and disputes, network status) and an OpenAI-compatible endpoint for
|
|
28
|
+
Codex and other clients; set `MYCOMESH_CONSUMER_API_KEY` to require a bearer token. Only pages from
|
|
29
|
+
this machine can call it: other websites and DNS-rebinding hosts are refused.
|
|
30
|
+
- `withdraw [UNITS]` requests a withdrawal; run it again after the delay to receive the funds.
|
|
31
|
+
- `dispute <settlement-key|last> --statement "..."`
|
|
27
32
|
reveals a recorded request and response to a randomly drawn Provider-AI jury
|
|
28
33
|
within the 24-hour dispute window. The 1 tUSDC reporter bond is returned and a
|
|
29
34
|
bounty paid when fraud is confirmed.
|
|
30
35
|
|
|
31
36
|
Each response is decrypted locally and checked against the Provider-signed
|
|
32
|
-
receipt before it is shown.
|
|
37
|
+
receipt before it is shown; streamed text is checked against it too. Relays come from the bundled
|
|
38
|
+
manifest and the on-chain Relay directory. The bundled network manifest is
|
|
33
39
|
`networks/mycomesh-v11-sepolia.json`; use `--network FILE` for another.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mycomesh-consumer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "MycoMesh V11 Consumer: a custodied deposit, relay-blind encrypted requests to any Provider, and a local OpenAI-compatible endpoint.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"bin/mycomesh-consumer.mjs",
|
|
18
18
|
"src/*.mjs",
|
|
19
|
+
"src/web/*.html",
|
|
19
20
|
"networks/mycomesh-v11-sepolia.json",
|
|
20
21
|
"networks/mycomesh-testnet-ca.crt",
|
|
21
22
|
"README.md"
|
package/src/account.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Consumer account operations shared by the CLI and the local web console.
|
|
2
|
+
import { addressOf, encodeCall } from "./eip712.mjs";
|
|
3
|
+
import { rpcCall, sendTransaction } from "./chain.mjs";
|
|
4
|
+
import { directoryRelays, httpJson } from "./consumer.mjs";
|
|
5
|
+
import { buildEvidence, evidenceHash, openDisputeCall, reportId } from "./disputes.mjs";
|
|
6
|
+
|
|
7
|
+
export const STATUSES = ["none", "pending", "disputed", "released", "confirmed", "dismissed", "timed_out", "jury_unavailable", "voided"];
|
|
8
|
+
|
|
9
|
+
async function words(network, to, signature, pairs = []) {
|
|
10
|
+
const raw = (await rpcCall(network.rpc_urls, "eth_call", [{ to, data: encodeCall(signature, pairs) }, "latest"])).slice(2);
|
|
11
|
+
return Array.from({ length: raw.length / 64 }, (_, i) => BigInt(`0x${raw.slice(i * 64, i * 64 + 64)}`));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Balances of the owner wallet and the payment key's grant; amounts are strings of base units. */
|
|
15
|
+
export async function walletStatus(network, owner, key) {
|
|
16
|
+
const [eth, [tokens], [deposit], withdrawal, grant, params] = await Promise.all([
|
|
17
|
+
rpcCall(network.rpc_urls, "eth_getBalance", [owner, "latest"]),
|
|
18
|
+
words(network, network.stablecoin, "balanceOf(address)", [["address", owner]]),
|
|
19
|
+
words(network, network.settlement, "availableBalance(address)", [["address", owner]]),
|
|
20
|
+
words(network, network.settlement, "withdrawals(address)", [["address", owner]]),
|
|
21
|
+
key ? words(network, network.settlement, "keyGrants(address)", [["address", key]]) : Promise.resolve(null),
|
|
22
|
+
words(network, network.settlement, "params()"),
|
|
23
|
+
]);
|
|
24
|
+
return {
|
|
25
|
+
owner, key, eth_wei: BigInt(eth).toString(), wallet_tokens: tokens.toString(), deposit: deposit.toString(),
|
|
26
|
+
withdrawal: { amount: withdrawal[0].toString(), available_at: Number(withdrawal[1]) },
|
|
27
|
+
grant: grant && { active: Boolean(grant[3]) && `0x${grant[0].toString(16).padStart(40, "0")}` === owner.toLowerCase(),
|
|
28
|
+
max_per_request: grant[1].toString() },
|
|
29
|
+
dispute_window: Number(params[0]), reporter_bond: params[3].toString(), faucet: Boolean(network.faucet_url),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function faucet(network, address) {
|
|
34
|
+
if (!network.faucet_url) throw new Error("this network has no faucet");
|
|
35
|
+
const reply = await httpJson(`${network.faucet_url}/v11/faucet`, { method: "POST", body: { address }, ca: network.tls_ca, timeoutMs: 400_000 });
|
|
36
|
+
if (reply.status !== 200) throw new Error(`faucet: ${reply.body?.error || reply.status}`);
|
|
37
|
+
return reply.body;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Deposit and grant the payment key; on testnets, top the wallet up from the faucet first. */
|
|
41
|
+
export async function setup(network, { ownerPrivate, key, deposit, limit, forceFaucet = false, log = () => {} }) {
|
|
42
|
+
const owner = addressOf(ownerPrivate);
|
|
43
|
+
const status = await walletStatus(network, owner, key);
|
|
44
|
+
if (network.faucet_url && (forceFaucet || BigInt(status.eth_wei) < 2_000_000_000_000_000n || BigInt(status.wallet_tokens) < deposit)) {
|
|
45
|
+
log(`funding ${owner} from the testnet faucet...`);
|
|
46
|
+
await faucet(network, owner);
|
|
47
|
+
}
|
|
48
|
+
const send = (to, data) => sendTransaction(network.rpc_urls, ownerPrivate, { to, data });
|
|
49
|
+
await send(network.stablecoin, encodeCall("approve(address,uint256)", [["address", network.settlement], ["uint", deposit]]));
|
|
50
|
+
await send(network.settlement, encodeCall("deposit(uint256)", [["uint", deposit]]));
|
|
51
|
+
if (!status.grant?.active || BigInt(status.grant.max_per_request) !== limit) {
|
|
52
|
+
await send(network.settlement, encodeCall("registerKey(address,uint256,uint64)", [["address", key], ["uint", limit], ["uint", 0]]));
|
|
53
|
+
}
|
|
54
|
+
return { owner, key, deposit: deposit.toString(), max_per_request: limit.toString() };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Withdrawals take two steps: request (funds stop backing requests), then withdraw after the delay. */
|
|
58
|
+
export async function requestWithdrawal(network, ownerPrivate, amount) {
|
|
59
|
+
await sendTransaction(network.rpc_urls, ownerPrivate, { to: network.settlement,
|
|
60
|
+
data: encodeCall("requestWithdrawal(uint256)", [["uint", amount]]) });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function completeWithdrawal(network, ownerPrivate) {
|
|
64
|
+
await sendTransaction(network.rpc_urls, ownerPrivate, { to: network.settlement, data: encodeCall("withdraw()", []) });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function settlementStatus(network, key) {
|
|
68
|
+
const fields = await words(network, network.settlement, "settlementInfo(bytes32)", [["bytes32", key]]);
|
|
69
|
+
return { status: STATUSES[Number(fields[14])] || "unknown", release_at: Number(fields[13]), fee: fields[10].toString() };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Commit the evidence on-chain (with the reporter bond), then hand it to every Relay's dispute desk. */
|
|
73
|
+
export async function dispute(network, ownerPrivate, record, { reasonCode, statement }) {
|
|
74
|
+
const evidence = buildEvidence(record, { reasonCode, statement });
|
|
75
|
+
const digest = evidenceHash(evidence);
|
|
76
|
+
const [bond] = (await words(network, network.settlement, "params()")).slice(3, 4);
|
|
77
|
+
if (bond > 0n) {
|
|
78
|
+
await sendTransaction(network.rpc_urls, ownerPrivate, { to: network.stablecoin,
|
|
79
|
+
data: encodeCall("approve(address,uint256)", [["address", network.settlement], ["uint", bond]]) });
|
|
80
|
+
}
|
|
81
|
+
await sendTransaction(network.rpc_urls, ownerPrivate, { to: network.settlement, data: openDisputeCall(record.settlement_key, digest) });
|
|
82
|
+
let relays = network.relays;
|
|
83
|
+
try { relays = [...relays, ...await directoryRelays(network)]; } catch {}
|
|
84
|
+
const accepted = [];
|
|
85
|
+
for (const relay of relays) {
|
|
86
|
+
try {
|
|
87
|
+
const reply = await httpJson(`${relay.url}/v11/evidence`, { method: "POST", body: evidence, ca: network.tls_ca, timeoutMs: 30_000 });
|
|
88
|
+
if (reply.status === 200) accepted.push(relay.url);
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
return { settlement_key: record.settlement_key, evidence_hash: digest,
|
|
92
|
+
report_id: reportId(record.settlement_key, addressOf(ownerPrivate), digest), relays_accepted: accepted };
|
|
93
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -5,11 +5,12 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { parseArgs } from "node:util";
|
|
8
|
-
import { addressOf,
|
|
9
|
-
import { availableBalance
|
|
10
|
-
import {
|
|
8
|
+
import { addressOf, settlementKey } from "./eip712.mjs";
|
|
9
|
+
import { availableBalance } from "./chain.mjs";
|
|
10
|
+
import { completeWithdrawal, dispute, faucet, requestWithdrawal, setup, walletStatus } from "./account.mjs";
|
|
11
|
+
import { Consumer, loadNetwork, serveConsumer } from "./consumer.mjs";
|
|
11
12
|
import { createWallet, ownerAddress, ownerKey, walletAddress } from "./wallet.mjs";
|
|
12
|
-
import {
|
|
13
|
+
import { loadRequest } from "./disputes.mjs";
|
|
13
14
|
|
|
14
15
|
const DEFAULT_NETWORK = join(dirname(fileURLToPath(import.meta.url)), "../networks/mycomesh-v11-sepolia.json");
|
|
15
16
|
|
|
@@ -23,9 +24,10 @@ const USAGE = `Usage: mycomesh-consumer <command> [options]
|
|
|
23
24
|
deposit into the settlement contract and authorize the payment key
|
|
24
25
|
(on testnets, tops the wallet up from the faucet first)
|
|
25
26
|
balance [--owner ADDR] show the custodied deposit
|
|
27
|
+
withdraw [UNITS] request a withdrawal of the deposit; run again after the delay to receive it
|
|
26
28
|
request "prompt" [--stream]
|
|
27
29
|
send one request and print the verified answer
|
|
28
|
-
serve [--port 8110] run the local OpenAI-compatible endpoint
|
|
30
|
+
serve [--port 8110] run the local web console and OpenAI-compatible endpoint (default)
|
|
29
31
|
dispute <settlement-key|last> [--reason CODE] [--statement TEXT]
|
|
30
32
|
reveal a recorded request and response to a Provider-AI jury
|
|
31
33
|
(within 24 hours; the reporter bond is returned if fraud is confirmed)
|
|
@@ -88,38 +90,37 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
88
90
|
return 0;
|
|
89
91
|
}
|
|
90
92
|
const network = loadNetwork(values.network || process.env.MYCOMESH_NETWORK || DEFAULT_NETWORK);
|
|
91
|
-
const faucet = async (address) => {
|
|
92
|
-
if (!network.faucet_url) throw new Error("this network has no faucet");
|
|
93
|
-
const reply = await httpJson(`${network.faucet_url}/v11/faucet`, { method: "POST", body: { address }, ca: network.tls_ca, timeoutMs: 400_000 });
|
|
94
|
-
if (reply.status !== 200) throw new Error(`faucet: ${reply.body?.error || reply.status}`);
|
|
95
|
-
return reply.body;
|
|
96
|
-
};
|
|
97
93
|
if (command === "faucet") {
|
|
98
94
|
const address = values.owner || ownerAddress(values["owner-key-file"], dir) || walletAddress(await createWallet(dir));
|
|
99
|
-
stdout.write(`${JSON.stringify(await faucet(address))}\n`);
|
|
95
|
+
stdout.write(`${JSON.stringify(await faucet(network, address))}\n`);
|
|
100
96
|
return 0;
|
|
101
97
|
}
|
|
102
98
|
if (command === "setup") {
|
|
103
99
|
const keyPrivate = paymentKey(dir);
|
|
104
100
|
if (!values.deposit) throw new Error("--deposit UNITS is required");
|
|
105
101
|
if (!values["owner-key-file"]) await createWallet(dir);
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
102
|
+
const result = await setup(network, {
|
|
103
|
+
ownerPrivate: await ownerKey(values["owner-key-file"], dir), key: addressOf(keyPrivate), deposit: BigInt(values.deposit),
|
|
104
|
+
limit: BigInt(values["max-per-request"] || values["max-fee"]), forceFaucet: values.faucet,
|
|
105
|
+
log: (line) => stdout.write(`${line}\n`),
|
|
106
|
+
});
|
|
107
|
+
stdout.write(`deposited ${result.deposit} and authorized ${result.key} up to ${result.max_per_request} per request\n`);
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
if (command === "withdraw") {
|
|
116
111
|
const owner = await ownerKey(values["owner-key-file"], dir);
|
|
117
|
-
await
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
112
|
+
const status = await walletStatus(network, addressOf(owner), null);
|
|
113
|
+
if (BigInt(status.withdrawal.amount) > 0n) {
|
|
114
|
+
if (status.withdrawal.available_at > Math.floor(Date.now() / 1000)) {
|
|
115
|
+
throw new Error(`the withdrawal of ${status.withdrawal.amount} unlocks at ${new Date(status.withdrawal.available_at * 1000).toISOString()}`);
|
|
116
|
+
}
|
|
117
|
+
await completeWithdrawal(network, owner);
|
|
118
|
+
stdout.write(`withdrew ${status.withdrawal.amount}\n`);
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
const amount = BigInt(rest[0] || status.deposit);
|
|
122
|
+
await requestWithdrawal(network, owner, amount);
|
|
123
|
+
stdout.write(`requested a withdrawal of ${amount}; run \`withdraw\` again after the delay\n`);
|
|
123
124
|
return 0;
|
|
124
125
|
}
|
|
125
126
|
if (command === "balance") {
|
|
@@ -130,29 +131,10 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
130
131
|
}
|
|
131
132
|
if (command === "dispute") {
|
|
132
133
|
const owner = await ownerKey(values["owner-key-file"], dir);
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const params = await rpcCall(network.rpc_urls, "eth_call", [{ to: network.settlement, data: encodeCall("params()", []) }, "latest"]);
|
|
138
|
-
const bond = BigInt(`0x${params.slice(2 + 64 * 3, 2 + 64 * 4)}`);
|
|
139
|
-
if (bond > 0n) {
|
|
140
|
-
await sendTransaction(network.rpc_urls, owner, { to: network.stablecoin,
|
|
141
|
-
data: encodeCall("approve(address,uint256)", [["address", network.settlement], ["uint", bond]]) });
|
|
142
|
-
}
|
|
143
|
-
await sendTransaction(network.rpc_urls, owner, { to: network.settlement, data: openDisputeCall(record.settlement_key, digest) });
|
|
144
|
-
const accepted = [];
|
|
145
|
-
let relays = network.relays;
|
|
146
|
-
try { relays = [...relays, ...await directoryRelays(network)]; } catch {}
|
|
147
|
-
for (const relay of relays) {
|
|
148
|
-
try {
|
|
149
|
-
const reply = await httpJson(`${relay.url}/v11/evidence`, { method: "POST", body: evidence, ca: network.tls_ca, timeoutMs: 30_000 });
|
|
150
|
-
if (reply.status === 200) accepted.push(relay.url);
|
|
151
|
-
} catch {}
|
|
152
|
-
}
|
|
153
|
-
stdout.write(`${JSON.stringify({ settlement_key: record.settlement_key, evidence_hash: digest,
|
|
154
|
-
report_id: reportId(record.settlement_key, addressOf(owner), digest), relays_accepted: accepted })}\n`);
|
|
155
|
-
return accepted.length ? 0 : 2;
|
|
134
|
+
const result = await dispute(network, owner, loadRequest(dir, rest[0] || "last"),
|
|
135
|
+
{ reasonCode: values.reason, statement: values.statement || rest.slice(1).join(" ") });
|
|
136
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
137
|
+
return result.relays_accepted.length ? 0 : 2;
|
|
156
138
|
}
|
|
157
139
|
const keyPrivate = paymentKey(dir);
|
|
158
140
|
const consumer = new Consumer({ network, keyPrivate, maxFee: Number(values["max-fee"]), journalDir: dir });
|
|
@@ -169,9 +151,11 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
169
151
|
return 0;
|
|
170
152
|
}
|
|
171
153
|
if (command === "serve") {
|
|
172
|
-
const server = await serveConsumer(consumer, { host: values.host, port: Number(values.port),
|
|
154
|
+
const server = await serveConsumer(consumer, { host: values.host, port: Number(values.port),
|
|
155
|
+
apiKey: process.env.MYCOMESH_CONSUMER_API_KEY, dataDir: dir });
|
|
173
156
|
const { port } = server.address();
|
|
174
157
|
stdout.write(`MycoMesh V11 Consumer on http://${values.host}:${port}/v1 (payment key ${consumer.key})\n`);
|
|
158
|
+
stdout.write(`Web console: http://127.0.0.1:${port}/\n`);
|
|
175
159
|
return new Promise(() => {});
|
|
176
160
|
}
|
|
177
161
|
throw new Error(`unknown command ${command}\n${USAGE}`);
|
package/src/console.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The local web console: the Bitcoin-node model, a UI served by this machine to this machine.
|
|
2
|
+
// The browser only ever talks to 127.0.0.1; this process reaches the network and verifies everything.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { addressOf } from "./eip712.mjs";
|
|
5
|
+
import { completeWithdrawal, dispute, faucet, requestWithdrawal, settlementStatus, setup, walletStatus } from "./account.mjs";
|
|
6
|
+
import { listRequests, loadRequest } from "./disputes.mjs";
|
|
7
|
+
import { outputText } from "./protocol.mjs";
|
|
8
|
+
import { createWallet, ownerAddress, unlockWallet, walletAddress } from "./wallet.mjs";
|
|
9
|
+
|
|
10
|
+
const PAGE = new URL("./web/console.html", import.meta.url);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Only this machine's own pages may call the local node: reject other Host
|
|
14
|
+
* names (DNS rebinding) and cross-site Origins (a web page spending the deposit).
|
|
15
|
+
*/
|
|
16
|
+
export function localRequestAllowed(req, port) {
|
|
17
|
+
const allowed = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
|
|
18
|
+
if (!allowed.has(String(req.headers.host || ""))) return false;
|
|
19
|
+
const origin = req.headers.origin;
|
|
20
|
+
return !origin || allowed.has(origin.replace(/^http:\/\//, ""));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function consoleRoutes({ consumer, dataDir }) {
|
|
24
|
+
const network = consumer.network;
|
|
25
|
+
const owner = () => ownerAddress(undefined, dataDir);
|
|
26
|
+
const withWallet = (body) => unlockWallet(dataDir, body.password);
|
|
27
|
+
const units = (value, name) => {
|
|
28
|
+
if (!/^[0-9]+$/.test(String(value ?? ""))) throw Object.assign(new Error(`${name} must be a whole number of base units`), { status: 400 });
|
|
29
|
+
return BigInt(value);
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
"GET /": () => ({ type: "text/html; charset=utf-8", body: readFileSync(PAGE, "utf8") }),
|
|
33
|
+
"GET /api/status": async () => {
|
|
34
|
+
const address = owner();
|
|
35
|
+
return {
|
|
36
|
+
network: { id: network.network_id, chain_id: network.chain_id, settlement: network.settlement, faucet: Boolean(network.faucet_url) },
|
|
37
|
+
payment_key: consumer.key, max_fee: String(consumer.maxFee),
|
|
38
|
+
wallet: address ? await walletStatus(network, address, consumer.key) : null,
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
"GET /api/network": async () => {
|
|
42
|
+
const relays = [];
|
|
43
|
+
for (const relay of await consumer.relays()) {
|
|
44
|
+
let health = null; let providers = [];
|
|
45
|
+
try { health = (await consumer.fetchJson(`${relay.url}/health`, { ca: network.tls_ca, timeoutMs: 5_000 })).body; } catch {}
|
|
46
|
+
try { providers = await consumer.providers(relay); } catch {}
|
|
47
|
+
relays.push({ url: relay.url, signer: relay.signer, healthy: Boolean(health?.ok), providers: providers.map((p) => ({
|
|
48
|
+
signer: p.provider_signer, models: p.models, prices: p.prices, capacity: p.capacity })) });
|
|
49
|
+
}
|
|
50
|
+
return { relays };
|
|
51
|
+
},
|
|
52
|
+
"GET /api/history": async () => {
|
|
53
|
+
const entries = listRequests(dataDir, outputText, 50);
|
|
54
|
+
await Promise.all(entries.map(async (entry) => {
|
|
55
|
+
try { Object.assign(entry, await settlementStatus(network, entry.settlement_key)); } catch { entry.status = "unknown"; }
|
|
56
|
+
}));
|
|
57
|
+
return { entries };
|
|
58
|
+
},
|
|
59
|
+
"POST /api/wallet": async (body) => ({ owner: walletAddress(await createWallet(dataDir, body.password)) }),
|
|
60
|
+
"POST /api/faucet": async () => {
|
|
61
|
+
const address = owner();
|
|
62
|
+
if (!address) throw Object.assign(new Error("create the wallet first"), { status: 400 });
|
|
63
|
+
return faucet(network, address);
|
|
64
|
+
},
|
|
65
|
+
"POST /api/setup": async (body) => setup(network, {
|
|
66
|
+
ownerPrivate: withWallet(body), key: consumer.key, deposit: units(body.deposit, "deposit"),
|
|
67
|
+
limit: units(body.max_per_request ?? consumer.maxFee, "max_per_request"),
|
|
68
|
+
}),
|
|
69
|
+
"POST /api/withdraw": async (body) => {
|
|
70
|
+
const ownerPrivate = withWallet(body);
|
|
71
|
+
const status = await walletStatus(network, addressOf(ownerPrivate), null);
|
|
72
|
+
if (BigInt(status.withdrawal.amount) > 0n) {
|
|
73
|
+
if (status.withdrawal.available_at > Math.floor(Date.now() / 1000)) {
|
|
74
|
+
throw Object.assign(new Error("the pending withdrawal is still locked"), { status: 409 });
|
|
75
|
+
}
|
|
76
|
+
await completeWithdrawal(network, ownerPrivate);
|
|
77
|
+
return { withdrawn: status.withdrawal.amount };
|
|
78
|
+
}
|
|
79
|
+
const amount = body.amount ? units(body.amount, "amount") : BigInt(status.deposit);
|
|
80
|
+
await requestWithdrawal(network, ownerPrivate, amount);
|
|
81
|
+
return { requested: amount.toString() };
|
|
82
|
+
},
|
|
83
|
+
"POST /api/dispute": async (body) => dispute(network, withWallet(body), loadRequest(dataDir, String(body.settlement_key)),
|
|
84
|
+
{ reasonCode: String(body.reason_code || "unrelated_response"), statement: String(body.statement || "") }),
|
|
85
|
+
};
|
|
86
|
+
}
|
package/src/consumer.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { rpcCall } from "./chain.mjs";
|
|
|
9
9
|
import { openDelta, openResponse, outputText, prepareRequest, verifiedProvider } from "./protocol.mjs";
|
|
10
10
|
import { recordRequest } from "./disputes.mjs";
|
|
11
11
|
import { chatStream, responseStream } from "./sse.mjs";
|
|
12
|
+
import { consoleRoutes, localRequestAllowed } from "./console.mjs";
|
|
12
13
|
|
|
13
14
|
const PROVIDER_CACHE_MS = 30_000;
|
|
14
15
|
const RELAY_CACHE_MS = 300_000;
|
|
@@ -241,7 +242,8 @@ async function readJson(req) {
|
|
|
241
242
|
}
|
|
242
243
|
|
|
243
244
|
/** Local OpenAI-compatible endpoint; nothing leaves this machine unsealed except pricing and routing. */
|
|
244
|
-
export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKey } = {}) {
|
|
245
|
+
export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKey, dataDir } = {}) {
|
|
246
|
+
const routes = dataDir ? consoleRoutes({ consumer, dataDir }) : {};
|
|
245
247
|
const server = createServer(async (req, res) => {
|
|
246
248
|
let writer = null;
|
|
247
249
|
const send = (status, body, type = "application/json") => {
|
|
@@ -249,8 +251,17 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
|
|
|
249
251
|
res.end(type === "application/json" ? JSON.stringify(body) : body);
|
|
250
252
|
};
|
|
251
253
|
try {
|
|
252
|
-
if (
|
|
254
|
+
if (!localRequestAllowed(req, server.address().port)) return send(403, openaiError("only this machine may use the local node", "forbidden"));
|
|
253
255
|
const path = new URL(req.url, "http://localhost").pathname.replace(/^\/v1\/v1\//, "/v1/");
|
|
256
|
+
const route = routes[`${req.method} ${path}`];
|
|
257
|
+
if (route) {
|
|
258
|
+
if (req.method === "POST" && !String(req.headers["content-type"] || "").startsWith("application/json")) {
|
|
259
|
+
return send(415, { error: "JSON body required" });
|
|
260
|
+
}
|
|
261
|
+
const result = await route(req.method === "POST" ? await readJson(req) : {});
|
|
262
|
+
return result?.type ? send(200, result.body, result.type) : send(200, result);
|
|
263
|
+
}
|
|
264
|
+
if (apiKey && req.headers.authorization !== `Bearer ${apiKey}`) return send(401, openaiError("invalid API key", "unauthorized"));
|
|
254
265
|
if (req.method === "GET" && path === "/health") return send(200, { ok: true, protocol: 11, key: consumer.key });
|
|
255
266
|
if (req.method === "GET" && path === "/v1/models") {
|
|
256
267
|
return send(200, { object: "list", data: (await consumer.models()).map((id) => ({ id, object: "model", owned_by: "mycomesh" })) });
|
|
@@ -284,6 +295,10 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
|
|
|
284
295
|
return res.end();
|
|
285
296
|
} catch (error) {
|
|
286
297
|
if (res.headersSent) { writer?.fail(error.message); return res.end(); }
|
|
298
|
+
if (error.message === "wrong wallet password") return send(401, { error: error.message });
|
|
299
|
+
if (error.status && error.status < 500 && !error.dispatched && routes[`${req.method} ${new URL(req.url, "http://x").pathname}`]) {
|
|
300
|
+
return send(error.status, { error: error.message });
|
|
301
|
+
}
|
|
287
302
|
const status = error.code === "outcome_unknown" ? 504 : (error.status && error.status < 500 ? error.status : 502);
|
|
288
303
|
return send(status, openaiError(error.message, error.code || "request_failed"));
|
|
289
304
|
}
|
package/src/disputes.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// V11 Consumer disputes: keep each request's plaintexts locally, reveal them as evidence on demand.
|
|
2
|
-
import { chmodSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { canonicalJson } from "./secure-envelope.mjs";
|
|
5
5
|
import { encodeCall, encodeWords, hex, keccak, settlementKey } from "./eip712.mjs";
|
|
@@ -52,3 +52,31 @@ export const reportId = (key, reporter, digest) =>
|
|
|
52
52
|
hex(keccak(encodeWords([["bytes32", key], ["address", reporter], ["bytes32", digest]])));
|
|
53
53
|
|
|
54
54
|
export const openDisputeCall = (key, digest) => encodeCall("openDispute(bytes32,bytes32)", [["bytes32", key], ["bytes32", digest]]);
|
|
55
|
+
|
|
56
|
+
function preview(document) {
|
|
57
|
+
const content = document.endpoint === "chat" ? (document.messages || []).filter((m) => m.role === "user").at(-1)?.content : document.input;
|
|
58
|
+
const text = typeof content === "string" ? content : Array.isArray(content)
|
|
59
|
+
? content.map((part) => (typeof part === "string" ? part : part?.text ?? part?.content ?? "")).join(" ") : JSON.stringify(content ?? "");
|
|
60
|
+
return String(text).slice(0, 400);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Recorded requests, newest first, with short previews (the plaintexts never leave this machine). */
|
|
64
|
+
export function listRequests(dir, outputText, limit = 100) {
|
|
65
|
+
const folder = journal(dir);
|
|
66
|
+
if (!existsSync(folder)) return [];
|
|
67
|
+
return readdirSync(folder).filter((name) => name.endsWith(".json"))
|
|
68
|
+
.map((name) => JSON.parse(readFileSync(join(folder, name), "utf8")))
|
|
69
|
+
.sort((a, b) => b.recorded_at - a.recorded_at).slice(0, limit)
|
|
70
|
+
.map((record) => {
|
|
71
|
+
let request = {}; let response = {};
|
|
72
|
+
try { request = JSON.parse(Buffer.from(record.request, "base64").toString("utf8")); } catch {}
|
|
73
|
+
try { response = JSON.parse(Buffer.from(record.response, "base64").toString("utf8")); } catch {}
|
|
74
|
+
const { authorization, receipt } = record.signed_receipt;
|
|
75
|
+
return {
|
|
76
|
+
settlement_key: record.settlement_key, recorded_at: record.recorded_at, relay_url: record.relay_url,
|
|
77
|
+
model: request.model, endpoint: request.endpoint, prompt: preview(request),
|
|
78
|
+
answer: String(outputText(response.output)).slice(0, 600), fee: String(receipt.actual_fee),
|
|
79
|
+
input_tokens: receipt.input_tokens, output_tokens: receipt.output_tokens, provider: authorization.provider_signer,
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
package/src/wallet.mjs
CHANGED
|
@@ -76,11 +76,12 @@ export async function password(prompt, { confirm = false } = {}) {
|
|
|
76
76
|
|
|
77
77
|
export const walletPath = (dir) => join(dir, "owner-wallet.json");
|
|
78
78
|
|
|
79
|
-
export async function createWallet(dir) {
|
|
79
|
+
export async function createWallet(dir, secret) {
|
|
80
80
|
const path = walletPath(dir);
|
|
81
81
|
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
|
|
82
|
+
if (secret !== undefined && String(secret).length < 8) throw new Error("use a wallet password of at least 8 characters");
|
|
82
83
|
const keystore = encryptKeystore(`0x${randomBytes(32).toString("hex")}`,
|
|
83
|
-
await password("New wallet password (protects your deposit): ", { confirm: true }));
|
|
84
|
+
secret ?? await password("New wallet password (protects your deposit): ", { confirm: true }));
|
|
84
85
|
writeFileSync(path, `${JSON.stringify(keystore, null, 2)}\n`, { mode: 0o600 });
|
|
85
86
|
chmodSync(path, 0o600);
|
|
86
87
|
return keystore;
|
|
@@ -104,3 +105,10 @@ export function ownerAddress(file, dir) {
|
|
|
104
105
|
if (/^(0x)?[0-9a-fA-F]{64}$/.test(text)) return addressOf(text.startsWith("0x") ? text : `0x${text}`);
|
|
105
106
|
return walletAddress(JSON.parse(text));
|
|
106
107
|
}
|
|
108
|
+
|
|
109
|
+
/** Unlock this machine's wallet with a password given by the local console. */
|
|
110
|
+
export function unlockWallet(dir, secret) {
|
|
111
|
+
const path = walletPath(dir);
|
|
112
|
+
if (!existsSync(path)) throw new Error("no owner wallet yet");
|
|
113
|
+
return decryptKeystore(JSON.parse(readFileSync(path, "utf8")), String(secret ?? ""));
|
|
114
|
+
}
|
|
@@ -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>
|