我打造了一个 AI 驱动的 TTRPG 冒险生成器(因为通用幻觉太无聊)
Source: Dev.to
我从小就沉浸在 R.L. Stine 那扣人心弦的小说中,并花了无数时间玩以剧情为驱动的电子游戏。成年后,我进入了桌面角色扮演游戏(TTRPG)领域,因为它拥有一种让我的“爬行动物大脑”感到兴奋的 je ne sais quoi。
最近,我发现自己构思全新冒险点子的能力在减弱。每个曾盯着闪烁光标的人都懂那种挣扎:你有一个很酷的概念——“漂浮城市中的赛博朋克抢劫”——但当你尝试展开细节时,却碰壁。
普通的大语言模型(LLM)擅长胡编乱造通用套路。让它写一个“恐怖森林”,得到的就是老套的“扭曲树木和低语的风”。缺少灵魂,更重要的是缺少规划。
我想要一个不仅仅是随意编造,而是实际去检索真实世界的传说、维基、论坛以及其他创作者作品,以生成有根基、富有创意的冒险的工具。于是诞生了 Adventure Weaver——一个基于 Exa 构建的应用,帮助 TTRPG 游戏主持人(以及一般写作者)通过把整个互联网变成程序化生成的灵感库来克服写作瓶颈。
研究‑后‑生成工作流
- 用户提示 – 描述你想要的氛围(例如,“一座建在垂死之神背上的城市”)。
- 代理 – 通过 Exa 派遣的 AI 代理理解概念,而不仅仅是匹配关键词。
- 流式更新 – 代理的动作(“爬取维基…”,“阅读博客…”)实时流向用户,消除枯燥的加载转圈。
- 灵感图谱 – 使用 D3.js 可视化展示灵感网络,让你清晰看到每个诡异反派点子的来源。
技术栈
| 组件 | 理由 |
|---|---|
| Next.js | 适用于生产环境的 React 框架 |
| Exa | 为 AI 驱动研究而构建的神经搜索引擎 |
| Tailwind CSS | 快速 UI 样式,无需在布局上花费数小时 |
| D3.js | 交互式“探索板”可视化 |
定义严格的 JSON Schema
为了确保 AI 返回可用的数据(而不是一大段文字),我们定义了一个 JSON Schema 作为合同。该 schema 明确列出我们需要的字段:标题、摘要、情节钩子、NPC 和地点。
// src/app/api/generate/route.ts
const adventureSchema = {
type: 'object',
required: ['adventure_title', 'summary', 'plot_hooks', 'npcs', 'locations'],
properties: {
adventure_title: { type: 'string' },
summary: { type: 'string' },
plot_hooks: { type: 'array', items: { type: 'string' } },
npcs: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
description: { type: 'string' },
},
required: ['name', 'description'],
},
},
locations: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
description: { type: 'string' },
},
required: ['name', 'description'],
},
},
},
};
神经搜索 vs. 关键词搜索
普通搜索引擎在你查询“真实的龙生物学”时可能只返回一篇列表文章。Exa 的神经模型能够理解你在寻找的是推测性生物学,并能够呈现小众博客、学术讨论或 StackExchange 线程,这些内容真正探讨了喷火的物理原理。
API 路由:启动研究任务
该端点使用 Exa 创建研究任务,立即返回 taskId,客户端随后可以轮询或流式获取进度。
// src/app/api/generate/route.ts
import Exa from 'exa-js';
import { NextRequest, NextResponse } from 'next/server';
const exa = new Exa(process.env.EXA_API_KEY);
export async function POST(req: NextRequest) {
const { prompt } = await req.json();
const instructions = `You are a creative assistant for a TTRPG Game Master.
Use the user's prompt to find ideas from blogs, forums, and wikis to generate a compelling adventure.
Please generate a title, a summary, a few plot hooks, some interesting NPCs, and some key locations for the adventure.
Each component should be placed in its respective schema field.
For context, here is the user's prompt: ${prompt}`;
// Create the research task without awaiting its completion.
const researchTask = await exa.research.create({
instructions,
outputSchema: adventureSchema,
});
// Return the task ID right away.
return NextResponse.json({ taskId: researchTask.researchId });
}
使用服务器发送事件(SSE)进行流式进度
等待 30 秒的响应感觉像是永恒,于是我们使用 SSE 将代理的进度实时推送。前端会实时收到诸如 “Searching: ‘medieval castles’” 或 “Crawling: example.com” 之类的消息。
// src/app/api/adventure/[taskId]/route.ts
import Exa from 'exa-js';
import { NextRequest, NextResponse } from 'next/server';
import { CitationProcessor } from '@/lib/citation-processor';
const exa = new Exa(process.env.EXA_API_KEY);
export async function GET(_req: NextRequest, context: any) {
const taskId = (await context.params).taskId;
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const citationProcessor = new CitationProcessor();
const taskStream = await exa.research.get(taskId, { stream: true });
for await (const event of taskStream) {
citationProcessor.processEvent(event);
if (
event.eventType === 'task-operation' ||
event.eventType === 'plan-operation'
) {
const op = event.data;
let message: string;
switch (op.type) {
case 'search':
message = `Searching: "${op.query}"`;
break;
case 'crawl':
message = `Crawling: ${new URL(op.result.url).hostname}`;
break;
case 'think':
message = op.content;
break;
default:
message = 'Starting an unknown journey...';
}
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: 'message', content: message })}\n\n`,
),
);
} else if (
event.eventType === 'research-output' &&
event.output.outputType === 'completed'
) {
const finalResult = event.output;
const deduplicatedCitations = citationProcessor.getCitations();
const resultData = {
...finalResult.parsed,
citations: deduplicatedCitations,
};
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: 'result', content: resultData })}\n\n`,
),
);
break;
}
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
引文映射
此设置最酷的地方之一是能够将每个生成的元素映射回其来源 URL。这让我们能够为 NPC、地点或情节钩子标记出具体的博客文章或论坛帖子,说明它们的灵感来源。
// src/lib/citation-processor.ts
export class CitationProcessor {
private taskIdToSection = new Map();
// ...implementation continues...
}
(这里只展示了类的开头;完整实现会处理事件、去重以及引用数据的获取。)
本地快速开始
- 克隆代码库。
- 使用
npm install安装依赖。 - 在
.env.local文件中设置EXA_API_KEY。 - 运行开发服务器:
npm run dev。 - 打开
http://localhost:3000,开始向 Adventure Weaver 输入提示。
祝你为桌面游戏会话打造更丰富、基于研究的冒险!