Linera Gen-NFT 生成式 NFT 应用实战:从本地网络部署、GraphiQL 交互到 React Web 前端

原创2026-09-09 19:43:041,741 阅读
文章标签:区块链Web3后端

Linera Gen-NFT 生成式 NFT 应用实战:从本地网络部署、GraphiQL 交互到 React Web 前端

本篇文章围绕 Linera 协议仓库中的 Gen-NFT(生成式 NFT)示例应用展开,讲解如何把"由大语言模型生成内容文本的 NFT 应用"完整跑起来:先在本地启动 Linera 测试网络、编译并发布应用模块,再通过钱包节点服务暴露的 GraphiQL 接口完成铸造、查询、转移,最后用配套的 React Web 前端(examples/gen-nft/web-frontend)以浏览器界面驱动同一套链上操作。读完本文,你将掌握 Gen-NFT 应用从零到可交互的全部操作步骤,并理解其"合约在链上、AI 模型在应用服务端离链运行"的核心架构。

一、Gen-NFT 示例应用是什么

Gen-NFT 是基于 non-fungible 示例应用 改造而来的生成式 NFT 示例。它与普通 NFT 的唯一区别在于:token 的 payload 不再是一段任意字节序列,而是由大语言模型根据用户输入 prompt 生成的文本字符串(如一句故事、一段描述)。

该示例的完整介绍见 examples/gen-nft/README.md,其核心目的有两个:

  1. 演示如何在**应用服务(service)**中运行一个 AI 模型,服务运行在客户端(通常随钱包启动、离链执行),用模型为铸造操作准备好 token 内容;
  2. 复用 NFT 示例的跨链消息机制,演示 NFT 如何在不同的链之间被铸造(mint)、转移(transfer)与认领(claim),强调 Linera 生态中应用实例的实例化与自动部署能力。

从 合约实现 可以看到,其合约逻辑与 NFT 示例几乎一致,唯一差别是把 payload 换成了文本字符串。整个应用的 ABI 类型定义集中在 examples/gen-nft/src/lib.rs:Operation(Mint / Transfer / Claim)、Message(Transfer / Claim)以及 Nft 结构(token_id、owner、prompt、minter)。

二、运行环境准备:二进制工具与本地测试网络

2.1 确保 linera* 工具与脚本函数就绪

在开始前,需要保证与当前 linera-sdk 版本匹配的 linera* 二进制工具位于 PATH 中,同时为脚本方便,定义 linera_spawn 这个 BASH 函数。在 Linera 仓库根目录下执行:

export PATH="$PWD/target/debug:$PATH"
source /dev/stdin <<<"$(linera net helper 2>/dev/null)"

其中 linera net helper 会输出一组便捷的 shell 函数(如 linera_spawn),用于简化网络启停流程。

2.2 启动本地网络与水龙头

FAUCET_PORT=8079
FAUCET_URL=http://localhost:$FAUCET_PORT
linera_spawn linera net up --with-faucet --faucet-port $FAUCET_PORT

# 如果使用的是测试网,则改为:
#   LINERA_TMP_DIR=$(mktemp -d)
#   FAUCET_URL=https://faucet.testnet-XXX.linera.net  # 具体 XXX 以测试网为准

启用用户应用日志(便于观察 AI 模型运行日志):

export LINERA_APPLICATION_LOGS=true

2.3 初始化钱包并申请两条链

export LINERA_WALLET="$LINERA_TMP_DIR/wallet.json"
export LINERA_KEYSTORE="$LINERA_TMP_DIR/keystore.json"
export LINERA_STORAGE="rocksdb:$LINERA_TMP_DIR/client.db"

linera wallet init --faucet $FAUCET_URL

INFO_1=($(linera wallet request-chain --faucet $FAUCET_URL))
INFO_2=($(linera wallet request-chain --faucet $FAUCET_URL))
CHAIN_1="${INFO_1[0]}"
CHAIN_2="${INFO_2[0]}"
OWNER_1="${INFO_1[1]}"
OWNER_2="${INFO_2[1]}"

这里向水龙头申请了两条链 CHAIN_1、CHAIN_2,并分别得到两个账户的所有者地址 OWNER_1、OWNER_2。后续的铸造、转移演示都用这些变量指代实际账户。

三、编译并发布 Gen-NFT 应用模块

与同仓库其他示例一致,应用以 WebAssembly 形式发布到链上。编译并发布:

(cd examples/gen-nft && cargo build --release --target wasm32-unknown-unknown)

MODULE_ID=$(linera publish-module \
    examples/target/wasm32-unknown-unknown/release/gen_nft_{contract,service}.wasm)

publish-module 一次性接收 contract 与 service 两个 wasm 文件,返回的模块 ID 存入 MODULE_ID 变量。

接着创建应用实例。与同质化代币不同,每个 NFT 都是独一无二的,由唯一 token ID 标识;同时创建 NFT 应用时不需要指定初始状态或初始化参数(NFT 之后才铸造),因此命令非常简洁:

APP_ID=$(linera create-application $MODULE_ID)

应用 ID 存入 APP_ID。如何查看测试链在默认钱包中的 ID 列表、定义对应变量,可参考 fungible 示例 README 中 "Creating a token" 一节的做法。

四、启动钱包节点服务并用 GraphiQL 交互

4.1 启动节点服务

所有链上操作都需要通过钱包的节点服务提交,先以 8080 端口启动:

PORT=8080
linera service --port $PORT &

服务启动后,进入 GraphiQL 界面的地址为:

echo "http://localhost:8080/chains/$CHAIN_1/applications/$APP_ID"

4.2 铸造 NFT(mint)

在 GraphiQL 中执行以下 mutation,注意把 $OWNER_1、$CHAIN_1、$APP_ID 替换成实际值:

mutation {
  mint(
    minter: "$OWNER_1",
    prompt: "Hello!"
  )
}

mint 的入参是铸造者 minter 与内容提示词 prompt。从 service.rs 可以看到,GraphQL 层的 mint 只是构造 Operation::Mint 并通过 runtime.schedule_operation 把它调度进区块提案;真正的铸造逻辑在 contract.rs 中执行:先通过 check_account_permission 校验权限,再用 Nft::create_token_id 生成唯一 token ID,最后把 Nft { token_id, owner, prompt, minter } 写入状态,并递增 num_minted_nfts 计数器。

4.3 查询用户持有的 NFT(ownedNfts)

query {
  ownedNfts(owner: "$OWNER_1")
}

把上一条查询返回的结果保存到 QUERY_RESULT 变量,然后用 jq 提取 tokenId 赋值给 TOKEN_ID(也可手动从返回的 tokenId 字段赋值):

TOKEN_ID=$(echo "$QUERY_RESULT" | jq -r '.ownedNfts[].tokenId')

ownedNfts 在 service.rs 中实现:先按 owner 从 owned_token_ids 视图取出该账户持有的 token ID 集合,再逐一从 nfts 视图取出 NFT 详情,最终返回以 tokenId 为键的映射。注意 tokenId 在链下 JSON 中以 Base64(无填充 STANDARD_NO_PAD 编码) 的字符串形式呈现。

4.4 查询单个 NFT 详情(nft)

query {
  nft(tokenId: "$TOKEN_ID") {
    tokenId,
    owner,
    prompt,
    minter,
  }
}

4.5 查询链上全部 NFT(nfts)

query {
  nfts
}

4.6 转移 NFT(transfer)

在同一个链 $CHAIN_1 上把 $TOKEN_ID 从 $OWNER_1 转移给 $OWNER_2:

mutation {
  transfer(
    sourceOwner: "$OWNER_1",
    tokenId: "$TOKEN_ID",
    targetAccount: {
      chainId: "$CHAIN_1",
      owner: "$OWNER_2"
    }
  )
}

transfer 的 GraphQL 实现同样只做"调度操作":构造 Operation::Transfer 后交给运行时(见 service.rs)。链上执行时(contract.rs)会校验 source_owner 的操作权限,并断言其确实是该 NFT 的当前 owner,随后调用跨链转移逻辑(详见第七节)。

五、使用 React Web 前端操作 Gen-NFT

5.1 安装依赖并启动开发服务器

进入前端目录安装依赖(示例中依赖未提交到 node_modules,需要现场安装):

cd examples/gen-nft/web-frontend
npm install --no-save

# 启动开发服务器,但不立即打开浏览器
BROWSER=none npm start &

npm start 实际调用的是 react-scripts start(见 web-frontend/package.json),开发服务器默认监听 3000 端口。

5.2 URL 格式与参数语义

打开某个账户对应的 Web UI,需要访问如下形式的 URL:

http://localhost:3000/$CHAIN?app=$APP_ID&owner=$OWNER&port=$PORT

参数含义:

参数 含义
URL 路径 $CHAIN 账户所在链的 Chain ID
app 创建应用时获得的 token 应用 ID(即 $APP_ID)
owner 所选用户账户的所有者地址(该 owner 必须具有在给定链上创建区块的权限)
port 钱包服务的端口(钱包必须持有 owner 对应的私钥)

例如:

echo "http://localhost:3000/$CHAIN_1?app=$APP_ID&owner=$OWNER_1&port=$PORT"
echo "http://localhost:3000/$CHAIN_1?app=$APP_ID&owner=$OWNER_2&port=$PORT"

URL 的解析逻辑在 web-frontend/src/index.js 中:路由参数 :id 作为 chain ID,app、owner、port 来自 query string;其中 port 缺省时默认 8080,而 app、owner 缺失会直接抛错。解析完成后,页面渲染为 <GraphQLProvider chainId={id} applicationId={app} port={port}> 包裹的 <App chainId={id} owner={owner} />。

5.3 前端如何连接链上 GraphQL

前端的 GraphQL 连接封装在 web-frontend/src/GraphQLProvider.js 中:

  • HTTP 查询/变更走 http://localhost:${port}/chains/${chainId}/applications/${applicationId};
  • 订阅(subscription)走 WebSocket ws://localhost:${port}/ws;
  • 通过 Apollo Client 的 split 根据操作类型分流:订阅走 ws,其余走 http。

页面核心交互逻辑在 web-frontend/src/App.js 中,通过 Apollo 的 useLazyQuery / useMutation / useSubscription 驱动:

  • ownedNfts(owner:) 查询当前账户持有的 NFT 列表(L9-L13),并以表格形式展示 tokenId、minter、prompt,每行附带 Transfer 按钮;
  • mint(minter:, prompt:) mutation(L15-L19)对应"铸造"弹窗:输入 Story(即 prompt),可先点 Preview 调用 prompt(prompt:) 查询(L33-L37)预览模型生成结果,再点 Submit 正式铸造;
  • transfer(sourceOwner:, tokenId:, targetAccount:) mutation(L21-L25)对应"转移"弹窗:填写目标链 ID 与目标 owner 后提交;
  • notifications(chainId:) 订阅(L27-L31)用于在链上出现新通知时自动刷新 NFT 列表(L94-L97),保证跨链转移等异步操作后界面同步更新。

操作完成后,页面会通过 onCompleted 回调重新拉取 ownedNfts 刷新列表,实现"铸造即见、转移即更"的交互体验。

5.4 前端可用的开发脚本

web-frontend/package.json 中预置了四个脚本,与 Create React App 约定一致:

脚本 作用
npm start 开发模式运行,页面热重载,控制台显示 lint 错误
npm test 以交互式监听模式启动测试运行器
npm run build 生产构建,输出到 build 目录,代码压缩并带 hash 文件名,产物可直接部署
npm run eject 弹出 CRA 配置(一次性操作,不可回退),将 webpack、Babel、ESLint 等配置暴露到项目中以便深度定制;一般无需使用

前端技术栈由 web-frontend/package.json 可确认:React 18 + @apollo/client(GraphQL 客户端)+ graphql-ws(WebSocket 订阅)+ Ant Design(antd 5,UI 组件库)+ Tailwind。

六、离链 AI 模型:prompt 生成的实现原理

Gen-NFT 最独特之处在于把大语言模型跑在**应用服务(service)**里。Linera 的应用服务随客户端(通常是钱包进程)运行、在链下执行,因此可以承载 AI 推理这类"计算重、不需要上链"的任务。

服务端 GraphQL 暴露了一个 prompt(prompt:) 查询,见 service.rs:

async fn prompt(&self, ctx: &Context<'_>, prompt: String) -> String {
    let runtime = ctx.data::<Arc<ServiceRuntime<GenNftService>>>().unwrap();
    let response = runtime.http_request(http::Request::get("http://localhost:10001/model.bin"));
    let raw_weights = response.body;
    let response = runtime.http_request(http::Request::get("http://localhost:10001/tokenizer.json"));
    let tokenizer_bytes = response.body;
    let model_context = ModelContext { model: raw_weights, tokenizer: tokenizer_bytes };
    model_context.run_model(&prompt).unwrap()
}

其流程是:

  1. 通过 SDK 提供的 runtime.http_request 从本地 HTTP 服务 http://localhost:10001 拉取模型权重 model.bin 与分词器 tokenizer.json;
  2. 组装成 ModelContext 后调用 run_model 完成推理,返回生成的文本;
  3. 前端在铸造弹窗中点 Preview 时就会触发该查询,让用户在下单上链前先预览"AI 将生成的内容"。

推理实现位于 examples/gen-nft/src/model.rs,基于 Hugging Face 生态的 candle-core / candle-transformers:

  • 同时支持 GGUF(gguf_file)与 GGML(ggml_file)两种量化模型格式,运行时按文件头自动尝试加载(见 try_load_gguf);
  • 模型类型封装为 Model 枚举,区分完整 Llama(含 KV cache)与量化版 Qllama(ModelWeights) 两种推理路径;
  • 使用 tokenizers::Tokenizer 做文本分词,配合 LogitsProcessor 做自回归生成。

也就是说,模型权重不需要打包进 wasm 或上链,而是由服务在运行期从本地文件服务按需获取,这也是"AI 推理完全离链"的关键设计。合约与链上状态里最终保存的是生成的文本本身(prompt 字段),而不是模型。

七、合约底层:跨链转移与认领的完整链路

7.1 三种操作(Operation)

lib.rs 定义了链上操作:

  • Mint { minter, prompt }:铸造。合约侧先 check_account_permission(minter) 校验权限,再生成 token ID 并入库(contract.rs);
  • Transfer { source_owner, token_id, target_account }:从本地链上的账户转移到(可能位于其他链的)目标账户;
  • Claim { source_account, token_id, target_account }:源账户可能在远程链。若源账户就在本链,则等价于 Transfer;否则构造带认证的跨链消息发往源链(见 contract.rs 与 remote_claim L187-L202)。

7.2 两种跨链消息(Message)

lib.rs 定义了应用间消息:

  • Message::Transfer { nft, target_account }:把 NFT 送达目标账户所在链;若消息发生弹回(bouncing),则按 message_is_bouncing 判定不更新 owner,实现"失败退回"语义(contract.rs);
  • Message::Claim { source_account, token_id, target_account }:在源链上先认领、再启动到目标账户的转移。

7.3 token ID 如何生成

每个 NFT 的 token ID 并非随机数,而是由确定性哈希生成(lib.rs):

let mut hasher = sha3::Sha3_256::new();
hasher.update(chain_id.to_bcs_bytes()?);
hasher.update(application_id.to_bcs_bytes()?);
hasher.update(prompt);
hasher.update(minter.to_bcs_bytes()?);
hasher.update(num_minted_nfts.to_bcs_bytes()?);

即 SHA3-256(chain_id ‖ application_id ‖ prompt ‖ minter ‖ 已铸造计数),保证同一应用实例内 token ID 唯一且可复现。合约的 mint 在 contract.rs 中调用它,并把 num_minted_nfts 递增。

跨链转移的实现(contract.rs)也很有代表性:先从本链状态移除 NFT;若目标链就是本链,直接改 owner 入库;否则构造 Message::Transfer 并 prepare_message(...).with_tracking().send_to(target_account.chain_id),由 Linera 的跨链消息机制投递,并在目标链的 execute_message 中完成落地。

八、常见问题与排查建议

  • 模型文件下载失败:prompt 查询依赖 http://localhost:10001/model.bin 与 tokenizer.json,需要先在该端口启动一个本地静态文件服务(或按 examples/gen-nft/README.md 说明准备模型文件),否则 Preview 会报错;
  • 前端 URL 报 "missing app/owner query param":说明访问时漏掉了 app 或 owner 参数,检查 URL 是否符合 http://localhost:3000/$CHAIN?app=$APP_ID&owner=$OWNER&port=$PORT 格式(index.js);
  • Mint / Transfer 权限失败:owner 必须与钱包持有的私钥一致,且在该链上有创建区块的权限,合约侧会通过 check_account_permission 严格校验;
  • 端口冲突:节点服务默认 8080,前端 port 参数需与 linera service --port $PORT 保持一致,缺省按 8080 处理;
  • 查看日志:设置 LINERA_APPLICATION_LOGS=true 可看到应用侧 info! 日志(如服务加载权重、运行 prompt 的过程)。

九、总结

Gen-NFT 是理解 Linera 应用架构的绝佳样例:合约(contract)负责链上状态与跨链消息,服务(service)在客户端离链运行 AI 模型,前端通过钱包节点服务的 GraphQL(HTTP + WebSocket)与两者对接。它完整覆盖了应用发布、实例创建、GraphiQL 调试、Web 前端集成、跨链转移与认领、以及 AI 推理离链化等关键知识点。若想深入了解合约与状态管理细节,可继续阅读 examples/gen-nft/src/contract.rs、examples/gen-nft/src/service.rs 以及 examples/non-fungible/README.md 中 "How it works" 一节。

登录后查看全文
linera-protocol