@12-apps/chat

中文文档
1.3.0发布于 2026/10/10MITES Module零依赖构建来源已验证近 30 天下载 3.3万

Conversation threads between the parties to one piece of work, each shown only by a host-chosen role label: framework-neutral HTTP routes with per-role write windows, a contact-info filter and quick replies, plus one thread screen built from @12-apps/ui p

原始使用文档
文档目录9 节
  1. What is in the box
  2. The host decides who is in a thread
  3. The wire
  4. The contact filter
  5. Retention
  6. Indexes for the host's own queries
  7. The screen
  8. Host options
  9. Copy

@12-apps/chat

Conversation threads between the parties to one piece of work, where each party is known to the others only by its role's label. No person id, name or contact detail crosses the wire. Mount the routes once per audience, and the same thread screen renders in a web SPA and in a React Native app.

What is in the box

subpathwhat
./servercreateApiChat(config) → { routes }: framework-neutral GET /, POST /messages, POST /read. createChatReads gives unread badges for the host's own lists.
./clientcreateChatClient and useChatThread, both platform-free: for a host drawing its own UI.
./reactcreateWebChat(config) → { ChatThread }: the web surface.
./nativecreateNativeChat(config) → { ChatThread }: the React Native surface.
./manifest*wiring manifests for the server, web and native runtimes.
prisma/chat.prisma (ChatThread, ChatMessage, ChatReadMarker) plus its migrations. pnpm --filter @12-apps/chat prisma:sync copies the partial into a host schema folder.

The host decides who is in a thread

const { routes } = createApiChat({
  db: () => getPrismaClient(),
  authorize: async (request) => {
    // Is this caller a party to the thread the URL names? Under which role?
    // May that role still write? Answer null for "no such thread for you" (404).
    return { tenantId, threadKey: `job:${request.params.id}`, role: "client",
             authorId: user.id, readerId: user.id, canWrite: true };
  },
  roles: {
    client: { label: "Client", shared: false, maxLength: 500, freeText: true,
              blockContact: [], quickReplies: [], rateLimit: { max: 20, windowMs: 60_000 } },
    agent:  { label: "Agent", shared: false, maxLength: 160, freeText: true,
              blockContact: ["phone", "email", "url", "handle"],
              quickReplies: [{ key: "arrived", text: "I have arrived." }],
              rateLimit: { max: 6, windowMs: 60_000 } },
    team:   { label: "Team", shared: true, maxLength: 1000, freeText: true,
              blockContact: [], quickReplies: [], rateLimit: { max: 30, windowMs: 60_000 } },
  },
  copy: EN_US_CHAT_SERVER_COPY,          // or a per-request resolver
  contactVocabulary: { numberWords: { nine: "9" }, atWords: ["at"] },
  onMessage: async (event) => { /* notify, publish a live hint */ },
  onRefused: async (event) => { /* who keeps trying to pass contact info */ },
  onError: (error, { hook }) => logger.error(`chat ${hook} failed`, error),
});

db may return the client or a promise of it. onMessage and onRefused run in the background: the send answers without waiting for a push provider, and a hook that throws or rejects is reported to onError, never to the sender.

All of this belongs to the host: the write window, which role a caller holds, the labels, and the quick replies' words. The package owns the rest:

  • the role's content rules;
  • the contact-info filter, which also reads the host's dodge-words;
  • the per-author rate limit;
  • what a message looks like on the wire.

The wire

Every 2xx answers { data }: GET / gives { thread, messages, unread }, POST /messages gives { message } (201), and POST /read gives { unread }. A refusal answers { error, message }, where error is the package's code (contact_info, closed, rate_limited, …) and message is the host's sentence. No person id ever crosses it: authors show only as their role's label.

POST /read takes { upTo }, the createdAt of the newest message the reader was actually shown. The marker never moves backwards and never past now, so a message that arrived after the screen loaded stays unread. Reading never creates a thread row; the first message does.

The contact filter

It reads the text after Unicode normalisation: full-width and other-script digits, invisible characters, look-alike Cyrillic and Greek letters, a bidi-reversed run, and spelled-out digits from the host's contactVocabulary. A phone number is 8 digits within any 16 characters, whatever separates them. Clock times, amounts after a currency symbol, and spans matching the host's neutralPatterns (address units like apto 1204, at most 4 digits each) are set aside first, so an address with an apartment number passes; a set-aside span still counts as at least one digit. An amount is set aside only when it is a price as a courier writes one, at most four integer digits (85,90, 1.250,00), so $54321, $98765,43 and $8.765.432 are not. A CEP, a full date, or a five-digit street number before a unit is refused; that is the documented price.

Across messages it reads only a contiguous chain: the same author's recent free text (contactLookback, default the last 12 messages within 10 minutes) joins the draft while each message ends where the next begins with a digit, a dot or an at-sign. A number trickled one digit per message is one number; Apto 1204 followed by Chego 19:30 is two lines. One author's sends to a thread are checked one at a time in each process, so parallel requests cannot pass the rule against each other's absence (several replicas can still interleave). Bidi controls are removed before a message is stored, so the screen shows what the filter read.

Every attempt spends a rate-limit slot, including a refused one, so probing the filter runs out of attempts. It is a deterrent, not a guarantee: a person determined to pass a number can always find an encoding no filter reads, and onRefused is how the host sees who keeps trying. Known misses, each the price of letting an ordinary line through: a word between the pieces of a number (98765 e 4321), a number of nine or ten digits split by one address unit or one price (98 765 ap 4321, 9876 $5.432 — a space inside the first group is exactly how a street number is written), both pieces behind a unit or a price (apto 98765 apto 4321), a chain broken by a message of something else, and a picture. A mobile with its area code (eleven digits) cannot hide behind one span. A host that wants arroba to count toward a social handle adds it to extraPatterns.

Retention

Messages keep author_id for moderation, and the package ships no delete or anonymise API: retention (LGPD, GDPR) is the host's. The chat_messages (tenant_id, created_at) index is there for the host's sweep. created_at comes from the app process's clock, and messages in the same millisecond are ordered by id.

Indexes for the host's own queries

chat_threads (tenant_id, last_message_at) (1.1.0, migration 20261003120000_add_chat_thread_activity_index) serves a host badge that counts the threads with activity since a moment (tenant_id = ? AND last_message_at >= ?). A host that copies the package's migrations picks it up with its next sync; it is additive, so it needs no release choreography.

The screen

const { ChatThread } = createWebChat({ fetch: credentialedFetch, copy: EN_US_CHAT_UI_COPY, formatTime });
<ChatThread endpoint={`/api/jobs/${id}/chat`} refreshSignal={liveTick} onUnreadChange={setBadge} />

The thread marks itself read up to the newest message it showed, and onUnreadChange then gets the server's remaining count (normally 0). Pass autoMarkRead={false} to mount it hidden only for its unread count.

autoMarkRead="visible" is for a thread that may be mounted out of sight (the lower half of a drawer): a load's unread messages wait until the message list is on screen, and are marked read then; a load that finds unread while the list is already on screen marks at once. On the web the list is watched with an IntersectionObserver (on screen from its first pixel); a browser without one marks at once, as true does. On native a mounted list counts as on screen. A host testing in jsdom stubs IntersectionObserver to drive it. A host drawing its own list with useChatThread({ autoMarkRead: "visible" }) reports it through controls.onVisibleChange(visible).

A read-only thread (canWrite: false) with no messages shows only the closed notice: no empty-thread invitation to write the first message, and no list.

Own messages and the others' are told apart by their surface: the reader's own bubbles are a tint of the theme's palette.primary.main (8% in light mode, 16% in dark), the others' sit on the paper surface. The label is body text on the own tint and secondary text on paper; the primary colour itself reads under 4.5:1 on its own tint, so it is not used for text there. A bubble is at most 80% of the thread wide (and 60 characters, on the web).

Accessibility: the list is a polite live region (aria-live on the web, accessibilityLiveRegion on native), so an arriving message is announced. On the web a click on the send button hands focus back to the field, and the role's maxLength sits on the <input> itself.

Layout:

  • Web: the messages sit in ui's ScrollArea (up to 60% of the viewport tall), which keeps the newest in view. ScrollArea needs ResizeObserver, so a host testing in jsdom stubs it.
  • Native: the thread is a ui Screen with keyboard avoidance and its own ScrollView. Mount it in a parent with a bounded height (a flex-1 view, or Screen scroll={false}), never inside another ScrollView. Pass keyboardOffset (dp) when a header sits above it.

createNativeChat takes the same config. Both surfaces draw the thread from @12-apps/ui primitives using only their cross-platform props, so a React Native bundle resolves each primitive to its native twin through ui's react-native export condition. What differs per platform is a slot of the internal ChatPlatform, the one place platform styling lives:

slotreceiveswebnative
Framethe list and the writing areaa Stacka keyboard-avoiding Screen
MessageListthe bubbles, label, scrollKey, onVisibleChange?ScrollArea + IntersectionObserverScrollView, visible on mount
Bubble{ mine, children }ui Box with sx (tint, min(80%, 60ch))ui Box with style (tint, 80%)
Composerthe controlled field and its send actionInput + EnterInput + the keyboard's send key
QuickRepliesthe chips' keys and labels, the headingui Chips, wrappingui Chips or pills; wrapping, or one sideways line
Noticea failed send's line, tone and lookui Alert, or icon + captionthe same, measured for onNoticeHeight

The package opens no socket. Bump refreshSignal when your live channel says the thread moved.

Host options

Both surfaces take these on the config, all optional; a config without them draws the thread exactly as before. A value outside the listed ones throws ChatConfigError when the surface is built.

configdefaultwhat it changes
quickReplyLabel(key, text)the server's sentenceA chip's label. For chips that are shorter names for the replies: a tap still sends the KEY, so the thread stores the full sentence. Return text (or a blank) for a key you do not know.
quickRepliesHeading"visible""label" drops copy.quickReplies from the screen and keeps it as the row's accessible name.
emptyDescription"always""withQuickReplies" shows copy.emptyDescription only while chips are drawn below, for a description that points at them.
sendFailureMessage(failure)server sentence, else copy.sendFailedThe line a failed send shows; return null for the default. failure.code is the package's (contact_info, …).
sendFailureNotice"alert""compact": an icon and at most two lines right on the composer, gone once the draft is edited; a contact_info refusal reads as a warning, anything else as an error. "alert" stays over the chips until the next send.
sendWhenEmpty"disabled""inert" keeps the send button drawn enabled on a blank field; a blank send does nothing.

createNativeChat also takes two that only the native row draws:

configdefaultwhat it changes
quickReplyLook"chip""pill": neutral filled pills with bold labels, at least NATIVE_CHAT_METRICS.quickReplyMinHeight (40) dp tall, instead of ui's 32-tall outlined chips.
quickReplyScrollBelowWidthnone (always wrap)Below this window width, in dp, the chips sit on one line that scrolls sideways instead of wrapping.

Chips sit NATIVE_CHAT_METRICS.quickReplyGap (8) dp apart either way.

ChatThread takes three props for a host that sizes the room around the thread (a small phone with the keyboard up):

  • foldQuickReplies leaves the chips out; the draft and the list stay.
  • onNoticeHeight(height) (native) gets the send-failure notice's height in dp on every layout, and 0 whenever none is drawn (dismissed, the thread turned read-only, unmounted). It excludes the gap over the notice: theme.spacing(0.5) on the composer in the compact look, theme.spacing(2) in the alert look.
  • onComposerFocusChange(focused) reports the field taking and losing the focus, and false when it goes away focused, so a host that hides its chrome while someone types never stays hidden.

Copy

Every sentence is required config, with no defaults. PT_BR_* and EN_US_* packs ship for the routes (CHAT_SERVER_COPY) and the screen (CHAT_UI_COPY); pass one by hand. Role labels are part of the role config, so every surface shows the same name for a role.

版本与发布记录

@12-apps/chat 共发布 6 个版本,首次发布于 2026/10/02;过去 12 个月发布了 6 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
1.3.0latest2026/10/1028 天前
1.2.12026/10/061 个月前
1.2.02026/10/061 个月前
1.1.02026/10/021 个月前
1.0.02026/10/021 个月前
0.0.0-stage2026/10/021 个月前

关于 @12-apps/chat 的常见问题

@12-apps/chat 是什么?

Conversation threads between the parties to one piece of work, each shown only by a host-chosen role label: framework-neutral HTTP routes with per-role write windows, a contact-info filter and quick replies, plus one thread screen built from @12-apps/ui p

如何安装 @12-apps/chat?

在项目目录运行 npm install @12-apps/chat,或使用 pnpm add / yarn add / bun add。

@12-apps/chat 支持 TypeScript 吗?

该包的 package.json 没有声明类型入口。需要类型提示时,可以在 npm 上查找社区维护的 @types/12-apps__chat 是否存在。

@12-apps/chat 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@12-apps/chat 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@12-apps/chat 可以商用吗?

@12-apps/chat 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@12-apps/chat 的下载量有多少?

根据 npm 官方下载统计,@12-apps/chat 在 2026/10/04 – 2026/11/02 这 30 天内被下载 32,654 次,日均约 1,088 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@12-apps/chat 还在维护吗?

最新版本 1.3.0 发布于 2026/10/10(28 天前);至今共发布 6 个版本,其中过去 12 个月发布了 6 个;首次发布于 2026/10/02。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。