prompts.chat Widget 插件开发指南:Feed 注入机制、定位策略与双渲染模式全解析
本文以 prompts.chat 仓库中的 widget-generator 技能文档(.windsurf/skills/widget-generator/SKILL.md)为主体,完整讲透如何为 prompts.chat 的提示词信息流(feed)开发可自定义的 Widget 插件:包括标准/自定义两种渲染模式的完整代码模板、positioning 定位配置的全部参数与默认值、shouldInject 注入逻辑的五种典型写法,以及如何完成注册、资源放置与验证。读完并对照 widgets 类型定义 和 注入引擎实现,你可以独立开发、注册并调试一个会在指定位置出现的 feed 卡片插件。
Widget 是什么:注入到提示词信息流的插件卡片
Widget 是注入到 prompts.chat 提示词信息流中的一类特殊条目,用于展示推广内容、赞助商卡片或自定义交互组件。它与普通提示词卡片共存于同一瀑布流中,但具备两个关键特性:
- 自主注入逻辑:每个 Widget 通过
shouldInject回调自行决定在哪些筛选条件下出现(例如只在无筛选时、只在特定分类下、只在搜索命中关键词时); - 可配置的位置策略:通过
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,有两处值得注意的实现细节,写插件时应以仓库实际类型为准:
render实际接收一个instanceIndex参数(0 基的重复实例序号)。因为同一个 Widget 在 repeat 模式下会在列表中多次出现,框架需要用它生成唯一的id,render拿到该序号后才能为每个实例创建互不冲突的 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;
- 顶层
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(插件显示名)、渲染模式(standard 或 custom)、赞助商信息(可选)。完整的配置项分为六组:
基础信息
- 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
其中 filters 与 itemCount 的完整结构定义在 WidgetContext 中,还包含 filters.type 和 page 两个字段——例如仓库中 coderabbit.ts 就利用 filters.type 在 skills / tastes 页面("SKILL" / "TASTE")主动不注入。itemCount 则由 injectWidgets 在调用 shouldInject 时自动补全为当前列表长度(见 widgets/index.ts 中 widget.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.ts:positioning 配置为 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 数组(当前注册了 coderabbitWidget、bookWidget、textreamWidget、commandcodeWidget),并向外暴露 getWidgetPlugins()、getWidgetPrompts()、getWidgetPlugin(id)、getWidgetPrompt(pluginId, promptId) 等查询函数以及核心的 injectWidgets。
编辑 src/lib/plugins/widgets/index.ts:
- 在顶部添加 import:
import { {widgetId}Widget } from "./{widget-id}";
- 加入
widgetPlugins数组:
const widgetPlugins: WidgetPlugin[] = [
coderabbitWidget,
bookWidget,
{widgetId}Widget, // Add new widget
];
赞助商资源文件
如果 Widget 带赞助商信息,需要把 logo 放入 public/sponsors/ 目录:
- 亮色 logo:
public/sponsors/{sponsor}.svg - 暗色 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 自身也计入长度,避免越界)或达到maxCount(undefined即不限次数)。
随后 injectWidgets(items, context) 的注入流程为:
- 对每个 Widget 调用其
shouldInject(若未定义,默认行为是"无q/category/tag筛选时注入"),并自动把itemCount补为items.length; - 收集所有
{ position, widget, instanceIndex }插入项,按 position 升序排序; - 逐个
splice插入,每插入一个就把后续插入点整体后移一位(offset++),保证多个 Widget 并存时位置互不冲突; - 重复实例的
id会被改写为{widget.id}-{instanceIndex}(首个实例保持原 id),同时附加isWidget: true与instanceIndex,保证列表key与render(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:当筛选后结果很少时不再插入卡片,避免推广内容淹没用户的真实搜索结果。
验证步骤
- 类型检查:
npx tsc --noEmit
- 启动开发服务器:
npm run dev
- 访问
/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.md、types.ts、widgets/index.ts 与 widget-card.tsx,本文覆盖的配置模板、定位算法、注入逻辑与渲染链路可以完整复现一个从创建到上线验证的 Widget 插件。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00