@decentnetwork/chat-components
v0.1.3
Published
Interactive forms in chat — the shared schema between an agent that asks and a client that renders. Declarative components ride inside the message text, so any transport that carries chat carries forms; no HTML, no URLs, no scripts. Zero dependencies, bro
Readme
@decentnetwork/chat-components
Interactive forms in chat. An agent declares a form as data; the client renders it with its own trusted widgets.
agent client
encodeForm(text, form) ──chat──▶ readMessage(text) → render widgets
│
readMessage(text).interaction ◀──chat── encodeInteraction(...)
route on custom_idNo HTML crosses the wire, no URL is fetched, no script runs. A chat client that escapes all markup — it should — does not have to relax that to support forms.
Where this sits
In the UI layer, and nowhere else. The transport (@decentnetwork/peer, the
decentlan daemon, anything that carries a chat message) does not interpret this
format and has no reason to. This package is what the asking side and the
rendering side share, so the format cannot drift into two implementations that
almost agree.
Which is also why the payload rides inside the message text: any agent that can already send a chat message can send a form — no new transport op, no daemon change, no privileged access.
Install
npm i @decentnetwork/chat-componentsZero dependencies. Works in the browser and in node.
Agent side
import { encodeForm, readMessage } from "@decentnetwork/chat-components";
await sendChat(userid, encodeForm(
"语音克隆:请选择音色和质量。", // required — see below
{
custom_id: "vc-form-7f3a", // yours; echoed back so you can route
components: [
{ type: "select", id: "voice", label: "音色", required: true,
options: voices.map((v) => ({ label: v, value: v })) },
{ type: "select", id: "quality", label: "质量", default: "good",
options: [{ label: "快", value: "fast" },
{ label: "较好", value: "good" },
{ label: "最佳", value: "best" }] },
{ type: "submit", id: "go", label: "开始换声" },
],
}
));
// later, on an inbound message
const { interaction } = readMessage(msg.text);
if (interaction?.custom_id === "vc-form-7f3a") {
start(interaction.values.voice, interaction.values.quality);
}The text argument is required. It is what a person reads on a client that doesn't render forms, so it is a parameter rather than a convention you have to remember: you cannot accidentally ship a message that is meaningless without component support.
Bind anything the form refers to at send time, keyed by your custom_id.
Resolving it later from "the newest thing this peer sent" silently picks the
wrong one when two are in flight.
Client side
import { readMessage, answers, buildInteraction, encodeInteraction }
from "@decentnetwork/chat-components";
const answered = answers(thread.filter((m) => m.dir === "out").map((m) => m.text));
for (const m of thread) {
const { text, form } = readMessage(m.text);
render(text);
if (form) render(answered.has(form.custom_id) ? summary(form) : widgets(form));
}
// on submit
await sendChat(userid, encodeInteraction(buildInteraction(customId, "go", values)));Submitted state is derived from the transcript, not stored beside it — one source of truth, and it survives a reload, a resync, or a second device.
Components
select, text, submit render today. button and attachment are declared
in the type and dropped by the parser until a build renders them: an older
client meeting a newer form must degrade, not break.
Security
Every value parsed here arrives from a remote peer, so the parsers are a trust boundary:
- unknown component types are dropped, not rejected (forward compatibility)
- every count and length is capped — a peer must not be able to freeze another peer's UI
- a
defaultthat isn't one of the options is ignored: it would submit a value the agent never offered - duplicate ids are dropped: they would alias each other's values
- a form with no submit, or a submit with no inputs, is refused as unusable
readMessagenever throws, whatever arrived- a payload that fails validation is still stripped from the display text — showing raw JSON next to the fallback sentence is worse than showing nothing
accept / max_bytes on an attachment are hints for the picker, never
enforcement; the receiving agent still validates.
Submissions are authenticated by the transport — they arrive from a known
identity — so an agent knows who submitted without extra work. It does not
know the payload is well-formed, which is what parseInteraction is for.
Tests
npm testLicense
GPL-3.0-or-later
