MoreRSS

site iconsugar | 粥里有勺糖修改

99年,西南石油大学,热爱开源。前端,美团,阿里。运营视野修炼周刊。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

sugar | 粥里有勺糖的 RSS 预览

视野修炼第130期 | 光影效果

2026-08-23 17:46:23

视野修炼第130期 | 光影效果

欢迎来到第 130 期的【视野修炼 - 技术周刊】,下面是本期的精选内容简介

又 🕊 了好几个月了,VibeCoding 多了都没顾上刷资讯了,最近补一下,有趣的还挺多。

🔥强烈推荐

1. 屏幕边缘光影效果

类似手机助手唤起时(如苹果的 Siri/超级小爱)周围的柔和发光效果。

2. Blossom Carousel - 轮播组件

基于浏览器原生滚动 API 的轮播组件。部分场景可以实现0外部依赖,

3. Virtua - 快且小的虚拟滚动库

支持 React、Vue、Solid 等,API 简单。

Vue React

🔧开源工具&技术资讯

4. Dot Matrix = 点阵动画

先收藏,以后没准能用上

5. Stolen Buttons - 可供参考的一大批按钮

印象之前有一个插件也是类似这样,会将访问过的网站收集一个按钮。

Button Stealer

6. termdom - 使用3剑客构建终端应用

使用熟悉的dom API 来操作构建终端应用。

7. Ant - 仅 9 MB的运行时

C 语言构建的 JavaScript 运行时,仅 9MB。

8. gridstack.js

可用于创建可灵活拖拽的大盘页面。

9. tslog - Beautiful logging

多运行时支持的日志库,运行时 0 外部依赖。

看着不错,引入项目里用用看。

10. import defer

7月底进入 stage 3,延迟加载模块的语法。

在此之前动态加载的版办法。

js
// cjs
exports.renderReport = function (data) {
  const chartRenderer = require('chart-renderer')
  return chartRenderer.render(data)
}

// esm
export async function renderReport(data) {
  const { render } = await import('chart-renderer')
  return render(data)
}

defer 语法。

ts
// 此时会导入,但不执行
import defer * as ns from "some-module";


function useFeature() {
  // 首次读取的时候才执行模块里的代码
  return ns.feature(); // reading this is what triggers evaluation
}

// or 动态导入
const ns = await import.defer("some-module");

又学到一手

11. hucre - 0 依赖的 Excel 处理库

支持读写XLSX、CSV、ODS、JSON、NDJSON、XML等格式。服务端客户端都可以使用。

12. scriptc - TS2Native

将TS脚本编译成可执行文件。

📚教程&文章

13. Node 新文档站

https://beta.docs.nodejs.org/

⭐️强力推荐关注

周刊部分内容来源如下渠道,推荐大家关注。

分享一下最近做的AI记账 App

2026-08-19 00:50:00

分享一下最近做的AI记账 App

最近得闲,学 Agent 过程中顺便做了一个 AI 应用。

最近对💰比较敏感了 😄,就想搞搞记账这件事。

于是自己做了一个,叫 妙蛙记账

目前支持 Web 和 Android:https://miaowa.sugarat.top/docs/

核心功能就是:自然语言描述,自动记账和完成数据查询。

记账:一句话搞定

自然语言描述账单信息

  • 单笔记录午饭花了 32(自动归类到餐饮-午餐)
  • 多笔连记咖啡 18,地铁 4,午饭 35(一次记 3 笔;账本没有的二级分类会自动建)
  • 补记昨天昨天晚饭花了 68(时间自动识别为昨天)
  • 记录收入发工资收入 8000
  • 指定分类 / 点名商家打车 25 记到交通拼多多购物 50(会落到购物 · 拼多多,没有就自动建这个二级)

查账:问就完事了

直接用大白话跟它“查账”:

  • 查汇总这个月花了多少? / 今天餐饮花了多少?
  • 按分类展开这个月餐饮按二级展开 / 这个月都花哪了
  • 找记录上一次吃黄焖鸡是什么时候? / 哪几天喝了奶茶?
汇总查询 最近消费查询

其他实用功能

除了 AI 交互,该有的基础功能也有:

  • 多账本:日常开销和出差可以分开记;也可以邀请家人一起记同一本。
  • 图表与预算:报表支持按日 / 周 / 月 / 年看分类占比、趋势和排行;账本可以设月度预算。
  • 传统手动记账:不想说话时,首页也能普通记一笔、改分类、补备注。

体验地址

💻 官网(包含网页版入口与 Android 下载):https://miaowa.sugarat.top/docs/

网页未登录是只读演示,登录后才是自己的账本,也能真正跟 AI 对话。

这还是个比较早期的版本,主打极简和实用,后续会加入识图,Native 侧的一些快速识别记账等功能。

欢迎试用反馈建议,帮助产品迭代。

交流群:

Harness 学习笔记分享(Part 1)

2026-08-10 10:26:33

Harness 学习笔记分享(Part 1)

本文仅代表作者个人观点,如果内容有错误或和你理解上有所出入,欢迎交流与指正。

学习过程参考了 Pi Agent 的源码。按照其分层思路和一些实现方案,笔者自行实现了一个 MVP 版本的 Harness。

本文主要分享一下整个过程中的一些学习心得和个人的一些想法。

前言

“Agent” 从最开始文本对话式,到多模态(语音/图片/视频)智能体,再到现在的 Agent(能够辅助办公、开发、生成完整项目等),不同阶段 Agent 的定义也不一样。

最近的一阶段是 "Model + Harness = Agent"。

之前是高频使用各种智能体辅助写文档,改项目代码。

最近一段时间准备做一些带 Agent 能力的 AI 应用,所以先学习一下 Harness 相关的知识,知己知彼😋。

开局先来一张图

Model + Harness

Harness 算是模型的运行时,各家 Agent 的差异也就在这部分,模型都是可以随时切换的。

所以没有 Harness 的「Agent」,大部分就是纯聊天。

先小小总结一下几个主要模块

各个模块 作用
Model 读 Context,产出文本或结构化 tool_calls
Context 本轮真正送进模型的内容,不单单是「用户输入的那一句话」
Tools 实际做外部调用,真正改文件、跑命令
Harness 负责 循环、校验、执行、上下文组装、回写、权限、资源控制等行为
Agent 模型跑在 Harness 上、冲着目标多步往前推进,直到完成或达到终止条件

Tools

Tools 是模型和外部沟通的桥:模型只负责发出结构化调用,真正去改文件、跑命令、调 API 的是具体 Tool 实现;调度和校验则由 Harness 来做。

先说 Function Calling

Function Calling(现在大部分时候也叫 Tool Calling)解决的问题是:

避免模型用自然语言描述去说明要调用的工具,而是让它输出可解析的调用结构。

没有这套约定时,模型可能写

我需要调用 get_weather(上海) 来获取天气信息

应用侧很难可靠地解析。有了 Function Calling:

  1. 先把工具的 schema 交给模型(能调什么、参数长什么样)
  2. 模型需要时返回 tool_calls(调用谁、参数是什么、本次调用的 id)
  3. Harness 解析调用工具执行,把结果以 tool 消息写回
  4. Harness 再请求模型,基于工具执行结果继续做推理

贴一张官方的图:function-calling#how-it-works

下面是我画的一版 Model / Harness / Tools 怎么流转起来的(对应上面 1→4 步):

Function Calling 交互释义

调用示例

下面来个最简 demo(OpenAI Chat Completions,非流式),看一下调用结构

ts
// 1)准备工具声明(schema)——只描述能力,不含实现
const tools = [
  {
    type: 'function',
    function: {
      name: 'get_weather',
      description: '查询某城市当前天气',
      parameters: {
        type: 'object',
        properties: {
          city: { type: 'string', description: '城市名,如上海' },
        },
        required: ['city'],
        additionalProperties: false,
      },
    },
  },
]

const messages = [
  { role: 'user', content: '上海今天天气怎么样?' },
]

// 2)一次请求:messages + tools
const res = await fetch(`${OPENAI_API_BASE_URL}/v1/chat/completions`, {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${OPENAI_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    model: 'gpt-5.6', // 模型 id
    messages,
    tools,
    tool_choice: 'auto', // 模型自己决定是否调用工具,也可设为 none / required / 指定某个工具
  }),
})

const data = await res.json()
const message = data.choices[0].message
// ↓ 输出释义见下方示例

一次成功响应(需要调工具时)大致长这样:

ts
// data 示意(省略 usage 等)
{
  id: "chatcmpl-xxx",
  choices: [
    {
      index: 0,
      finish_reason: "tool_calls", // 因工具调用结束本轮;最终文本常见 "stop"
      message: {
        role: "assistant",
        content: null, // 也可能是一段说明;有 tool_calls 时经常为 null
        tool_calls: [
          {
            id: "call_abc123",
            type: "function",
            function: {
              name: "get_weather",
              arguments: '{"city":"上海"}', // 注意:是字符串,不是对象
            },
          },
        ],
      },
    },
  ],
}

返回结果里,重点关注这几个:

  • message.tool_calls:决定是否需要执行工具
  • tool_calls[].id:回写结果时需要一一对应上id
  • tool_calls[].function.name / arguments:工具名和参数
  • finish_reason"tool_calls" 表示「需要执行工具」;"stop" 表示本轮结束

解析执行示例

Harness 处理工具调用的核心逻辑是:校验 → 执行 → 回写 → 再请求。

ts
// 1)先把本轮 assistant(含 tool_calls)写入 messages,下一轮还要带上
// 保持 OpenAI 原始结构即可
messages.push({
  role: 'assistant',
  content: message.content,
  tool_calls: message.tool_calls,
})

// 2)解析参数 → 校验 → 执行 → 回写
for (const tc of message.tool_calls) {
  // 格式化一下参数
  const call = {
    id: tc.id,
    name: tc.function.name,
    arguments: JSON.parse(tc.function.arguments || '{}'), // 字符串 → 对象
  }

  // 按 schema 校验;不合法则回写错误
  if (isInvalid(call)) {
    messages.push({
      role: 'tool',
      tool_call_id: call.id,
      content: '参数不合法',
    })
    continue
  }

  // 执行工具
  const result = await executeTool(call)

  // 回写结果到上下文中
  messages.push({
    role: 'tool',
    tool_call_id: call.id,
    content: result.content, // 字符串
  })
}

// 3)带着更新后的 messages 再请求模型
const res = await fetch(`${OPENAI_API_BASE_URL}/v1/chat/completions`, {
  // ... 其他参数
  body: JSON.stringify({ messages, tools, tool_choice: 'auto' }),
})

const data = await res.json()
// 模型再次回复,可能包含 tool_calls 或继续自然语言回复
const message = data.choices[0].message

下面是第二次请求模型的 messages 示例,包含了工具调用结果:

json
[
  { "role": "user", "content": "上海今天天气怎么样?" },
  {
    "role": "assistant",
    "content": null,
    "tool_calls": [
      {
        "id": "call_abc123",
        "type": "function",
        "function": {
          "name": "get_weather",
          "arguments": "{\"city\":\"上海\"}"
        }
      }
    ]
  },
  {
    "role": "tool",
    "tool_call_id": "call_abc123",
    "content": "{\"temp\":28,\"unit\":\"C\"}"
  }
]

小结

  1. Function Calling 让模型输出可解析的 tool_calls 参数
  2. Tool:由声明(schema)与实现(handler)组成;声明给到模型,实现留在 Harness 里
  3. Harness 负责校验 → 执行 → 按 tool_call_id 回写 → 再请求

Tools 声明与实现

Agent Loop

真正办事的 Agent,需要把上面的流程自动循环起来:组装上下文 → 请求模型 → 有调用就执行回写 → 再请求,直到给出最终答复,或被约束条件强制停掉。

当然我这里只是一个最简单的实现(用「有没有 tool_calls」来决定要不要自动推进),实际的循环推进条件可能还有其它的策略。

最简 Agent Loop

精简的循环示意

ts
async function runAgent(state) {
  while (true) {
    // 1)组装本轮真正进模型的 Context
    const messages = assembleContext(state)

    // 2)请求模型
    const assistant = await adapter.stream({ messages, tools: state.tools })

    // 3)判断有没有 tool_calls
    if (!assistant.tool_calls?.length) {
      return assistant.content
    }

    // 4)有调用:校验 → 执行 → 回写
    state.messages.push({
      role: 'assistant',
      content: assistant.content,
      tool_calls: assistant.tool_calls,
    })

    for (const call of assistant.tool_calls) {
      const result = await executeTool(call)
      state.messages.push({
        role: 'tool',
        tool_call_id: call.id,
        content: result.content,
      })
    }
    // 5)带着工具结果进入下一轮 while
  }

  return assistant.content
}

和纯对话的差别就在第 4 步:模型输出调用指令,Harness 调度 Tools,用执行结果继续推进。

其它条件

Pi: agent-loop 为例,默认仍是「本轮有没有工具调用」驱动自动续跑,同时还会看下面这些条件:

1)工具结果不一定需要回传

  • 工具执行结果包含一个 terminate: true 字段来决定是否回传
  • 有时最终答案已经在工具结果里了(比如结构化输出工具),不必再花一轮 LLM 把结果「复述」一遍
  • 如果本轮工具调用全部带 terminate: true,就不需要再请求模型
ts
// 示意:结构化输出工具跑完就结束本轮自动续跑
async execute(_id, params) {
  return {
    content: [{ type: "text", text: "已保存结构化结果" }],
    details: params,
    terminate: true,
  };
}

2)shouldStopAfterTurn(回合结束后可选停)

内置钩子:回合结束后由调用方决定是否继续;也可在这里选择性地压缩上下文。

3)steering / follow-up

这两个都是「往 Loop 里塞用户消息」,时机不同:

名词 白话 典型时机
steering Agent 还在跑时,用户可以临时插一句进来,改变执行方向 当前回合工具跑完后、下一次请求模型前注入
follow-up Agent 准备结束了,队列里还有一句比如「顺便再总结一下」 内层循环要退出前从队列里取出;有则继续跑

相关 loop 代码大概就 100 来行,比较容易理解。

小结

  1. Loop 最小模型 = 组装上下文 → 调模型 →(有 tool_calls 则)执行工具 → 再请求进行下一轮
  2. Trace 可以在 Loop 里记录,通过事件将相关信息暴露出来,在 Loop 外监听,方便后续进一步的分析。

运行时约束

Loop 能跑起来之后,接着就是:怎么让它停得住。

Prompt 只能做一些软约束:不可逆操作、调用死循环、反复执行等,需要在 Harness 代码里做硬约束。

运行时约束

常见约束

ts
const controller = new AbortController()

await runAgent({
  maxSteps: 8, // 最大执行回合数
  timeoutMs: 60_000, // 整次 Run 的超时时间
  stopOnToolError: false, // false:错误写回,模型来纠正
  signal: controller.signal,
  onConfirm: async (call) => {
    // 高危工具执行前二次确认;拒绝则写成错误 tool 结果,不调真实 handler
    if (call.name === 'rm' || call.name === 'bash') {
      return window.confirm(`允许执行 ${call.name}?`)
    }
    return true
  },
})

// controller.abort(); 主动取消

校验

执行工具调用前加一层执行前校验:校验 schema,工具是否存在,参数是否合法等。

异常情况可以回写错误的 Tool 消息,让模型可以自主的重试,用户也能感知到。

小结

循环的终止与工具调用安全边界,靠 Harness 代码硬约束。

执行沙箱等下来仔细研究一下,后面再单开文章。

Context Engineering

上下文组装也是 Context Engineering 在 Harness 里的应用。

每次给模型推送的,不只是「用户刚输入的那一句」,而是 Harness 组装出来的完整上下文,常见包括:

  1. 系统提示词:角色、全局行为;项目 Rules 也常并进这一层
  2. 历史对话:全文,或先对历史对话内容进行摘要,再保留最近若干轮
  3. tool schemas:工具声明(本地 + MCP 统一成同一套 schema)
  4. 工具调用结果:上一轮 / 本轮 Loop 写回的 role: "tool" 消息
  5. 本轮用户输入:当前意图
  6. 按需材料:Memory(跨会话的记忆)、RAG(检索的内容片段)、SKILL(SOP 说明)等,相关时才注入

构造上下文

示意代码

ts
const context = assembleContext({
  system: [identity, ...rules], // ① 系统提示 + Rules
  tools: toolDefs, // ③ schemas(含 MCP 归一后的)
  history: messages, // ②④ 历史里已含 tool 结果;或先摘要
  user: currentUserMessage, // ⑤ 本轮输入
  // skillCatalog / skillBody     // ⑥ SKILL:SKILL 目录和相关SKILL 描述,全文按需注入
  // memoryHits / ragChunks       // ⑥ Memory / RAG:外置命中 → 注入
})

小结

  1. 常驻内容:系统提示词 / 历史摘要 / tool schemas / tool 结果 / 本轮 user;
  2. 按需内容:Memory、RAG、SKILL等,相关时才注入

这块的细节实现,待我下去再多研究几个项目,单独出一期文章。

SKILL

可以看成是解决某类问题或完成某项任务的流程规范文档(SOP)。

SKILL 不是 Tool,可以看做是一大段提示词,实际执行仍是通过内部的指令描述调用 Tool 来完成任务。

SKILL 结构

常见就是一份 Markdown(可带 frontmatter),大致三块:

部分 内容
元数据 namedescription(何时该用);可选权限(允许的工具) / 依赖说明
正文 步骤、约束、输出格式、决策规则(真正的 SOP)
其它 (可选) references/、模板、脚本、workflow 等,这块一般是渐进式披露的
md
---
name: weather-brief
description: 用户要天气简报时使用
---

1. 先调 get_weather …
2. 再按固定结构输出 …

怎么进 Context

学到的几种方式:

方式 谁决定加载全文 全文怎么进
手工 / 显式 用户指定(如 /skill:name 直接写入 Context
Harness 匹配 按 SKILL 描述与用户输入做简单的关键词匹配 匹配到的正文写入 Context
模型决定 模型自己选要加载哪个 先把SKILL目录里的(name + description)给模型,模型决策后,再调工具 load_skill,SKILL 正文以 tool 结果回写

三种方式的逐步流程(同一例子 weather-brief):

SKILL 三种进 Context 流程

小结

SKILL 可以由 Harness 发现并注入上下文,也可以给模型披露一些基本信息让模型自己决定加载哪个。

MCP

MCP(Model Context Protocol)是一套开放协议,用来标准化 AI 应用怎么连接外部系统(数据、工具等)。

本文先只看 Tools 部分:统一发现与调用。

收到 tool_calls 后的执行路径:

Harness → 选则 Client → Server → 返回的结果回写 Context。

MCP 构成与调用

角色 职责
MCP Host 创建/管理多个 Client,这里就是 Harness 去负责管理
MCP Client 与单个 Server 的会话连接:提供listToolscallTool 等常用方法
MCP Server 暴露 Tools 并执行(本地进程或远程服务;另有 Resources / Prompts,本文不展开)

传输:本地多用 stdio,远程多用 Streamable HTTP;数据传输协议使用 JSON-RPC

MCP Server(Tools)

ts
// mcp-server.mjs —— Host spawn 的就是这个进程
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'

const server = new McpServer({ name: 'demo', version: '1.0.0' })

server.registerTool(
  'add',
  {
    description: '计算两个数字之和',
    inputSchema: {
      a: z.number().describe('加数 a'),
      b: z.number().describe('加数 b'),
    },
  },
  async ({ a, b }) => ({
    content: [{ type: 'text', text: String(a + b) }],
  }),
)

const transport = new StdioServerTransport()
await server.connect(transport) // stdout 专给 JSON-RPC;日志用 console.error

接入到 Harness 中

下面提到的 registry 可以看做是 Harness 里负责管理工具注册和发现的模块

包含本地 Tools + MCP Tools 的管理

ts
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'

// 连接 MCP Server
const transport = new StdioClientTransport({
  command: 'node',
  args: ['./mcp-server.mjs'],
})
const client = new Client({ name: 'my-harness', version: '0.1.0' })
await client.connect(transport) // spawn + initialize

const { tools } = await client.listTools()
for (const t of tools) {
  // 写入 Harness 工具表
  registry.register({
    name: t.name,
    description: t.description ?? '',
    parameters: t.inputSchema ?? { type: 'object', properties: {} },
    source: { kind: 'mcp', client },
  })
}

async function executeTool(call: ToolCall): Promise<ToolResult> {
  const meta = registry.get(call.name)
  if (meta?.source.kind === 'mcp') {
    const res = await meta.source.client.callTool({
      name: call.name,
      arguments: call.arguments,
    })
    return {
      toolCallId: call.id,
      content: JSON.stringify(res.content),
      isError: Boolean(res.isError),
    }
  }
  return localHandlers[call.name](call)
}

CLient 连接是长生命周期的:

  • 启动时 new ClientconnectlistTools 注册一次工具;
  • 之后多轮 Agent Loop 里反复 callTool,复用之前的连接;
  • 一般只有 Harness 退出或用户主动停止该 Server 时才 close()

MCP 还包含 Resources 和 Prompt 能力,这块还要再下来研究一下与 Harness 的联动机制

最后

到这里 0-1 搞一个 MVP 版可跑的 Agent 应该是妥妥的了

下一篇(Part 2)大概会是以下的内容:

  1. Memory / RAG:怎么检索、何时注入、怎么裁切;准备看一下各大 Agent 框架的实现细节,和一些业界开源库对比一下效果
  2. 执行沙箱和权限管控:部分工具执行时怎么与用户项目环境隔离;工具白名单,执行权限控制等
  3. MCP 其它内容:Resources / Prompts

相关链接

润了

2026-08-01 20:56:52

润了

如题 😄 嘿嘿,7月底从 美团 离职了。

下一步

前端基本已经不需要古法手搓了,准备转 Agent 方向研究了!

顺便搞点 Agent/AI 相关项目练练手。

家人们有合适的机会也可以推一推。🫶

之前都在干什么

VitePress 产品卡片展示插件介绍

2026-07-29 17:55:45

VitePress 产品卡片展示插件介绍

笔者维护的 VitePress 博客主题 中包含一个「作品/产品卡片」的能力,可以在 markdown 里通过简单的 ::: card 容器语法快速展示项目。为便于在主题之外复用,将其抽离为独立插件 vitepress-plugin-product-card

如何使用

3 步接入:

  1. 安装
bash
pnpm add vitepress-plugin-product-card
  1. .vitepress/config.ts 中注册 markdown 容器插件
ts
import { defineConfig } from 'vitepress'
import { productCardMarkdownPlugin } from 'vitepress-plugin-product-card'

export default defineConfig({
  markdown: {
    config(md) {
      md.use(productCardMarkdownPlugin, {
        showCreated: true, // 默认 true
        showUpdated: true // 默认 true
      })
    }
  }
})
  1. .vitepress/theme/index.ts 中注册 Vue 组件
ts
import DefaultTheme from 'vitepress/theme'
import { registerProductCard } from 'vitepress-plugin-product-card/client'

export default {
  extends: DefaultTheme,
  enhanceApp({ app }) {
    registerProductCard(app)
  }
}

在 Markdown 中使用

md
::: card 我的产品
- title: sugar-blog
  desc: 简约风的 VitePress 博客主题
  icon: 📝
  iconColor: '#42b883'
  link: https://theme.sugarat.top
  github: https://github.com/ATQQ/sugar-blog
  tags: [VitePress, Vue3, TypeScript]
----
- title: 另一个项目
  desc: 单卡覆盖全局默认
  github: https://github.com/some/repo
  showCreated: false
:::
  • 顶部标题(::: card 我的产品)可选,不写则不渲染分组标题
  • 多张卡片之间使用 ---- 分隔
  • 每张卡片以 - title: xxx 开头

支持的字段

字段 类型 说明
title string 卡片标题(必填)
desc string 卡片描述,支持行内 markdown
icon string 图标:URL / 相对路径 → 渲染 <img>;否则渲染为字符
iconColor string 字符 icon 的背景色
link string 点击标题跳转的地址
github string GitHub 仓库地址,自动拉取创建/更新时间
tags string[] 标签数组
showCreated boolean 覆盖全局,是否展示创建时间
showUpdated boolean 覆盖全局,是否展示最后更新时间

插件参数

参数 类型 默认值 说明
showCreated boolean true 全局默认,是否展示创建时间
showUpdated boolean true 全局默认,是否展示最后更新时间
componentName string ProductCard 输出 html_block 使用的组件名,与 registerProductCard(app, name) 配合使用

@sugarat/theme 中使用

主题内置该插件,直接在文章里用 ::: card 即可。