我打造了一个 AI 驱动的 TTRPG 冒险生成器(因为通用幻觉太无聊)

发布: (2025年12月4日 GMT+8 00:00)
8 min read
原文: Dev.to

Source: Dev.to

我从小就沉浸在 R.L. Stine 那扣人心弦的小说中,并花了无数时间玩以剧情为驱动的电子游戏。成年后,我进入了桌面角色扮演游戏(TTRPG)领域,因为它拥有一种让我的“爬行动物大脑”感到兴奋的 je ne sais quoi

最近,我发现自己构思全新冒险点子的能力在减弱。每个曾盯着闪烁光标的人都懂那种挣扎:你有一个很酷的概念——“漂浮城市中的赛博朋克抢劫”——但当你尝试展开细节时,却碰壁。

普通的大语言模型(LLM)擅长胡编乱造通用套路。让它写一个“恐怖森林”,得到的就是老套的“扭曲树木和低语的风”。缺少灵魂,更重要的是缺少规划。

我想要一个不仅仅是随意编造,而是实际去检索真实世界的传说、维基、论坛以及其他创作者作品,以生成有根基、富有创意的冒险的工具。于是诞生了 Adventure Weaver——一个基于 Exa 构建的应用,帮助 TTRPG 游戏主持人(以及一般写作者)通过把整个互联网变成程序化生成的灵感库来克服写作瓶颈。

研究‑后‑生成工作流

  1. 用户提示 – 描述你想要的氛围(例如,“一座建在垂死之神背上的城市”)。
  2. 代理 – 通过 Exa 派遣的 AI 代理理解概念,而不仅仅是匹配关键词。
  3. 流式更新 – 代理的动作(“爬取维基…”,“阅读博客…”)实时流向用户,消除枯燥的加载转圈。
  4. 灵感图谱 – 使用 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...
}

(这里只展示了类的开头;完整实现会处理事件、去重以及引用数据的获取。)

本地快速开始

  1. 克隆代码库。
  2. 使用 npm install 安装依赖。
  3. .env.local 文件中设置 EXA_API_KEY
  4. 运行开发服务器:npm run dev
  5. 打开 http://localhost:3000,开始向 Adventure Weaver 输入提示。

祝你为桌面游戏会话打造更丰富、基于研究的冒险!

Back to Blog

相关文章

阅读更多 »

SaaS IA 新闻

SaaS IA 新闻的封面图片 https://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.amazon...

从混沌到代码:ALPHALABS

让我彻夜难眠的问题 我想要构建一个平台,让任何人都能创建 AI trading agents、backtest strategies,并证明其 performance……