@presencelearning/ai
AI integration library for Presence Learning. Provides streaming chat against Chiron, background job management, session and feedback APIs, and UI components for AI-powered features using the Vercel AI SDK.
Current version: 4.0.1. Ships as a root entry point plus per-component secondary entry points for the presentational pieces (@presencelearning/ai/entry-button, /alert, /markdown, etc.) — every component is still importable from the bare @presencelearning/ai root too. See “Secondary entry points” below for the full list and how entries are wired up.
Install
Section titled “Install”npm install @presencelearning/ai @ai-sdk/angular ai markedPeer dependencies
Section titled “Peer dependencies”Declared in package.json:
@angular/core>=18.0.0 <22.0.0@angular/common>=18.0.0 <22.0.0@ai-sdk/angular^2.0.0ai^6.0.0rxjs^7.0.0marked>=15.0.0 <19.0.0
Additionally required at runtime, depending on what you use:
provideHttpClient()—AISessionService,AIAgentService,AIFeedbackService, andAIJobManagerall injectHttpClient.@angular/material—UserPromptDialogComponent(button, dialog, form-field, input, radio) andAIToolProgressComponent(icon).@angular/forms—UserPromptDialogComponent.
Configure the library once in your root providers. Because the auth callback is invoked per request (not during construction), resolve your token source in the factory and close over it:
import { ApplicationConfig, inject } from '@angular/core';import { provideHttpClient } from '@angular/common/http';import { PRESENCE_AI_CONFIG, PresenceAIConfig } from '@presencelearning/ai';
export const appConfig: ApplicationConfig = { providers: [ provideHttpClient(), { provide: PRESENCE_AI_CONFIG, useFactory: (): PresenceAIConfig => { const authStore = inject(AuthStore); return { chiron: { baseUrl: environment.apps.chiron.url }, workplace: { baseUrl: environment.apps.apiWorkplace.url }, auth: { getToken: () => authStore.getCurrentToken() }, }; }, }, ],};Do not write
getToken: () => inject(AuthStore).getCurrentToken().getToken()runs inside each service’sgetAuthHeaders()at request time, outside any injection context, soinject()throwsNG0203.
When the token does not come from DI, providePresenceAI() is a shorthand for the same token:
import { ApplicationConfig } from '@angular/core';import { provideHttpClient } from '@angular/common/http';import { providePresenceAI } from '@presencelearning/ai';
export const appConfig: ApplicationConfig = { providers: [ provideHttpClient(), providePresenceAI({ chiron: { baseUrl: environment.apps.chiron.url }, workplace: { baseUrl: environment.apps.apiWorkplace.url }, auth: { getToken: () => tokenStore.token }, }), ],};providePresenceAI() registers the config with useValue, so it cannot resolve DI dependencies itself — use the useFactory form above when it needs to.
Features
Section titled “Features”| Feature | Description |
|---|---|
| Chat | PresenceChatFactory for streaming chat with tool call tracking. create(options) starts a new session; joinSession(options) attaches to an existing one and can seed prior turns via initialMessages to resume |
| Transport | ChironChatTransport — the SSE transport that talks to Chiron and surfaces ChironToolResults |
| Sessions | AISessionService — listConversations (browse, search/pagination), getMessages (full-fidelity transcript), rename, delete |
| Agents | AIAgentService.getSuggestions(agentId) — starter prompt suggestions from the serving plane |
| Feedback | AIFeedbackService — submit, getForSession, positive, negative, retract |
| Jobs | AIJobManager — start, getStatus, cancel, poll for background AI jobs |
| Errors | AIError plus AINetworkError, AIJobError, AIParseError, AIAuthError |
| Status messages | AI_STATUS_MESSAGES, getRandomAIStatusMessage(), createAIStatusMessageRotator() |
AISessionService methods are option-object based: getMessages and delete take SessionOptions, listConversations takes ListConversationsOptions (adds search/limit/offset), and rename takes RenameConversationOptions. None accept a version — the agent version is pinned server-side to the one a session started on.
Components
Section titled “Components”| Component | Selector | Description |
|---|---|---|
PresenceChatPanelComponent | pl-ai-chat-panel | Full chat drawer — message list, composer, and ask_user prompt handling. See inputs below |
AIMarkdownComponent | pl-ai-markdown | Renders AI-generated markdown (bold, italic, code, lists, links, blockquotes, tables). marked parses it; Angular’s DomSanitizer then sanitizes the resulting HTML |
AIToolStatusComponent | pl-ai-tool-status | Displays tool invocation status during streaming. Inputs: showCompleted, labels. See tool labels below |
AIToolProgressComponent | pl-ai-tool-progress | Streaming tool progress with step descriptions and summaries. Input: idleMessage |
SparkleLoaderComponent | pl-sparkle-loader | Animated loading indicator with therapy-themed status messages |
UserPromptDialogComponent | pl-user-prompt-dialog | Material dialog for Chiron ask_user tool prompts (radio options + optional free text) |
PresenceChatPanelComponent inputs
Section titled “PresenceChatPanelComponent inputs”| Input | Type | Description |
|---|---|---|
agentId | string | Required. Chiron agent id to converse with |
systemPrompt | string | Optional system prompt override |
initialMessage | string | Message sent automatically once the panel mounts |
placeholder | string | Placeholder/aria-label for the composer. Default 'Type a message…' |
chat | PresenceChat | Host-owned chat instance. Read once in ngOnInit; the panel will not destroy() it. Omit to have the panel create and own one |
localPrompt | { question, options } | null | Host-owned prompt rendered with the same markup as an agent ask_user prompt. An active agent prompt takes precedence |
localEntries | ReadonlyArray<{ id, kind, text }> | Host-owned 'status' / 'note' entries rendered after the message list. Pass a new array reference to update (OnPush) |
suggestions | readonly string[] | Starter prompt chips shown before the conversation starts. Clicking one sends it as the first message |
useAgentSuggestions | boolean | When suggestions is empty, fetch the agent’s configured suggestions from chiron. Default false |
emptyStateTemplate | TemplateRef | Host content shown before the conversation starts. Usually supplied as an <ng-template plAiChatEmptyState> child instead |
enableFeedback | boolean | Thumbs up/down feedback under finished assistant replies, sent to chiron. Clicking the active thumb takes the vote back. Restores earlier votes on reopened conversations. Default false |
showFeedback | (message: ChatMessage) => boolean | Which replies get feedback when enableFeedback is on. Default: all of them |
stepsLabel | string | (count: number) => string | Heading of each reply’s steps log, with {count} for the number of steps. Default “Review 3 steps” |
enableAttachments | boolean | Attach button, plus drag and drop and paste onto the input. See File attachments. Default false |
attachmentRules | AIFileAttachmentRules | Narrows which files can be attached: types, maxFiles, maxBytes. Defaults to Chiron’s limits |
Outputs:
localPromptAnswered: EventEmitter<string>— emits the chosen option when alocalPromptchip is clicked.turnCompleted: EventEmitter<ChatMessage>— emits the assistant’s final message each time a response finishes streaming, e.g. to read a<conversation-title>tag and rename the session. It doesn’t emit for messages loaded from history or for turns that end in an error. A stopped response emits its partial message, unless it was stopped before any reply arrived.pl-ai-chat-windowforwards the same output.feedbackSubmitted: EventEmitter<{ message, messageIndex, submission }>— emits each feedback vote as the user submits it, whether or not the request to chiron succeeds, so a host can record it elsewhere too.messageIndexis the message’s position in the full message list, as chiron numbers it.pl-ai-chat-windowforwards it, along with theenableFeedbackandshowFeedbackinputs.feedbackRetracted: EventEmitter<{ message, messageIndex }>— emits when the user takes a vote back by clicking the active thumb again. The panel records the undo in chiron.feedbackFailed: EventEmitter<{ message, messageIndex, rating, error }>— emits when saving a vote or undo fails. The panel has already put the earlier vote back, so use this to tell the user, e.g. with a “Couldn’t save feedback” toast.ratingis what failed to save ('none'for an undo). It doesn’t fire for a change the user has since replaced.pl-ai-chat-windowforwards both outputs.
While a response streams, the composer’s Send button becomes a Stop button. After a stop, the panel shows “Response was interrupted”. After an error, it shows the error text. Both offer Try again, which re-sends the last user message as a new turn (attachments aren’t re-sent). Hosts can call the panel’s stop() and retry() methods directly too. A standalone pl-ai-chat-input takes a streaming input and emits (stop).
Empty state
Section titled “Empty state”Before the first message, the panel shows an optional host template and any suggestion chips. Both disappear once the conversation starts. pl-ai-chat-window takes the same suggestions and useAgentSuggestions inputs and template child, and shows them again for every new conversation:
<pl-ai-chat-window agentId="admin-copilot" agentName="AI Copilot" [useAgentSuggestions]="true"> <ng-template plAiChatEmptyState> <p>What would you like help with?</p> </ng-template></pl-ai-chat-window>Import AIChatEmptyStateDirective alongside the panel or window to use the template.
Conversation history actions
Section titled “Conversation history actions”In pl-ai-chat-window’s history view, each past conversation has an options menu with Rename (edit the title in place: Enter saves, Escape cancels) and Delete (confirmed in the row). The window saves them through AISessionService and updates the list right away:
- Deleting the open conversation starts a new chat, and
conversationDeletedemits the session ID. - If a rename or delete fails, the window reloads the list to undo its change and emits
historyActionFailed({ action, sessionId, error }), e.g. for a toast. - Set
[conversationActions]="false"to hide the menu.
A standalone pl-ai-conversation-history takes an actions input and only emits renameRequested / deleteRequested; the host saves them and passes back updated conversations.
Streaming smoothing
Section titled “Streaming smoothing”Two opt-in options on pl-ai-chat-panel and pl-ai-chat-window smooth out how streaming looks:
revealText: reveals a streaming reply at a steady pace instead of in network-sized bursts. The panel stays busy (Stop shown, no sending) until the reveal catches up; pressing Stop then shows the rest at once. Replies loaded from history show in full.holdToolStatus: keeps each tool status on screen for at least 500ms, so fast tool calls don’t flicker.
Both are off by default. They match what edu-clients Provider AI Chat does today.
Input actions
Section titled “Input actions”Hosts can add their own buttons inside the chat input, before the textarea (plAiChatInputPrefix) or after it, next to Send (plAiChatInputSuffix). The template gets the input’s state and controls as let-input: text, disabled, streaming, setText(), appendText() and focus(). Use this for app-owned features like dictation, which stays in the host because the Web Speech API sends audio to Google in Chrome:
<pl-ai-chat-window agentId="content-lake" agentName="Presence AI"> <ng-template plAiChatInputSuffix let-input> <app-dictate-button (transcript)="input.setText($event)" /> </ng-template></pl-ai-chat-window>- Give every host button
type="button", including the one inside a component likeapp-dictate-button. The templates render inside the input’s<form>, and a<button>with notypesubmits it, sending the draft. - Keep a dictation control mounted while it’s listening. Wrapping it in
@if (!input.text)destroys it as soon as the first transcript fills the input, cutting off the rest of the dictation. To hide an idle mic once the user has typed, gate it on your own listening state as well, e.g.@if (listening() || !input.text).
The same templates work on pl-ai-chat-panel and a standalone pl-ai-chat-input. Import AIChatInputPrefixDirective / AIChatInputSuffixDirective to use them.
Status line and steps log
Section titled “Status line and steps log”While the agent works, pl-ai-chat-panel and pl-ai-chat-window show one line of status text with animated typing dots, naming what the agent is doing now: the label of the tool that’s running, or “Thinking…” between tools. It updates as each tool runs, is announced politely to screen readers, and stays until the reply has fully rendered, including a revealText reveal. holdToolStatus keeps each tool’s label up for at least 500ms.
When the reply finishes, the status line goes away and the reply’s completed tool calls collapse into a steps log above it (“Review 3 steps”), collapsed by default. Steps use each tool’s completed label. Failed tool calls aren’t listed, and a reply that used no tools has no log. Replies reopened from history list the steps from the transcript’s tool calls. Set stepsLabel to change the heading, e.g. 'Processing {count} steps', or pass a function of the count.
This replaces the earlier sparkle loader and list of tool calls. pl-ai-tool-status is still exported for standalone use, and pl-ai-loading-message takes a message input for any status text.
Tool status labels
Section titled “Tool status labels”pl-ai-tool-status shows each tool’s name in Title Case (get_session_notes becomes “Get Session Notes”). To show custom text instead, register labels once with provideAIToolStatusLabels(). This also covers the status line and steps log in pl-ai-chat-panel and pl-ai-chat-window, which use running and completed respectively:
import { provideAIToolStatusLabels } from '@presencelearning/ai';
providers: [ provideAIToolStatusLabels({ // Same text for every status search_students: 'Searching students', // `running` while pending/running/errored, `completed` once done get_session_notes: { running: 'Reading SOAP notes...', completed: 'Read SOAP notes' }, }),];A standalone pl-ai-tool-status also takes a labels input with the same shape. Its entries win over provided labels for any tool both define. Tools with no label keep the Title Case name.
Hidden content-block tags
Section titled “Hidden content-block tags”Some agents add tags to a reply that are meant for the app, not the user, such as <conversation-title>. Register them with provideAIHiddenContentBlocks() to strip them from the rendered message, including while they stream in. The raw message content keeps the tag, so you can still read it, e.g. from turnCompleted:
import { provideAIHiddenContentBlocks } from '@presencelearning/ai';
// Root ApplicationConfig providers only, like provideAIContentBlockRenderersproviders: [...provideAIHiddenContentBlocks(['conversation-title', 'library-results'])];Custom renderers
Section titled “Custom renderers”Agents can return structured content that the chat draws with your own components:
- Content blocks: a custom tag in the reply text, e.g.
<next-steps>["Plan the next session"]</next-steps>. The tag’s body is parsed as JSON. - Tool results: a completed tool call, keyed by the chiron tool name.
A renderer is a component with a data input. Register renderers in the root providers, or provide AIRendererRegistry alongside them on one component to scope them there:
import { provideAIContentBlockRenderers, provideAIToolRenderers } from '@presencelearning/ai';
providers: [ ...provideAIContentBlockRenderers([{ key: 'next-steps', component: NextStepsComponent }]), ...provideAIToolRenderers([{ key: 'display_chart', component: AIDisplayChartComponent }]),];Interactive renderers
Section titled “Interactive renderers”A renderer that needs the chat injects AI_BLOCK_CONTEXT. Renderers that only take data don’t need it.
| Member | Description |
|---|---|
messageId, sessionId | The message the block is in, e.g. for analytics |
status | Signal: streaming, awaiting-input (latest message, finished) or complete (anything has followed it) |
answer | Signal: the text of the user message that followed, or null |
respond(text) | Answers the block’s question as the next user message. Only works while awaiting-input, so an answered block can’t answer again |
send(text), send({ text, files }) | Sends new input, e.g. from a file dropzone, regardless of status. Only sends while the chat is idle: like the input, it does nothing while a reply is streaming or being revealed |
import { Component, computed, inject, input } from '@angular/core';import { AI_BLOCK_CONTEXT, AISuggestionChipComponent } from '@presencelearning/ai';
@Component({ selector: 'app-next-steps', imports: [AISuggestionChipComponent], template: ` @for (step of data(); track step) { <pl-ai-suggestion-chip [label]="step" [selected]="context?.answer() === answerFor(step)" [disabled]="!canAnswer()" (pressed)="context?.respond(answerFor(step))" /> } `,})export class NextStepsComponent { readonly data = input<string[]>([]); // Optional, so the renderer also works outside a chat readonly context = inject(AI_BLOCK_CONTEXT, { optional: true }); readonly canAnswer = computed(() => this.context?.status() === 'awaiting-input');
answerFor(step: string): string { return `Next step: ${step}`; }}status and answer are read from the transcript, so an answered block shows the same receipt after a reload or when a conversation is reopened from history. Make answers self-describing (“Selected appointment: Jane D., Tue 3:00pm”, not “Tue 3pm”), since the agent may not have the block’s options in its context. pl-ai-chat-panel and pl-ai-chat-window provide the context. To render blocks yourself, pass one to pl-ai-content-blocks / pl-ai-tool-parts through their context input, and keep it the same object for the life of the message, or the renderers are recreated. See the Examples/RendererContext story.
AIMarkdownComponent link handling: all links render with target="_blank" rel="noopener noreferrer". Absolute http(s):/mailto: URLs and same-origin app paths are allowed; protocol-relative, backslash, javascript:, and data: URLs are neutered to #.
File attachments
Section titled “File attachments”PresenceChat.send() accepts files. CHIRON_FILE_ATTACHMENT_LIMITS mirrors Chiron’s server-side limits so the UI validates against the same source of truth:
maxFiles: 5maxTotalBytes: 20 MB for all the files on one message togetherapplication/pdf: 15 MBtext/csv,text/markdown,text/plain,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,image/jpeg,image/png,image/gif,image/webp: 5 MB each
Chiron also rejects two files with the same name in one message.
In the chat input
Section titled “In the chat input”Set enableAttachments on pl-ai-chat-window or pl-ai-chat-panel to add an attach button to the input. Files can also be dropped onto the input, which shows a drop state, or pasted into it. Queued files show as square thumbnails in one row above the text, scrolling sideways with arrows when they don’t fit. Hovering or focusing a thumbnail shows its file name and an X to remove it. Sent files show on the user’s message.
Invalid files are rejected with one message under the input, announced to screen readers, e.g. "notes.docx" isn't a supported file type. Try CSV, Markdown, text, PDF, Excel or image.
attachmentRules narrows the limits for one use, e.g. an agent that only reads PDFs. It can only tighten Chiron’s limits: types Chiron doesn’t accept are dropped, and maxFiles and maxBytes are capped at Chiron’s.
<pl-ai-chat-window agentId="report-reviewer" agentName="Report reviewer" [enableAttachments]="true" [attachmentRules]="{ types: ['application/pdf'], maxFiles: 1 }"/>types takes MIME types, or a wildcard like image/*.
A standalone pl-ai-chat-input takes attachments and attachmentRules. When files are attached, a submit emits (sendWithFiles) with { text, files } instead of (send); text may be empty. Host input actions can queue files with input.addFiles(files).
Attachments on a reopened conversation don’t show yet. Chiron records which files each turn read, but its transcript API doesn’t return them.
Dropzone
Section titled “Dropzone”pl-ai-file-dropzone is a larger “Choose or drop a file” area for workflow-style uploads, with a type and size hint and a Choose file button. It validates the files and emits the valid ones from (filesSelected). It never sends anything itself, so a custom renderer can send the files through its AIBlockContext:
<pl-ai-file-dropzone [rules]="{ types: ['application/pdf'] }" hint="PDF · Up to 15 MB" [disabled]="context?.status() !== 'awaiting-input'" (filesSelected)="context?.send({ text: 'Review this report', files: $event })"/>send() does nothing while the chat is busy, so disable the dropzone unless
the block is awaiting input. Otherwise files picked while a reply streams are
dropped.
| Input | Type | Description |
|---|---|---|
rules | AIFileAttachmentRules | Narrows which files are accepted. Defaults to Chiron’s limits |
multiple | boolean | Accept several files at once. Default false |
label | string | Default 'Choose or drop a file' |
hint | string | Defaults to the accepted types and size, e.g. “PDF · Up to 15 MB”. '' hides it |
buttonLabel | string | Default 'Choose file' |
disabled | boolean | Default false |
Validation helpers
Section titled “Validation helpers”The same checks are exported for hosts with their own upload UI: validateFiles(files, existing, limits) splits files into accepted and rejections, resolveAttachmentLimits(rules) gives Chiron’s limits narrowed by rules, and fileRejectionMessage(rejections, limits) gives one message for a batch. describeAttachmentHint(limits) and formatFileSize(bytes) format hint text.
Messages can also be sent hidden from the transcript — send(text, { hidden: true }) or send({ text, files, hidden: true }).
Utilities
Section titled “Utilities”| Export | Description |
|---|---|
createAskUserHandler | Creates a reactive handler for the ask_user tool lifecycle — provides a pendingPrompt signal and a respond() method |
getRandomAIStatusMessage | Picks one entry from AI_STATUS_MESSAGES |
createAIStatusMessageRotator | Returns a rotator that cycles status messages while a request is in flight |
isPlIcon-style guards | none — see @presencelearning/icons for icon helpers |
Key types
Section titled “Key types”| Type | Description |
|---|---|
PresenceAIConfig | Shape of PRESENCE_AI_CONFIG — chiron.baseUrl, workplace.baseUrl, auth.getToken |
PresenceChat | Chat instance with Angular signals for messages, status, streaming, tool calls, and deployedVersion |
PresenceChatOptions / JoinSessionOptions | Arguments to PresenceChatFactory.create() / .joinSession() |
PresenceChatSendInput | Argument to PresenceChat.send() (text, optional files, optional hidden) |
ChatMessage | A single transcript entry, including files |
ChatStatus | Chat lifecycle status |
ToolCallPart / ToolCallStatus | Tool call parts surfaced during streaming |
ChironChatTransportOptions | Options for ChironChatTransport |
ChironToolResult | Tool result from the Chiron SSE stream |
UserPrompt | Pending ask_user prompt data (question, options, allowCustom, toolCallId) |
AskUserHandler | Return type of createAskUserHandler |
UserPromptDialogData | Input data for UserPromptDialogComponent (adds optional title) |
UserPromptDialogResult | Dialog return value (response string + isCustom flag) |
ToolDescriptionMap | Maps tool names to user-friendly descriptions for AIToolProgressComponent |
ToolSummarizer | Function that extracts a summary string from a ChironToolResult |
SessionOptions / SessionMessage / SessionToolCall / ConversationSummary / ConversationPage / ListConversationsOptions / RenameConversationOptions | AISessionService argument and response shapes |
FeedbackParams / FeedbackRating / SessionFeedbackEntry | AIFeedbackService argument and response shapes. Feedback is append-only; retract() records a 'none' rating |
JobStatus / JobState / JobResult / JobTracker / StartJobOptions / PollOptions | AIJobManager types |
AIStatusMessage | A single entry in AI_STATUS_MESSAGES |
Secondary entry points
Section titled “Secondary entry points”Importing anything from the root @presencelearning/ai barrel bundles the whole package, including the chat/session/job/feedback machinery and its heavy peer dependencies (ai, @ai-sdk/angular, chart.js, ng2-charts). If you only need one small presentational component, import it from its own subpath instead so those peer dependencies are never pulled into your bundle:
| Subpath | Exports |
|---|---|
/entry-button | AIEntryButtonComponent |
/alert | AIAlertComponent |
/loading-message | AILoadingMessageComponent, AILoadingPhase |
/suggestion-chip | AISuggestionChipComponent |
/processing-steps | AIProcessingStepsComponent |
/chat-header | AIChatHeaderComponent |
/chat-header-light | AIChatHeaderLightComponent |
/chat-input | AIChatInputComponent |
/feedback-widget | AIFeedbackWidgetComponent, AIFeedbackSubmission |
/markdown | AIMarkdownComponent |
/sparkle-loader | SparkleLoaderComponent |
/conversation-date-label | AIConversationDateLabelComponent |
/conversation-list-item | AIConversationListItemComponent |
/charts | AIDisplayChartComponent, DisplayChartPayload (isolates chart.js/ng2-charts) |
AIConversationHistoryComponent, AIToolStatusComponent, AIToolProgressComponent, PresenceChatPanelComponent, PresenceChatWindowComponent, and UserPromptDialogComponent are root-only — they depend directly on the chat/session machinery, or (for AIConversationHistoryComponent) they hit an ng-packagr/TypeScript bug (see below).
moduleResolution prerequisite: subpath imports rely on the package’s exports map, so they only resolve under a TypeScript moduleResolution of bundler, node16, or nodenext. Root-level imports are unaffected and keep working under any resolution strategy via the retained main/module fields.
Adding a new secondary entry point
Section titled “Adding a new secondary entry point”The hand-written exports map in packages/ai/package.json doesn’t get generated — it has to be kept in sync by hand whenever a new secondary entry point is added. To add one:
- Create
packages/ai/<name>/ng-package.jsonwithlib.entryFilepointing directly at the existing component file, e.g."../src/components/ai-alert.component.ts"(no barrel needed if the component file already exports everything the entry needs). - Add an entry for
./<name>to theexportsmap inpackages/ai/package.json, prefixed withdist/(matching the pattern of the existing entries). - The component stays exported from the root barrel too (
src/index.ts) — this package doesn’t remove things from root when splitting them out.
Known compiler limitation: if the component’s entry file transitively imports a file that lives in a different subdirectory of src/ (e.g. a component in src/components/ importing a util from src/sessions/ or src/messages/), and that combination pulls in more than one already-compiled file, the build can fail with Cannot destructure property 'pos' of 'file.referencedFiles[index]' as it is undefined. — a known ng-packagr/TypeScript bug (angular/angular#57850), not anything wrong with the export itself. This is why AIConversationHistoryComponent (which imports three sibling components plus session types) isn’t split out yet. Workaround demonstrated by the sparkle-loader entry: co-locate the dependency in the same directory as its only consumer (ai-status-messages.ts was moved from src/messages/ into src/components/, next to sparkle-loader.component.ts) so nothing crosses a directory boundary. That only works when the dependency has a single real consumer; for a component tangled with multiple independently-used siblings, that’s not a clean option.
Local demo
Section titled “Local demo”The package ships a Vite demo harness that exercises chat, attachments, and the panel against a proxied Chiron:
npm run demo --workspace=@presencelearning/aiSource: packages/ai