首页
/ prompts.chat Widget 插件开发指南:Feed 注入机制、定位策略与双渲染模式全解析

prompts.chat Widget 插件开发指南:Feed 注入机制、定位策略与双渲染模式全解析

2026-09-04 14:41:29作者:郁楠烈Hubert

本文以 prompts.chat 仓库中的 widget-generator 技能文档(.windsurf/skills/widget-generator/SKILL.md)为主体,完整讲透如何为 prompts.chat 的提示词信息流(feed)开发可自定义的 Widget 插件:包括标准/自定义两种渲染模式的完整代码模板、positioning 定位配置的全部参数与默认值、shouldInject 注入逻辑的五种典型写法,以及如何完成注册、资源放置与验证。读完并对照 widgets 类型定义注入引擎实现,你可以独立开发、注册并调试一个会在指定位置出现的 feed 卡片插件。

Widget 是什么:注入到提示词信息流的插件卡片

Widget 是注入到 prompts.chat 提示词信息流中的一类特殊条目,用于展示推广内容、赞助商卡片或自定义交互组件。它与普通提示词卡片共存于同一瀑布流中,但具备两个关键特性:

  1. 自主注入逻辑:每个 Widget 通过 shouldInject 回调自行决定在哪些筛选条件下出现(例如只在无筛选时、只在特定分类下、只在搜索命中关键词时);
  2. 可配置的位置策略:通过 positioning 配置第一次出现的位置,以及是一次性出现还是周期性重复出现。

从源码结构看,Widget 的完整生命周期分三段:注册(把 WidgetPlugin 加入 widgets/index.ts 中的 widgetPlugins 数组)→ 注入(前端列表组件调用 injectWidgets 计算插入点)→ 渲染(列表用 isWidget 类型守卫区分条目,交给 WidgetCard 渲染,而非普通 PromptCard)。

两种渲染模式与类型定义

技能文档明确了 Widget 的两种渲染模式,仓库中也各有一个真实参照实现:

模式 文件形式 渲染方式 仓库参照
标准提示词 Widget {widget-id}.ts 复用默认 PromptCard 样式(含赞助商头、内容预览、标签、复制/CTA 按钮) coderabbit.ts
自定义渲染 Widget {widget-id}.tsx 提供 render 函数返回任意 React 组件 book.tsx

完整类型参考

技能文档给出的类型定义如下,它定义了 Widget 的全部可配置面:

interface WidgetPrompt {
  id: string;
  slug: string;
  title: string;
  description: string;
  content: string;
  type: "TEXT" | "STRUCTURED";
  structuredFormat?: "json" | "yaml";
  sponsor?: {
    name: string;
    logo: string;
    logoDark?: string;
    url: string;
  };
  tags?: string[];
  category?: string;
  actionUrl?: string;
  actionLabel?: string;
  positioning?: {
    position?: number;      // Default: 2
    mode?: "once" | "repeat"; // Default: "once"
    repeatEvery?: number;   // For repeat mode
    maxCount?: number;      // Max occurrences
  };
  shouldInject?: (context: WidgetContext) => boolean;
  render?: () => ReactNode; // For custom rendering
}

interface WidgetPlugin {
  id: string;
  name: string;
  prompts: WidgetPrompt[];
}

对照仓库中的 types.ts,有两处值得注意的实现细节,写插件时应以仓库实际类型为准:

  1. render 实际接收一个 instanceIndex 参数(0 基的重复实例序号)。因为同一个 Widget 在 repeat 模式下会在列表中多次出现,框架需要用它生成唯一的 idrender 拿到该序号后才能为每个实例创建互不冲突的 DOM id。仓库注释原文为 "instanceIndex is provided for repeated widgets (0-based) to support unique IDs":
/** Custom render function for completely custom widget designs.
 * instanceIndex is provided for repeated widgets (0-based) to support unique IDs. */
render?: (instanceIndex: number) => ReactNode;
  1. 顶层 position 字段已被标记为 @deprecated,应改用 positioning.position。不过 widgets/index.ts 中的 getWidgetInsertionPositions 仍保留向后兼容:优先取 positioning.position,再回退到旧的 position 字段,最后默认 2(见下文定位算法小节)。

另外,注入后的条目会被附加两个标记字段(InjectedWidget),用于列表渲染时区分普通提示词与 Widget,并为重复实例保证 key 唯一:

interface InjectedWidget extends WidgetPrompt {
  isWidget: true;
  /** 0-based index for repeated widget instances */
  instanceIndex: number;
}

配套的 isWidget 类型守卫(见 types.ts 末尾)通过检查 isWidget === true 完成运行时判别,是列表组件分流渲染的依据。

Widget 配置参数详解

技能文档要求创建 Widget 前,先从使用者处收集以下信息。核心四参数为:Widget ID(唯一标识,kebab-case,如 my-sponsor)、Widget Name(插件显示名)、渲染模式standardcustom)、赞助商信息(可选)。完整的配置项分为六组:

基础信息

- id: string (unique, kebab-case)
- name: string (display name)
- slug: string (URL-friendly identifier)
- title: string (card title)
- description: string (card description)

内容(standard 模式使用)

- content: string (prompt content, can be multi-line markdown)
- type: "TEXT" | "STRUCTURED"
- structuredFormat?: "json" | "yaml" (if type is STRUCTURED)

分类

- tags?: string[] (e.g., ["AI", "Development"])
- category?: string (e.g., "Development", "Writing")

操作按钮

- actionUrl?: string (CTA link)
- actionLabel?: string (CTA button text)

赞助商(可选)

- sponsor?: {
    name: string
    logo: string (path to light mode logo)
    logoDark?: string (path to dark mode logo)
    url: string (sponsor website)
  }

定位策略

- positioning: {
    position: number (0-indexed start position, default: 2)
    mode: "once" | "repeat" (default: "once")
    repeatEvery?: number (for repeat mode, e.g., 30)
    maxCount?: number (max occurrences, default: 1 for once, unlimited for repeat)
  }

注入逻辑

- shouldInject?: (context) => boolean
  Context contains:
  - filters.q: search query
  - filters.category: category name
  - filters.categorySlug: category slug
  - filters.tag: tag filter
  - filters.sort: sort option
  - itemCount: total items in feed

其中 filtersitemCount 的完整结构定义在 WidgetContext 中,还包含 filters.typepage 两个字段——例如仓库中 coderabbit.ts 就利用 filters.type 在 skills / tastes 页面("SKILL" / "TASTE")主动不注入。itemCount 则由 injectWidgets 在调用 shouldInject 时自动补全为当前列表长度(见 widgets/index.tswidget.shouldInject({ ...context, itemCount: items.length })),开发者无需手动传入。

创建标准 Widget(纯 TypeScript)

标准模式不写任何 UI 代码,只需导出一个 WidgetPlugin 对象,卡片外观由 WidgetCard 默认样式承担(赞助商头 + "Sponsored" 徽章、标题与类型徽章、内容预览 <pre> 块、标签、复制按钮与 CTA/运行按钮)。

创建文件:src/lib/plugins/widgets/{widget-id}.ts

import type { WidgetPlugin } from "./types";

export const {widgetId}Widget: WidgetPlugin = {
  id: "{widget-id}",
  name: "{Widget Name}",
  prompts: [
    {
      id: "{prompt-id}",
      slug: "{prompt-slug}",
      title: "{Title}",
      description: "{Description}",
      content: `{Multi-line content here}`,
      type: "TEXT",
      // Optional sponsor
      sponsor: {
        name: "{Sponsor Name}",
        logo: "/sponsors/{sponsor}.svg",
        logoDark: "/sponsors/{sponsor}-dark.svg",
        url: "{sponsor-url}",
      },
      tags: ["{Tag1}", "{Tag2}"],
      category: "{Category}",
      actionUrl: "{action-url}",
      actionLabel: "{Action Label}",
      positioning: {
        position: 2,
        mode: "repeat",
        repeatEvery: 50,
        maxCount: 3,
      },
      shouldInject: (context) => {
        const { filters } = context;

        // Always show when no filters active
        if (!filters?.q && !filters?.category && !filters?.tag) {
          return true;
        }

        // Add custom filter logic here
        return false;
      },
    },
  ],
};

一个可直接对照的真实例子是 coderabbit.tspositioning 配置为 position: 2, mode: "repeat", repeatEvery: 50, maxCount: 3(从第 2 位开始,每 50 条重复一次,最多出现 3 次);shouldInject 实现了完整的条件链——无筛选时注入、搜索词含 "code" 时注入、分类 slug 含 "vibe"/"code"/"coding" 时注入、标签含 "code"/"debug"/"git" 时注入,其余情况返回 false

创建自定义渲染 Widget(TSX + React)

自定义模式提供一个 React 组件,并通过 render 返回它,可以完全接管卡片设计(图片、动画、渐变背景等)。创建文件:src/lib/plugins/widgets/{widget-id}.tsx

import Link from "next/link";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import type { WidgetPlugin } from "./types";

function {WidgetName}Widget() {
  return (
    <div className="group border rounded-[var(--radius)] overflow-hidden hover:border-foreground/20 transition-colors bg-gradient-to-br from-primary/5 via-background to-primary/10 p-5">
      {/* Custom widget content */}
      <div className="flex flex-col items-center gap-4">
        {/* Image/visual element */}
        <div className="relative w-full aspect-video">
          <Image
            src="/path/to/image.jpg"
            alt="{Alt text}"
            fill
            className="object-cover rounded-lg"
          />
        </div>

        {/* Content */}
        <div className="w-full text-center">
          <h3 className="font-semibold text-base mb-1.5">{Title}</h3>
          <p className="text-xs text-muted-foreground mb-4">{Description}</p>
          <Button asChild size="sm" className="w-full">
            <Link href="{action-url}">{Action Label}</Link>
          </Button>
        </div>
      </div>
    </div>
  );
}

export const {widgetId}Widget: WidgetPlugin = {
  id: "{widget-id}",
  name: "{Widget Name}",
  prompts: [
    {
      id: "{prompt-id}",
      slug: "{prompt-slug}",
      title: "{Title}",
      description: "{Description}",
      content: "",
      type: "TEXT",
      tags: ["{Tag1}", "{Tag2}"],
      category: "{Category}",
      actionUrl: "{action-url}",
      actionLabel: "{Action Label}",
      positioning: {
        position: 10,
        mode: "repeat",
        repeatEvery: 60,
        maxCount: 4,
      },
      shouldInject: () => true,
      render: () => <{WidgetName}Widget />,
    },
  ],
};

仓库中的 book.tsx 是自定义渲染的完整范例:它实现了一个带 CSS @keyframes 3D 翻页动画的书籍封面卡片(perspective + preserve-3d + rotateY),说明自定义模式可以承载相当复杂的视觉实现。

常用自定义渲染模式

技能文档整理了四段可直接复用的 UI 片段:

渐变背景卡片

<div className="border rounded-[var(--radius)] overflow-hidden bg-gradient-to-br from-primary/5 via-background to-primary/10 p-5">

赞助商徽章

<div className="flex items-center gap-2 mb-2">
  <span className="text-xs font-medium text-primary">Sponsored</span>
</div>

响应式图片

<div className="relative w-full aspect-video">
  <Image src="/image.jpg" alt="..." fill className="object-cover" />
</div>

CTA 按钮

<Button asChild size="sm" className="w-full">
  <Link href="https://example.com">
    Learn More
    <ArrowRight className="ml-2 h-3.5 w-3.5" />
  </Link>
</Button>

注册 Widget 到插件注册表

Widget 文件创建后不会自动生效,必须注册进 widgets/index.ts。该文件维护一个显式的 widgetPlugins 数组(当前注册了 coderabbitWidgetbookWidgettextreamWidgetcommandcodeWidget),并向外暴露 getWidgetPlugins()getWidgetPrompts()getWidgetPlugin(id)getWidgetPrompt(pluginId, promptId) 等查询函数以及核心的 injectWidgets

编辑 src/lib/plugins/widgets/index.ts

  1. 在顶部添加 import:
import { {widgetId}Widget } from "./{widget-id}";
  1. 加入 widgetPlugins 数组:
const widgetPlugins: WidgetPlugin[] = [
  coderabbitWidget,
  bookWidget,
  {widgetId}Widget, // Add new widget
];

赞助商资源文件

如果 Widget 带赞助商信息,需要把 logo 放入 public/sponsors/ 目录:

  1. 亮色 logo:public/sponsors/{sponsor}.svg
  2. 暗色 logo(可选):public/sponsors/{sponsor}-dark.svg

渲染侧 widget-card.tsx 会同时输出两张 Image,用 Tailwind 的 dark:hidden / hidden dark:block 切换,并给赞助商头加 "Sponsored" 徽章——因此 logo 字段必填,logoDark 提供后暗色模式才有专属视觉。

定位策略:三种典型配置与底层插入算法

技能文档给出三种定位写法:

在第 5 位显示一次

positioning: {
  position: 5,
  mode: "once",
}

每 30 条重复一次,最多 5 次

positioning: {
  position: 3,
  mode: "repeat",
  repeatEvery: 30,
  maxCount: 5,
}

无限重复

positioning: {
  position: 2,
  mode: "repeat",
  repeatEvery: 25,
  // No maxCount = unlimited
}

源码级解析:插入位置如何计算

widgets/index.ts 中的 getWidgetInsertionPositions(widget, totalItems) 是定位逻辑的核心,行为可以归纳为:

  • "once" 模式maxCount 默认为 1,返回 [startPosition] 单点数组;startPosition 的取值链为 positioning.position ?? 顶层废弃字段 position ?? 2
  • "repeat" 模式repeatEvery 默认 30,从 startPosition 起以步长 repeatEvery 依次生成位置,直到超出 totalItems + positions.length(把已插入的 Widget 自身也计入长度,避免越界)或达到 maxCountundefined 即不限次数)。

随后 injectWidgets(items, context) 的注入流程为:

  1. 对每个 Widget 调用其 shouldInject(若未定义,默认行为是"无 q/category/tag 筛选时注入"),并自动把 itemCount 补为 items.length
  2. 收集所有 { position, widget, instanceIndex } 插入项,按 position 升序排序;
  3. 逐个 splice 插入,每插入一个就把后续插入点整体后移一位(offset++),保证多个 Widget 并存时位置互不冲突;
  4. 重复实例的 id 会被改写为 {widget.id}-{instanceIndex}(首个实例保持原 id),同时附加 isWidget: trueinstanceIndex,保证列表 keyrender(instanceIndex) 都基于唯一标识。

shouldInject 的五种典型写法

技能文档给出了覆盖常见运营场景的五种注入条件:

始终显示

shouldInject: () => true,

仅在无筛选时显示

shouldInject: (context) => {
  const { filters } = context;
  return !filters?.q && !filters?.category && !filters?.tag;
},

仅在特定分类显示

shouldInject: (context) => {
  const slug = context.filters?.categorySlug?.toLowerCase();
  return slug?.includes("development") || slug?.includes("coding");
},

搜索命中关键词时显示

shouldInject: (context) => {
  const query = context.filters?.q?.toLowerCase() || "";
  return ["ai", "automation", "workflow"].some(kw => query.includes(kw));
},

仅在条目足够多时显示

shouldInject: (context) => {
  return (context.itemCount ?? 0) >= 10;
},

最后一个模式适合推广类 Widget:当筛选后结果很少时不再插入卡片,避免推广内容淹没用户的真实搜索结果。

验证步骤

  1. 类型检查:
npx tsc --noEmit
  1. 启动开发服务器:
npm run dev
  1. 访问 /discover/feed 页面,确认 Widget 出现在配置的位置上,并切换不同筛选条件验证 shouldInject 的生效情况。

常见问题排查

技能文档汇总的排障表如下:

问题 解决方案
Widget 不显示 检查 shouldInject 逻辑,确认已在 index.ts 中注册
TypeScript 报错 确保从 ./types 导入类型,核对 sponsor 对象结构
样式问题 使用 Tailwind 类,对齐现有 Widget 的样式模式
位置不对 记住位置是 0-indexed,检查 repeatEvery 取值

渲染链路:注入发生在客户端,为什么这样设计

理解 Widget 为何"只在客户端注入",有助于调试时的现象解释。infinite-prompt-list.tsx 中的关键代码是:

// Inject widgets into the prompt list (widgets decide their own injection logic)
// Only inject after mount to prevent hydration mismatch
const itemsToRender = isMounted ? injectWidgets(prompts, { filters }) : prompts;

即服务端首屏渲染的列表不含 Widget,组件挂载后(isMounted 为 true)才把 filters 作为 WidgetContext 传入 injectWidgets。注释明确说明这是为了避免 hydration mismatch——因为 shouldInject 是运行时函数、位置计算依赖客户端状态,若在服务端渲染会导致首屏 HTML 与客户端不一致。随后列表用 isWidget(item) 守卫分流:Widget 条目交给 WidgetCard,其余交给 PromptCard

widget-card.tsx 内部的优先级也很清晰:只要定义了 prompt.render,就直接 return <>{prompt.render(prompt.instanceIndex)}</>,完全跳过标准卡片;否则走默认样式,并在此处接入埋点——复制按钮触发 analyticsWidget.copy(...),CTA 点击触发 analyticsWidget.action(...)(见 analytics.ts 中的 analyticsWidget)。也就是说,标准模式的 Widget 天然具备内容复制、"Run prompt" 以及点击统计能力,无需额外开发。

小结

prompts.chat 的 Widget 体系用一个注册表 + 一个纯函数注入引擎(injectWidgets)实现了 feed 中的可编程插卡:开发者只需交付一个 WidgetPlugin 对象,通过 positioning 声明位置策略、shouldInject 声明出现条件,选择标准模式获得开箱即用的赞助商卡片,或用 render 完全自定义 UI。对照 SKILL.mdtypes.tswidgets/index.tswidget-card.tsx,本文覆盖的配置模板、定位算法、注入逻辑与渲染链路可以完整复现一个从创建到上线验证的 Widget 插件。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
982
503
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384