@asteby/metacore-ai-chat
Copilot chat for metacore apps: floating widget (draggable FAB), chat panel, starter prompts, proposals (approve/reject) and an injectable chat client. Everything app-specific is injected; the package imports no stores, API clients or routes.
Install
pnpm add @asteby/metacore-ai-chat @asteby/metacore-app-providers lucide-react
Tailwind must scan the package (same as other metacore UI packages):
@source "../node_modules/@asteby/metacore-ai-chat/dist/**/*.{js,mjs}";
What you inject
| Concern | How |
|---|---|
| HTTP client | createHttpChatClient(api, { basePath: '/chat' }) or your own ChatClient |
| Permissions | permissions={{ canChat, canDecideProposals }} (canChat: false hides the widget and shows a denied notice in CopilotChat) |
| Branding | branding={{ name, mascotSkin, avatar }}; the mascot is Mascot from @asteby/metacore-app-providers |
| Suggestions | suggestions={[...]}, or omit to use useAgentSuggestions() (tenant agent_suggestions, neutral default) |
| Copy | labels={{ ... }} partial override (Spanish defaults) |
Default REST contract (createHttpChatClient):
POST {base}/send{message, session_id, attachments}->{success, session_id, reply|response, proposals?}GET {base}/sessions,GET {base}/history/:idPOST {base}/proposals/:id/decide{approve}
Different backend? Implement ChatClient (send required; listSessions,
loadHistory, decideProposal optional).
Proposal format
A reply may carry proposals. Both shapes are accepted and normalized (normalizeProposal)
on ChatClient.send, loadHistory and the HTTP client:
// A) display shape
{ "id": "p1", "title": "Archivar hallazgo", "summary": "Motivo…", "status": "pending" }
// B) action shape: title = "action · model", summary = compact JSON of payload
{ "id": "p2", "action": "archive", "model": "finding", "payload": { "id": 7 }, "status": "pending" }
id is required (string or number, coerced to string); status is pending (default) | approved | rejected.
An explicit title/summary wins over the derived ones. Rows without id or without anything to show are dropped.
action, model and payload are kept on the normalized proposal so decideProposal hosts can use them.
Markdown replies
AI replies render as safe Markdown + GFM (Markdown, also exported): headings, lists, tables (horizontal scroll),
blockquotes, inline/blocked code with a copy button, links and images. Raw HTML is never rendered, dangerous URL
schemes are dropped, external links open with noopener noreferrer, images are limited to http(s)/same-origin.
Unterminated code fences are closed while a reply is typed in. Styled with theme tokens only (no typography plugin).
Pass onLinkClick to CopilotProvider to route in-app links through your router (return true when handled).
Channels: bot + agents + team
Give the widget an adapter and it becomes a unified inbox. The package knows no endpoint or socket; you map your backend:
const channels: ChannelAdapter = {
listChannels: () => api.get('/internal-chat/channels').then(toChannels), // kind: 'bot' | 'agent' | 'team'
loadMessages: (id) => api.get(`/internal-chat/channels/${id}/messages`).then(toMessages),
send: (id, body) => api.post(`/internal-chat/channels/${id}/messages`, { body }).then(toMessage),
markRead: (id) => api.post(`/internal-chat/channels/${id}/read`),
subscribe: (on) => ws.subscribe('INTERNAL_CHAT_MESSAGE', (e) => on({ type: 'message', message: toMessage(e.payload) })),
}
<CopilotWidget channels={channels} fullscreenHref="/agent" />
- The bot channel (your
ChatClient) is injected first (includeBot={false}to omit). Anagentchannel with its ownclient: ChatClientgets the full AI chat (proposals, history); without one it is served by the adapter like a team thread. - Unread counts drive a FAB badge (live even while the panel is closed); the last opened channel is remembered.
subscribeevents:message,unread,presence,channels(refetch).
Full screen
The header's expand button turns the widget into a viewport-filling surface (list + thread side by side from md up;
phones always get it). Escape steps back: expanded → floating → closed. fullscreenHref / onOpenFullscreen add an
"open full view" link for apps with their own workspace route. hidden removes the widget on routes that already show it.
Demo badge
The badge shows when CopilotProvider demo / CopilotWidget demo is set, or when the backend flags a reply with
demo: true (createHttpChatClient reads demo / is_demo). Never leave it permanently on for real data.
Accessibility and states
CopilotWidgetpanel isrole="dialog"(aria-label= branding name,aria-modal="false": the page stays usable). Focus moves to the composer on open and returns to the FAB on close;Escapecloses (anyCopilotChatwithonClose).- Controls are at least 44x44px with a visible focus ring; animations honour
prefers-reduced-motion. - Offline (
navigator.onLine) shows a status banner and disables sending. Failed turns show an alert withlabels.retry;loadHistoryanddecideProposalfailures show an alert (history with retry) instead of failing silently. demoonCopilotProvider(orCopilotChat) renders thelabels.demobadge ("Datos demostrativos").- Optional session picker:
<CopilotChat sessions={list} sessionId={id} onSelectSession={setId} />(getlistfromclient.listSessions()).
Adopting it
ops (ERP)
import { CopilotProvider, CopilotWidget, createHttpChatClient } from '@asteby/metacore-ai-chat'
import { api } from '@/lib/api'
import { useAuthStore } from '@/stores/auth-store'
const client = createHttpChatClient(api) // ops exposes /chat/send, /chat/sessions, /chat/history/:id
export function Copilot() {
const canChat = useAuthStore((s) => s.can('chat.use')) // adapt to your permission check
return (
<CopilotProvider client={client} permissions={{ canChat }}>
<CopilotWidget />
</CopilotProvider>
)
}
Mount <Copilot /> in the authenticated layout, replacing AIChatWidget.
Not covered by this package yet (stay in ops until follow-up): voice/live mode,
file upload, guided tours, team-chat embeds, chart/choropleth embeds, Sparks pill.
Use the CopilotChat panel directly (/agent page) with className="h-full".
gobiernos-ops
<CopilotProvider
client={createHttpChatClient(api, { basePath: '/api/v1/chat' })}
branding={{ name: 'Gobi', mascotSkin: 'gobi-navy' }}
permissions={{ canChat: hasPermission('copilot:use') }}
suggestions={['Resumen del tablero', 'Hallazgos pendientes de revisión']}
>
<CopilotWidget defaultPosition={{ bottom: 24, right: 24 }} />
</CopilotProvider>
If the gobiernos backend uses a different contract, pass a custom ChatClient.
Use suggestions to avoid ERP-flavored prompts; omit it to inherit tenant branding.
Also exported
useChat (state machine), chatReducer, useDraggableFab / computePanelLayout,
useRevealText, and the AGENT_* surface class constants (use theme tokens
--surface-*, --elevation-*).