本文解决什么问题 / 适合谁 / 前置条件
在做一个横版 RPG 小样时,我遇到一个具体痛点:玩家做完三五个支线后,任务面板里堆着十几条「击败 3 只野狼」「把信送到铁匠铺」的碎日志,翻起来很累。我想让引擎自动把这些碎日志压成一段「剧情回顾」,于是把 LayaAir 3.x 接入讯飞星火 来做任务日志摘要。本文记录的就是这套 Laya接入讯飞星火做任务日志 的完整实操,重点补齐我在别处教程里很少看到的三件事:前端为什么不能直连、Node.js 代理怎么封鉴权、以及流式返回如何逐字刷进 UI。
适合谁:已经会用 LayaAir 搭基础 UI、能读 TypeScript、想给游戏接一个大模型能力的独立开发者。
前置环境(我的实测机器,2026-06):
- 操作系统:macOS 14.5 / 同一套代码在 Windows 11 上也跑通过
- LayaAir 3.2.0(LayaAirIDE 3.2,TypeScript 项目模板)
- Node.js 20.11 LTS(代理服务)
- 讯飞星火:
generalv3.5接口,需在讯飞开放平台申请APPID / APIKey / APISecret(访问日期 2026-06,官方文档 https://www.xfyun.cn/doc/spark/Web.html) - 依赖:
ws@8.17、express@4.19、cors@2.8
整体方案:为什么前端不直连大模型
第一版我图省事,想在 Laya 里直接 new WebSocket() 连讯飞星火,结果两个问题立刻卡住:
- 密钥暴露。讯飞星火的鉴权要用到
APISecret做 HMAC-SHA256 签名。任何写进前端的密钥,打开浏览器控制台就能扒出来,等于把付费额度公开。这是红线,不能做。 - 签名与时钟。鉴权 URL 里带一个 RFC1123 的
date,服务端会校验时间偏差(我实测超过约 5 分钟就 401)。放在前端,用户本地时钟一歪就全挂。
所以正确结构是:Laya 前端 → 本地/自有 Node.js 代理 → 讯飞星火。密钥只留在代理侧,前端只跟自己的代理说话。数据流如下:
[Laya 任务面板]
│ POST /summary { logs: [...] } (只传任务文本)
▼
[Node.js 代理] ← 这里持有 APPID/APIKey/APISecret
│ 1. 拼签名 → wss 鉴权
│ 2. 连讯飞星火 WebSocket,边收边转发
▼ Server-Sent Events 逐块回吐
[Laya 前端] 逐字追加到日志面板
任务日志的数据结构我定得很朴素,一条日志就是一个对象,摘要时只把标题和状态喂给模型:
// 任务日志条目:只把必要字段送进模型,别把内部 id/坐标也塞进去浪费 token
interface QuestLog {
id: string;
title: string; // "击败狼群"
status: "done" | "doing" | "failed";
note?: string; // "在黑森林东侧,剩 1 只逃跑"
}
步骤一:创建 Laya RPG 任务日志 UI 与测试数据
先用纯代码搭一个最小任务面板,不依赖 IDE 拖拽,方便你直接复制。新建 QuestPanel.ts:
import { Laya } from "Laya";
import { Stage } from "laya/display/Stage";
import { Label } from "laya/ui/Label";
import { Button } from "laya/ui/Button";
import { Sprite } from "laya/display/Sprite";
const MOCK_LOGS: QuestLog[] = [
{ id: "q1", title: "护送商队出城", status: "done", note: "路上遇伏,损失一匹马" },
{ id: "q2", title: "清剿黑森林狼群", status: "done", note: "剩 1 只逃向东侧" },
{ id: "q3", title: "把断裂的圣剑交给铁匠", status: "doing", note: "缺少陨铁" },
{ id: "q4", title: "调查村庄井水中毒", status: "failed", note: "线索中断" },
];
export class QuestPanel {
private summaryLabel!: Label;
setup(): void {
Laya.init(720, 1280).then(() => {
Laya.stage.scaleMode = Stage.SCALE_FIXED_WIDTH; // 移动端竖屏
Laya.stage.bgColor = "#1b1b23";
// 列出原始日志
MOCK_LOGS.forEach((log, i) => {
const line = new Label(`【${this.zh(log.status)}】${log.title}`);
line.fontSize = 26;
line.color = "#d8d8e0";
line.pos(40, 60 + i * 44);
Laya.stage.addChild(line);
});
// 摘要输出区
this.summaryLabel = new Label("点击下方按钮生成任务回顾…");
this.summaryLabel.fontSize = 28;
this.summaryLabel.color = "#8be9fd";
this.summaryLabel.wordWrap = true;
this.summaryLabel.width = 640;
this.summaryLabel.pos(40, 320);
Laya.stage.addChild(this.summaryLabel);
const btn = new Button("res/btn.png", "生成任务回顾");
btn.pos(40, 260);
btn.on(Laya.Event.CLICK, this, this.onSummary);
Laya.stage.addChild(btn);
});
}
private zh(s: QuestLog["status"]): string {
return { done: "完成", doing: "进行中", failed: "失败" }[s];
}
private onSummary(): void {
this.summaryLabel.text = ""; // 清空,准备逐字追加
streamSummary(MOCK_LOGS, (chunk) => {
this.summaryLabel.text += chunk; // 关键:流式追加
});
}
}
new QuestPanel().setup();
streamSummary 稍后在步骤三实现。这一步跑起来应该能看到四条静态日志和一个按钮——先确认 UI 没问题,再接后端,别一次性堆完再 debug。
步骤二:用 Node.js 代理封装讯飞星火鉴权与流式接口
这是整篇的核心,也是最容易踩坑的地方。讯飞星火 WebSocket 的鉴权是「把签名塞进 URL 查询参数」。新建 server/spark.js:
// server/spark.js —— 生成带鉴权的 wss URL
const crypto = require("crypto");
const HOST = "spark-api.xf-yun.com";
const PATH = "/v3.5/chat"; // 对应 domain generalv3.5
const { SPARK_APPID, SPARK_API_KEY, SPARK_API_SECRET } = process.env;
function buildAuthUrl() {
const date = new Date().toUTCString(); // RFC1123,务必是 GMT
const signOrigin =
`host: ${HOST}\n` +
`date: ${date}\n` +
`GET ${PATH} HTTP/1.1`;
const signature = crypto
.createHmac("sha256", SPARK_API_SECRET)
.update(signOrigin)
.digest("base64");
const authOrigin =
`api_key="${SPARK_API_KEY}", algorithm="hmac-sha256", ` +
`headers="host date request-line", signature="${signature}"`;
const authorization = Buffer.from(authOrigin).toString("base64");
const params = new URLSearchParams({ authorization, date, host: HOST });
return `wss://${HOST}${PATH}?${params.toString()}`;
}
module.exports = { buildAuthUrl, HOST, PATH };
再写请求体拼装和 WebSocket 转发。讯飞星火返回是分帧的,header.status 为 2 表示结束:
// server/index.js
const express = require("express");
const cors = require("cors");
const WebSocket = require("ws");
const { buildAuthUrl } = require("./spark");
const app = express();
app.use(cors()); // 本地开发放开,生产要收紧到你的域名
app.use(express.json());
// 把任务日志组织成给模型的 prompt
function buildPrompt(logs) {
const lines = logs
.map((l) => `- [${l.status}] ${l.title}${l.note ? "(" + l.note + ")" : ""}`)
.join("\n");
return (
"你是 RPG 旁白。请把下面的任务日志压成一段 80 字以内的剧情回顾," +
"语气像游戏旁白,只输出回顾本身,不要解释:\n" + lines
);
}
app.post("/summary", (req, res) => {
const logs = req.body.logs || [];
// 用 SSE 把流式结果吐给前端
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.flushHeaders();
const ws = new WebSocket(buildAuthUrl());
ws.on("open", () => {
ws.send(JSON.stringify({
header: { app_id: process.env.SPARK_APPID, uid: "quest-log" },
parameter: { chat: { domain: "generalv3.5", temperature: 0.5, max_tokens: 256 } },
payload: { message: { text: [{ role: "user", content: buildPrompt(logs) }] } },
}));
});
ws.on("message", (raw) => {
const data = JSON.parse(raw.toString());
if (data.header.code !== 0) { // 鉴权/参数错误在这里暴露
res.write(`event: error\ndata: ${data.header.message}\n\n`);
return ws.close();
}
const piece = data.payload?.choices?.text?.[0]?.content || "";
if (piece) res.write(`data: ${JSON.stringify(piece)}\n\n`);
if (data.header.status === 2) { // 2 = 最后一帧
res.write("event: done\ndata: end\n\n");
res.end();
ws.close();
}
});
ws.on("error", (e) => {
res.write(`event: error\ndata: ${e.message}\n\n`);
res.end();
});
req.on("close", () => ws.close()); // 前端断开就掐掉上游,别泄漏连接
});
app.listen(3001, () => console.log("proxy on http://localhost:3001"));
启动命令(密钥用环境变量,别写进代码提交):
cd server
npm init -y
npm i ws@8.17 express@4.19 cors@2.8
export SPARK_APPID=你的appid
export SPARK_API_KEY=你的apikey
export SPARK_API_SECRET=你的apisecret
node index.js
# Windows PowerShell 用 $env:SPARK_APPID="..." 逐个设置
步骤三:在 Laya 中请求摘要并逐字更新面板
前端用 fetch + ReadableStream 读 SSE,比 EventSource 更好控制,因为我们要发 POST 带 body。回到步骤一里预留的 streamSummary:
// stream.ts
async function streamSummary(
logs: QuestLog[],
onChunk: (text: string) => void
): Promise<void> {
const resp = await fetch("http://localhost:3001/summary", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ logs }),
});
const reader = resp.body!.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true }); // stream:true 防止多字节截断
// 按 SSE 的空行分帧
const frames = buffer.split("\n\n");
buffer = frames.pop() || "";
for (const frame of frames) {
const line = frame.split("\n").find((l) => l.startsWith("data: "));
if (!line) continue;
const payload = line.slice(6);
if (payload === "end") return;
try {
onChunk(JSON.parse(payload)); // 后端 JSON.stringify 过,这里解回来
} catch { /* 忽略非数据帧 */ }
}
}
}
跑通后的效果:点按钮,摘要区会像打字机一样逐字冒出「你护送商队冲出重围,荡平黑森林狼群,却在中毒疑云前折戟,圣剑仍待陨铁重铸……」。这段是我实机截到的一次真实输出,语气比我预期的还上道。
真实踩坑:鉴权失败、跨域、乱码、移动端卡顿
下面每一条都是我在这台机器上真实撞过的,附处理方式。
1. WebSocket 鉴权 11200 / 401
最开始一直返回 header.code: 11200(授权错误)。查了半天,两个原因:一是 date 用了本地时区字符串,必须是 new Date().toUTCString() 生成的 GMT;二是签名原文里 GET /v3.5/chat HTTP/1.1 的路径写错版本(我一开始抄成了 v3.1)。处理:确保 PATH、domain、签名路径三者版本号完全一致,且系统时间准确(NTP 同步)。
2. 跨域 CORS 被拦
Laya 预览跑在 http://localhost:5175,代理在 3001,浏览器直接报 CORS。处理:代理侧 app.use(cors());生产环境别偷懒开全放,改成白名单:cors({ origin: "https://你的游戏域名" })。
3. 流式中文乱码 / 半个字
一开始摘要里时不时蹦出「�」。原因是 UTF-8 多字节汉字被拆在两个网络包里,单独 decode 就烂了。处理:前端 TextDecoder.decode(value, { stream: true }) 的 stream: true 一定要加,它会把不完整的字节留到下一帧再拼。加上之后乱码彻底消失。
4. 移动端逐字更新掉帧
真机(红米 Note 12)上逐字追加时,如果每来一个字就重排一次长文本 Label,会明显卡。处理:把高频到达的 chunk 先攒进一个字符串缓冲,用 Laya.timer.frameOnce 或简单节流每 60ms 刷一次 label.text,肉眼仍是打字机效果,但重排次数从上百次降到十几次,卡顿消失。
// 节流刷新,避免逐字重排导致移动端掉帧
let pending = "";
let scheduled = false;
function pushChunk(label: Label, chunk: string): void {
pending += chunk;
if (scheduled) return;
scheduled = true;
Laya.timer.once(60, null, () => {
label.text += pending;
pending = "";
scheduled = false;
});
}
小结 + 可复现完整代码
整套 Laya接入讯飞星火做任务日志 的关键就三点:密钥只留代理侧、鉴权靠 GMT 时间加 HMAC 签名、流式用 SSE 逐帧转发并在前端节流刷新。目录结构很简单:
project/
├── src/
│ ├── QuestPanel.ts # 步骤一:任务面板 UI + mock 数据
│ └── stream.ts # 步骤三:SSE 逐字读取
└── server/
├── spark.js # 步骤二:讯飞星火鉴权 URL
└── index.js # 步骤二:SSE 代理 + WebSocket 转发
运行顺序:先 cd server && node index.js 起代理,再在 LayaAirIDE 里预览前端,点「生成任务回顾」即可看到逐字摘要。上文已给出全部脚本,可按步骤复制到本地项目中运行。想扩展的话,把 MOCK_LOGS 换成你真实的任务系统数据、给 prompt 加上人物名和地名,就能得到更贴合剧情的回顾;再进一步可以缓存最近一次摘要,避免玩家反复点按钮重复消耗额度。

发表回复