@hashbrownai/angular
Version:
Angular bindings for Hashbrown AI
1,305 lines (1,274 loc) • 57.3 kB
JavaScript
import * as i0 from '@angular/core';
import { inject, Injector, runInInjectionContext, untracked, isSignal, computed, reflectComponentType, DestroyRef, signal, input, ApplicationRef, ViewContainerRef, Component, Directive, output, contentChild, ViewEncapsulation, ChangeDetectionStrategy, InjectionToken, effect } from '@angular/core';
import { NgComponentOutlet, NgTemplateOutlet } from '@angular/common';
import { ɵcreateRuntimeFunctionImpl as _createRuntimeFunctionImpl, ɵcreateRuntimeImpl as _createRuntimeImpl, s, prepareMagicText, fryHashbrown, ɵui as _ui } from '@hashbrownai/core';
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Creates a function with an input schema.
*
* @public
* @param cfg - The configuration for the function containing:
* - `name`: The name of the function
* - `description`: The description of the function
* - `args`: The args schema of the function
* - `result`: The result schema of the function
* - `handler`: The handler of the function
* @returns The function reference.
*/
function createRuntimeFunction(cfg) {
const injector = inject(Injector);
return _createRuntimeFunctionImpl({
...cfg,
handler: (...args) => {
return runInInjectionContext(injector, () => cfg.handler(...args));
},
});
}
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Creates a new runtime.
*
* @public
* @param options - The options for creating the runtime.
* @returns A reference to the runtime.
*/
function createRuntime(options) {
return _createRuntimeImpl(options);
}
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* @public
*/
function createTool(input) {
if ('schema' in input) {
const { name, description, schema, handler } = input;
return {
name,
description,
schema,
handler: (args, abortSignal) => handler(args, abortSignal),
};
}
else {
const { name, description, handler } = input;
return {
name,
description,
schema: s.object('Empty Object', {}),
handler: (_, abortSignal) => handler(abortSignal),
};
}
}
function bindToolToInjector(tool, injector) {
return {
...tool,
handler: (args, abortSignal) => untracked(() => runInInjectionContext(injector, () => tool.handler(args, abortSignal))),
};
}
/**
* The schema for the javascript tool.
*/
const schema = s.streaming.object('The result', {
code: s.streaming.string('The JavaScript code to run'),
});
/**
* Creates a tool that allows the LLM to run JavaScript code. It is run
* in a stateful JavaScript environment, with no access to the internet, the DOM,
* or any function that you have not explicitly defined.
*
* @public
* @param options - The options for creating the tool.
* @returns The tool.
*/
function createToolJavaScript({ runtime, }) {
return createTool({
name: 'javascript',
description: [
'Whenever you send a message containing JavaScript code to javascript, it will be',
'executed in a stateful JavaScript environment. javascript will respond with the output',
'of the execution or time out after ${runtime.timeout / 1000} seconds. Internet access',
'for this session is disabled. Do not make external web requests or API calls as they',
'will fail.',
'',
'Important: Prefer calling javascript once with a large amount of code, rather than calling it',
'multiple times with smaller amounts of code.',
'',
'The following functions are available to you:',
runtime.describe(),
].join('\n'),
schema,
handler: async ({ code }, abortSignal) => {
return runtime.run(code, abortSignal);
},
});
}
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Symbol used to mark signals that were created by toDeepSignal.
* This helps us identify and clean up stale deep signal properties
* when the structure of the data changes.
*/
const DEEP_SIGNAL = Symbol('DEEP_SIGNAL');
/**
* Converts a Signal to a DeepSignal, allowing reactive access to nested properties.
*
* This implementation is lifted from @ngrx/signals and uses a Proxy to lazily create
* computed signals for nested properties as they are accessed.
*
* @param signal - The signal to convert to a deep signal
* @returns A DeepSignal that allows accessing nested properties as signals
*
* @remarks
* The implementation uses a Proxy to intercept property access and lazily creates
* computed signals for nested properties. This ensures:
* - Minimal memory overhead (signals are only created when accessed)
* - Automatic cleanup of stale signals when data structure changes
* - Full reactivity for nested properties
*
* @example
* ```typescript
* const messages = signal([{ content: { text: 'Hello' } }]);
* const deepMessages = toDeepSignal(messages);
*
* // In a component or effect:
* effect(() => {
* // This will re-run when the text changes
* console.log(deepMessages()[0].content.text);
* });
* ```
*
* @public
*/
function toDeepSignal(signal) {
return new Proxy(signal, {
has(target, prop) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return !!this.get(target, prop, undefined);
},
get(target, prop) {
const value = untracked(target);
if (!isRecord(value) || !(prop in value)) {
if (isSignal(target[prop]) && target[prop][DEEP_SIGNAL]) {
delete target[prop];
}
return target[prop];
}
if (!isSignal(target[prop])) {
Object.defineProperty(target, prop, {
value: computed(() => target()[prop]),
configurable: true,
});
target[prop][DEEP_SIGNAL] = true;
}
return toDeepSignal(target[prop]);
},
});
}
const nonRecords = [
WeakSet,
WeakMap,
Promise,
Date,
Error,
RegExp,
ArrayBuffer,
DataView,
Function,
];
function isRecord(value) {
if (value === null || typeof value !== 'object' || isIterable(value)) {
return false;
}
let proto = Object.getPrototypeOf(value);
if (proto === Object.prototype) {
return true;
}
while (proto && proto !== Object.prototype) {
if (nonRecords.includes(proto.constructor)) {
return false;
}
proto = Object.getPrototypeOf(proto);
}
return proto === Object.prototype;
}
function isIterable(value) {
return typeof value?.[Symbol.iterator] === 'function';
}
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Exposes a component by combining it with additional configuration details.
*
* @public
* @typeParam T - The type of the Angular component.
* @param component - The Angular component to be exposed.
* @param config - The configuration object for the component, excluding the component itself.
* @returns An object representing the exposed component, including the component and its configuration.
*/
function exposeComponent(component, config) {
const reflected = reflectComponentType(component);
if (!reflected?.selector) {
throw new Error(`Could not reflect component: ${component}`);
}
const { input, name, ...rest } = config;
return {
component,
...rest,
props: input,
name: name ?? reflected?.selector,
};
}
function readSignalLike(signalLike) {
if (isSignal(signalLike)) {
return signalLike();
}
if (typeof signalLike === 'function') {
return signalLike();
}
return signalLike;
}
function toNgSignal(source, debugName) {
const destroyRef = inject(DestroyRef);
const options = debugName ? { debugName } : undefined;
const _signal = signal(source(), options);
const teardown = source.subscribe((value) => {
_signal.set(value);
});
destroyRef.onDestroy(() => {
teardown();
});
return _signal.asReadonly();
}
const TAG_NAME_REGISTRY = Symbol('ɵtagNameRegistry');
const getTagNameRegistry = (message) => {
if (TAG_NAME_REGISTRY in message) {
return message[TAG_NAME_REGISTRY];
}
return undefined;
};
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @angular-eslint/component-selector */
/**
* Renders messages generated by the assistant from uiChatResource.
*
* @public
* @example
*
* ```html
* <hb-render-message [message]="message" />
* ```
*/
class RenderMessageComponent {
message = input.required(...(ngDevMode ? [{ debugName: "message" }] : []));
/**
* @internal
*/
appRef = inject(ApplicationRef);
/**
* @internal
*/
content = computed(() => this.message().content?.ui ?? [], ...(ngDevMode ? [{ debugName: "content" }] : []));
/**
* @internal
*/
tagNameRegistry = computed(() => getTagNameRegistry(this.message()), ...(ngDevMode ? [{ debugName: "tagNameRegistry" }] : []));
/**
* @internal
*/
viewContainerRef = inject(ViewContainerRef);
/**
* @internal
*/
rootNodesWeakMap = new WeakMap();
/**
* @internal
*/
embeddedViewsWeakMap = new WeakMap();
/**
* @internal
*/
getTagComponent(tagName) {
return this.tagNameRegistry()?.[tagName]?.component ?? null;
}
/**
* @internal
*/
getEmbeddedView(tpl) {
if (this.embeddedViewsWeakMap.has(tpl)) {
return this.embeddedViewsWeakMap.get(tpl);
}
const view = this.viewContainerRef.createEmbeddedView(tpl);
this.embeddedViewsWeakMap.set(tpl, view);
return view;
}
/**
* @internal
*/
getRootNodes(tpl) {
if (this.rootNodesWeakMap.has(tpl)) {
return this.rootNodesWeakMap.get(tpl);
}
const view = this.getEmbeddedView(tpl);
const nodes = [view.rootNodes];
this.rootNodesWeakMap.set(tpl, nodes);
return nodes;
}
/**
* @internal
*/
isTextNode(node) {
return typeof node.$children === 'string';
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: RenderMessageComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.4", type: RenderMessageComponent, isStandalone: true, selector: "hb-render-message", inputs: { message: { classPropertyName: "message", publicName: "message", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
<ng-template #nodeTemplateRef let-node="node">
<ng-template #childrenTemplateRef>
@if (isTextNode(node)) {
{{ node.$children }}
} @else {
@for (child of node.$children; track $index) {
<ng-container
*ngTemplateOutlet="nodeTemplateRef; context: { node: child }"
/>
}
}
</ng-template>
@if (node) {
<ng-container
*ngComponentOutlet="
getTagComponent(node.$tag);
inputs: node.$props;
content: getRootNodes(childrenTemplateRef)
"
></ng-container>
}
</ng-template>
@if (content()) {
@for (node of content(); track $index) {
<ng-template
[ngTemplateOutlet]="nodeTemplateRef"
[ngTemplateOutletContext]="node"
>
</ng-template>
<ng-container
*ngTemplateOutlet="nodeTemplateRef; context: { node: node }"
/>
}
}
`, isInline: true, dependencies: [{ kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule", "ngComponentOutletNgModuleFactory"], exportAs: ["ngComponentOutlet"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: RenderMessageComponent, decorators: [{
type: Component,
args: [{
selector: 'hb-render-message',
imports: [NgComponentOutlet, NgTemplateOutlet],
template: `
<ng-template #nodeTemplateRef let-node="node">
<ng-template #childrenTemplateRef>
@if (isTextNode(node)) {
{{ node.$children }}
} @else {
@for (child of node.$children; track $index) {
<ng-container
*ngTemplateOutlet="nodeTemplateRef; context: { node: child }"
/>
}
}
</ng-template>
@if (node) {
<ng-container
*ngComponentOutlet="
getTagComponent(node.$tag);
inputs: node.$props;
content: getRootNodes(childrenTemplateRef)
"
></ng-container>
}
</ng-template>
@if (content()) {
@for (node of content(); track $index) {
<ng-template
[ngTemplateOutlet]="nodeTemplateRef"
[ngTemplateOutletContext]="node"
>
</ng-template>
<ng-container
*ngTemplateOutlet="nodeTemplateRef; context: { node: node }"
/>
}
}
`,
}]
}] });
/* eslint-disable @angular-eslint/component-class-suffix */
/* eslint-disable no-useless-escape */
/* eslint-disable @angular-eslint/component-selector */
/* eslint-disable @angular-eslint/directive-selector */
class MagicTextRenderLink {
template;
constructor(template) {
this.template = template;
}
static ngTemplateContextGuard(dir, context) {
return true;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: MagicTextRenderLink, deps: [{ token: i0.TemplateRef }], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.4", type: MagicTextRenderLink, isStandalone: true, selector: "ng-template[hbMagicTextRenderLink]", ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: MagicTextRenderLink, decorators: [{
type: Directive,
args: [{ selector: 'ng-template[hbMagicTextRenderLink]' }]
}], ctorParameters: () => [{ type: i0.TemplateRef }] });
class MagicTextRenderText {
template;
constructor(template) {
this.template = template;
}
static ngTemplateContextGuard(dir, context) {
return true;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: MagicTextRenderText, deps: [{ token: i0.TemplateRef }], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.4", type: MagicTextRenderText, isStandalone: true, selector: "ng-template[hbMagicTextRenderText]", ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: MagicTextRenderText, decorators: [{
type: Directive,
args: [{ selector: 'ng-template[hbMagicTextRenderText]' }]
}], ctorParameters: () => [{ type: i0.TemplateRef }] });
/** @public */
class MagicTextRenderCitation {
template;
constructor(template) {
this.template = template;
}
static ngTemplateContextGuard(dir, context) {
return true;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: MagicTextRenderCitation, deps: [{ token: i0.TemplateRef }], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.4", type: MagicTextRenderCitation, isStandalone: true, selector: "ng-template[hbMagicTextRenderCitation]", ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: MagicTextRenderCitation, decorators: [{
type: Directive,
args: [{ selector: 'ng-template[hbMagicTextRenderCitation]' }]
}], ctorParameters: () => [{ type: i0.TemplateRef }] });
/** @public */
class MagicTextRenderWhitespace {
template;
constructor(template) {
this.template = template;
}
static ngTemplateContextGuard(dir, context) {
return true;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: MagicTextRenderWhitespace, deps: [{ token: i0.TemplateRef }], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.4", type: MagicTextRenderWhitespace, isStandalone: true, selector: "ng-template[hbMagicTextRenderWhitespace]", ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: MagicTextRenderWhitespace, decorators: [{
type: Directive,
args: [{ selector: 'ng-template[hbMagicTextRenderWhitespace]' }]
}], ctorParameters: () => [{ type: i0.TemplateRef }] });
/** @public */
class MagicText {
text = input.required(...(ngDevMode ? [{ debugName: "text" }] : []));
defaultLinkTarget = input('_blank', ...(ngDevMode ? [{ debugName: "defaultLinkTarget" }] : []));
defaultLinkRel = input('noopener noreferrer', ...(ngDevMode ? [{ debugName: "defaultLinkRel" }] : []));
citations = input(...(ngDevMode ? [undefined, { debugName: "citations" }] : []));
linkClick = output();
citationClick = output();
linkTemplate = contentChild(MagicTextRenderLink, ...(ngDevMode ? [{ debugName: "linkTemplate" }] : []));
textTemplate = contentChild(MagicTextRenderText, ...(ngDevMode ? [{ debugName: "textTemplate" }] : []));
citationTemplate = contentChild(MagicTextRenderCitation, ...(ngDevMode ? [{ debugName: "citationTemplate" }] : []));
whitespaceTemplate = contentChild(MagicTextRenderWhitespace, ...(ngDevMode ? [{ debugName: "whitespaceTemplate" }] : []));
fragments = computed(() => {
const fragments = prepareMagicText(this.text()).fragments;
return fragments.map((fragment, index, all) => {
if (fragment.type !== 'text') {
return fragment;
}
const next = all[index + 1];
const prev = all[index - 1];
// Keep natural spacing, but strip edge spaces that would create gaps
// around tight footnote citations.
let text = fragment.text.replace(/[\u00a0\u202f]/g, ' ');
if (next?.type === 'citation') {
text = text.replace(/\s+$/, '');
}
if (prev?.type === 'citation') {
text = text.replace(/^\s+/, '');
}
return { ...fragment, text };
});
}, ...(ngDevMode ? [{ debugName: "fragments" }] : []));
citationLookup = computed(() => {
const map = new Map();
for (const citation of this.citations() ?? []) {
if (!citation) {
continue;
}
const key = citation.id?.trim?.() ?? '';
const url = citation.url?.trim?.() ?? '';
if (key && url) {
map.set(key, url);
}
}
return map;
}, ...(ngDevMode ? [{ debugName: "citationLookup" }] : []));
whitespaceContext(fragment, position, index) {
const fragments = this.fragments();
const previous = fragments[index - 1];
let render = position === 'before'
? fragment.renderWhitespace.before
: fragment.renderWhitespace.after;
if (position === 'before' && index === 0) {
render = false;
}
const next = fragments[index + 1];
const hasLeadingWhitespace = (frag) => frag?.type === 'text' && /^\s/.test(frag.text);
const hasTrailingWhitespace = (frag) => frag?.type === 'text' && /\s$/.test(frag.text);
if (position === 'before') {
// If the surrounding fragments already carry whitespace in their text,
// avoid rendering an extra spacer node.
const hasWhitespaceInText = hasTrailingWhitespace(previous) || hasLeadingWhitespace(fragment);
render = render && !hasWhitespaceInText;
}
if (position === 'after') {
const hasWhitespaceInText = hasTrailingWhitespace(fragment) || hasLeadingWhitespace(next);
render = render && !hasWhitespaceInText;
}
if (position === 'before' && fragment.type === 'citation') {
render = false;
}
// Footnote-style citations should sit tight against the preceding text.
if (position === 'after' && next?.type === 'citation') {
render = false;
}
const startsTight = (frag) => frag?.type === 'text' && /^[,.;:!?|\)\]]/.test(frag.text.trim());
const endsWithNoGap = (frag) => frag?.type === 'text' && /([\(\|])$/.test(frag.text.trim());
if (position === 'before' && startsTight(fragment)) {
render = false;
}
if (position === 'after' && startsTight(next)) {
render = false;
}
if (position === 'after' && endsWithNoGap(fragment)) {
render = false;
}
return {
$implicit: { position, render, fragment },
position,
render,
fragment,
index,
};
}
templateContext(node) {
return { $implicit: node, node };
}
toTextNode(fragment) {
const text = this.normalizeFragmentText(fragment);
return {
text,
tags: fragment.tags,
state: fragment.state,
isStatic: fragment.isStatic,
renderWhitespace: fragment.renderWhitespace,
isCode: fragment.isCode,
fragment,
};
}
normalizeFragmentText(fragment) {
// Normalize only non-breaking spaces; keep the original whitespace intact
// so we don't double-insert gaps alongside rendered spacer nodes.
return fragment.text.replace(/[\u00a0\u202f]/g, ' ');
}
toLinkNode(fragment) {
const link = fragment.marks.link;
if (!link) {
throw new Error('Link fragment is missing link metadata.');
}
return {
...this.toTextNode(fragment),
href: link.href,
title: link.title,
ariaLabel: link.ariaLabel,
rel: link.rel,
target: link.target,
link,
};
}
toCitationNode(fragment) {
const url = this.citationLookup().get(String(fragment.citation.id));
const text = fragment.text.trim();
return {
citation: { ...fragment.citation, url },
text,
state: fragment.state,
isStatic: fragment.isStatic,
renderWhitespace: fragment.renderWhitespace,
fragment,
};
}
handleLinkClick(event, fragment) {
const href = fragment.marks.link?.href ?? '';
this.linkClick.emit({ mouseEvent: event, href, fragment });
}
handleCitationClick(event, context) {
this.citationClick.emit({
mouseEvent: event,
citation: { id: context.citation.id, url: context.citation.url },
fragment: context.fragment,
});
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: MagicText, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.4", type: MagicText, isStandalone: true, selector: "hb-magic-text", inputs: { text: { classPropertyName: "text", publicName: "text", isSignal: true, isRequired: true, transformFunction: null }, defaultLinkTarget: { classPropertyName: "defaultLinkTarget", publicName: "defaultLinkTarget", isSignal: true, isRequired: false, transformFunction: null }, defaultLinkRel: { classPropertyName: "defaultLinkRel", publicName: "defaultLinkRel", isSignal: true, isRequired: false, transformFunction: null }, citations: { classPropertyName: "citations", publicName: "citations", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { linkClick: "linkClick", citationClick: "citationClick" }, queries: [{ propertyName: "linkTemplate", first: true, predicate: MagicTextRenderLink, descendants: true, isSignal: true }, { propertyName: "textTemplate", first: true, predicate: MagicTextRenderText, descendants: true, isSignal: true }, { propertyName: "citationTemplate", first: true, predicate: MagicTextRenderCitation, descendants: true, isSignal: true }, { propertyName: "whitespaceTemplate", first: true, predicate: MagicTextRenderWhitespace, descendants: true, isSignal: true }], ngImport: i0, template: `
<ng-template
#defaultWhitespace
let-fragment="fragment"
let-position="position"
let-render="render"
>
@if (render) {
<span
class="hb-space"
[class.hb-space--before]="position === 'before'"
[class.hb-space--after]="position === 'after'"
aria-hidden="true"
>{{ ' ' }}</span
>
}
</ng-template>
<ng-template #defaultText let-node="node">
<span
class="hb-text"
[class.hb-text--code]="node.isCode"
[class.hb-text--strong]="node.tags.includes('strong')"
[class.hb-text--em]="node.tags.includes('em')"
[attr.data-fragment-state]="node.state"
animate.enter="hb-text--enter"
>{{ node.text }}</span
>
</ng-template>
<ng-template #defaultLink let-node="node">
<a
class="hb-link"
[attr.href]="node.href"
[attr.title]="node.title || null"
[attr.aria-label]="node.ariaLabel || null"
[attr.rel]="node.rel || defaultLinkRel()"
[attr.target]="node.target || defaultLinkTarget()"
data-fragment-kind="text"
[attr.data-fragment-state]="node.state"
animate.enter="hb-text--enter"
(click)="handleLinkClick($event, node.fragment)"
>
<ng-container
*ngTemplateOutlet="
textTemplate()?.template ?? defaultText;
context: templateContext(toTextNode(node.fragment))
"
/>
</a>
</ng-template>
<ng-template #defaultCitation let-node="node">
<span animate.enter="hb-text--enter">
@if (node.citation.url) {
<a
class="hb-citation"
role="doc-noteref"
[attr.href]="node.citation.url"
[attr.rel]="defaultLinkRel()"
[attr.target]="defaultLinkTarget()"
data-fragment-kind="citation"
[attr.data-fragment-state]="node.state"
(click)="handleCitationClick($event, node)"
>{{ node.text }}</a
>
} @else {
<button
type="button"
class="hb-citation hb-citation-placeholder"
role="doc-noteref"
data-fragment-kind="citation"
[attr.data-fragment-state]="node.state"
(click)="handleCitationClick($event, node)"
>
{{ node.text }}
</button>
}
</span>
</ng-template>
@for (fragment of fragments(); track fragment.key; let i = $index) {
<ng-container
*ngTemplateOutlet="
whitespaceTemplate()?.template ?? defaultWhitespace;
context: whitespaceContext(fragment, 'before', i)
"
/>
<span
class="hb-fragment"
[attr.data-fragment-kind]="fragment.type"
[attr.data-fragment-state]="fragment.state"
animate.enter="hb-text--enter"
>
@if (fragment.type === 'text') {
@if (fragment.marks.link) {
<ng-container
*ngTemplateOutlet="
linkTemplate()?.template ?? defaultLink;
context: templateContext(toLinkNode(fragment))
"
/>
} @else {
<ng-container
*ngTemplateOutlet="
textTemplate()?.template ?? defaultText;
context: templateContext(toTextNode(fragment))
"
/>
}
} @else {
<ng-container
*ngTemplateOutlet="
citationTemplate()?.template ?? defaultCitation;
context: templateContext(toCitationNode(fragment))
"
/>
}
</span>
<ng-container
*ngTemplateOutlet="
whitespaceTemplate()?.template ?? defaultWhitespace;
context: whitespaceContext(fragment, 'after', i)
"
/>
}
`, isInline: true, styles: [".hb-text--code{font-family:monospace}.hb-text--strong{font-weight:700}.hb-text--em{font-style:italic}.hb-text--enter{animation:enter .35s ease-in-out}@keyframes enter{0%{opacity:0}to{opacity:1}}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: MagicText, decorators: [{
type: Component,
args: [{ selector: 'hb-magic-text', imports: [NgTemplateOutlet], changeDetection: ChangeDetectionStrategy.OnPush, template: `
<ng-template
#defaultWhitespace
let-fragment="fragment"
let-position="position"
let-render="render"
>
@if (render) {
<span
class="hb-space"
[class.hb-space--before]="position === 'before'"
[class.hb-space--after]="position === 'after'"
aria-hidden="true"
>{{ ' ' }}</span
>
}
</ng-template>
<ng-template #defaultText let-node="node">
<span
class="hb-text"
[class.hb-text--code]="node.isCode"
[class.hb-text--strong]="node.tags.includes('strong')"
[class.hb-text--em]="node.tags.includes('em')"
[attr.data-fragment-state]="node.state"
animate.enter="hb-text--enter"
>{{ node.text }}</span
>
</ng-template>
<ng-template #defaultLink let-node="node">
<a
class="hb-link"
[attr.href]="node.href"
[attr.title]="node.title || null"
[attr.aria-label]="node.ariaLabel || null"
[attr.rel]="node.rel || defaultLinkRel()"
[attr.target]="node.target || defaultLinkTarget()"
data-fragment-kind="text"
[attr.data-fragment-state]="node.state"
animate.enter="hb-text--enter"
(click)="handleLinkClick($event, node.fragment)"
>
<ng-container
*ngTemplateOutlet="
textTemplate()?.template ?? defaultText;
context: templateContext(toTextNode(node.fragment))
"
/>
</a>
</ng-template>
<ng-template #defaultCitation let-node="node">
<span animate.enter="hb-text--enter">
@if (node.citation.url) {
<a
class="hb-citation"
role="doc-noteref"
[attr.href]="node.citation.url"
[attr.rel]="defaultLinkRel()"
[attr.target]="defaultLinkTarget()"
data-fragment-kind="citation"
[attr.data-fragment-state]="node.state"
(click)="handleCitationClick($event, node)"
>{{ node.text }}</a
>
} @else {
<button
type="button"
class="hb-citation hb-citation-placeholder"
role="doc-noteref"
data-fragment-kind="citation"
[attr.data-fragment-state]="node.state"
(click)="handleCitationClick($event, node)"
>
{{ node.text }}
</button>
}
</span>
</ng-template>
@for (fragment of fragments(); track fragment.key; let i = $index) {
<ng-container
*ngTemplateOutlet="
whitespaceTemplate()?.template ?? defaultWhitespace;
context: whitespaceContext(fragment, 'before', i)
"
/>
<span
class="hb-fragment"
[attr.data-fragment-kind]="fragment.type"
[attr.data-fragment-state]="fragment.state"
animate.enter="hb-text--enter"
>
@if (fragment.type === 'text') {
@if (fragment.marks.link) {
<ng-container
*ngTemplateOutlet="
linkTemplate()?.template ?? defaultLink;
context: templateContext(toLinkNode(fragment))
"
/>
} @else {
<ng-container
*ngTemplateOutlet="
textTemplate()?.template ?? defaultText;
context: templateContext(toTextNode(fragment))
"
/>
}
} @else {
<ng-container
*ngTemplateOutlet="
citationTemplate()?.template ?? defaultCitation;
context: templateContext(toCitationNode(fragment))
"
/>
}
</span>
<ng-container
*ngTemplateOutlet="
whitespaceTemplate()?.template ?? defaultWhitespace;
context: whitespaceContext(fragment, 'after', i)
"
/>
}
`, encapsulation: ViewEncapsulation.None, styles: [".hb-text--code{font-family:monospace}.hb-text--strong{font-weight:700}.hb-text--em{font-style:italic}.hb-text--enter{animation:enter .35s ease-in-out}@keyframes enter{0%{opacity:0}to{opacity:1}}\n"] }]
}] });
/**
* @internal
*/
const ɵHASHBROWN_CONFIG_INJECTION_TOKEN = new InjectionToken('HashbrownConfig');
/**
* Provides the Hashbrown configuration.
*
* @public
* @param options - The Hashbrown configuration.
* @returns The Hashbrown configuration.
*/
function provideHashbrown(options) {
return {
provide: ɵHASHBROWN_CONFIG_INJECTION_TOKEN,
useValue: options,
};
}
/**
* @internal
*/
function ɵinjectHashbrownConfig() {
return inject(ɵHASHBROWN_CONFIG_INJECTION_TOKEN);
}
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* This Angular resource provides a reactive chat interface for send and receiving messages from a model.
* The resource-based API includes signals for the current messages, status, and control methods for sending and stopping messages.
*
* @public
* @remarks
* The `chatResource` function provides the most basic functionality for un-structured chats. Unstructured chats include things like general chats and natural language controls.
*
* @param options - Configuration for the chat resource.
* @returns An object with reactive signals and methods for interacting with the chat.
* @typeParam Tools - The set of tool definitions available to the chat.
* @example
* This example demonstrates how to use the `chatResource` function to create a simple chat component.
*
* ```ts
* const chat = chatResource({
* system: 'hashbrowns should be covered and smothered',
* model: 'gpt-5',
* });
*
* chat.sendMessage(\{ role: 'user', content: 'Write a short story about breakfast.' \});
* ```
*/
function chatResource(options) {
const config = ɵinjectHashbrownConfig();
const injector = inject(Injector);
const destroyRef = inject(DestroyRef);
const hashbrown = fryHashbrown({
apiUrl: options.apiUrl ?? config.baseUrl,
middleware: config.middleware?.map((m) => {
return (requestInit) => runInInjectionContext(injector, () => m(requestInit));
}),
system: readSignalLike(options.system),
model: readSignalLike(options.model),
tools: options.tools?.map((tool) => bindToolToInjector(tool, injector)),
emulateStructuredOutput: config.emulateStructuredOutput,
debugName: options.debugName,
transport: options.transport ?? config.transport,
ui: false,
threadId: readSignalLike(options.threadId),
});
const teardown = hashbrown.sizzle();
destroyRef.onDestroy(() => teardown());
const value = toNgSignal(hashbrown.messages, options.debugName && `${options.debugName}.value`);
const isReceiving = toNgSignal(hashbrown.isReceiving, options.debugName && `${options.debugName}.isReceiving`);
const isSending = toNgSignal(hashbrown.isSending, options.debugName && `${options.debugName}.isSending`);
const isGenerating = toNgSignal(hashbrown.isGenerating, options.debugName && `${options.debugName}.isGenerating`);
const isRunningToolCalls = toNgSignal(hashbrown.isRunningToolCalls, options.debugName && `${options.debugName}.isRunningToolCalls`);
const isLoading = toNgSignal(hashbrown.isLoading, options.debugName && `${options.debugName}.isLoading`);
const error = toNgSignal(hashbrown.error, options.debugName && `${options.debugName}.error`);
const sendingError = toNgSignal(hashbrown.sendingError, options.debugName && `${options.debugName}.sendingError`);
const generatingError = toNgSignal(hashbrown.generatingError, options.debugName && `${options.debugName}.generatingError`);
const lastAssistantMessage = toNgSignal(hashbrown.lastAssistantMessage, options.debugName && `${options.debugName}.lastAssistantMessage`);
const isLoadingThread = toNgSignal(hashbrown.isLoadingThread, options.debugName && `${options.debugName}.isLoadingThread`);
const isSavingThread = toNgSignal(hashbrown.isSavingThread, options.debugName && `${options.debugName}.isSavingThread`);
const threadLoadError = toNgSignal(hashbrown.threadLoadError, options.debugName && `${options.debugName}.threadLoadError`);
const threadSaveError = toNgSignal(hashbrown.threadSaveError, options.debugName && `${options.debugName}.threadSaveError`);
const status = computed(() => {
if (isLoading()) {
return 'loading';
}
if (error()) {
return 'error';
}
const hasAssistantMessage = value().some((message) => message.role === 'assistant');
if (hasAssistantMessage) {
return 'resolved';
}
return 'idle';
}, { debugName: options.debugName && `${options.debugName}.status` });
function reload() {
const lastMessage = value()[value().length - 1];
if (lastMessage.role === 'assistant') {
hashbrown.setMessages(value().slice(0, -1));
return true;
}
return false;
}
function hasValue() {
return value().some((message) => message.role === 'assistant');
}
function sendMessage(message) {
hashbrown.sendMessage(message);
}
function stop(clearStreamingMessage = false) {
hashbrown.stop(clearStreamingMessage);
}
return {
hasValue: hasValue,
status,
isReceiving,
isSending,
isGenerating,
isRunningToolCalls,
isLoading,
isLoadingThread,
isSavingThread,
sendingError,
generatingError,
threadLoadError,
threadSaveError,
reload,
sendMessage,
stop,
value,
error,
lastAssistantMessage,
};
}
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Creates a completion resource.
*
* @public
* @param options - The options for the completion resource.
* @typeParam Input - The type of the input to the completion.
* @returns The completion resource.
*/
function completionResource(options) {
const { model, input, system } = options;
const injector = inject(Injector);
const destroyRef = inject(DestroyRef);
const config = ɵinjectHashbrownConfig();
const hashbrown = fryHashbrown({
debugName: options.debugName,
apiUrl: options.apiUrl ?? config.baseUrl,
middleware: config.middleware?.map((m) => {
return (requestInit) => runInInjectionContext(injector, () => m(requestInit));
}),
model: readSignalLike(model),
system: readSignalLike(system),
messages: [],
tools: [],
retries: 3,
transport: options.transport ?? config.transport,
threadId: options.threadId ? readSignalLike(options.threadId) : undefined,
});
const teardown = hashbrown.sizzle();
destroyRef.onDestroy(() => teardown());
const messages = toNgSignal(hashbrown.messages);
const isReceiving = toNgSignal(hashbrown.isReceiving);
const isSending = toNgSignal(hashbrown.isSending);
const isGenerating = toNgSignal(hashbrown.isGenerating);
const isRunningToolCalls = toNgSignal(hashbrown.isRunningToolCalls);
const isLoading = toNgSignal(hashbrown.isLoading);
const isLoadingThread = toNgSignal(hashbrown.isLoadingThread);
const isSavingThread = toNgSignal(hashbrown.isSavingThread);
const sendingError = toNgSignal(hashbrown.sendingError);
const generatingError = toNgSignal(hashbrown.generatingError);
const threadLoadError = toNgSignal(hashbrown.threadLoadError);
const threadSaveError = toNgSignal(hashbrown.threadSaveError);
const internalMessages = computed(() => {
const _input = input();
if (!_input) {
return [];
}
return [
{
role: 'user',
content: _input,
},
];
}, ...(ngDevMode ? [{ debugName: "internalMessages" }] : []));
const error = toNgSignal(hashbrown.error, options.debugName && `${options.debugName}.error`);
const exhaustedRetries = toNgSignal(hashbrown.exhaustedRetries, options.debugName && `${options.debugName}.exhaustedRetries`);
effect(() => {
const _messages = internalMessages();
hashbrown.setMessages(_messages);
});
const value = computed(() => {
const lastMessage = messages()[messages().length - 1];
if (lastMessage &&
lastMessage.role === 'assistant' &&
lastMessage.content &&
typeof lastMessage.content === 'string') {
return lastMessage.content;
}
return null;
}, { debugName: options.debugName && `${options.debugName}.value` });
const status = computed(() => {
if (isLoading()) {
return 'loading';
}
if (exhaustedRetries()) {
return 'error';
}
return 'idle';
}, ...(ngDevMode ? [{ debugName: "status" }] : []));
const reload = () => {
return true;
};
function hasValue() {
return Boolean(value());
}
function stop(clearStreamingMessage = false) {
hashbrown.stop(clearStreamingMessage);
}
return {
value,
status,
error,
isLoading,
isReceiving,
isSending,
isGenerating,
isRunningToolCalls,
isLoadingThread,
isSavingThread,
sendingError,
generatingError,
threadLoadError,
threadSaveError,
reload,
stop,
hasValue: hasValue,
};
}
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Creates a structured chat resource.
*
* @public
* @param options - The options for the structured chat resource.
* @returns The structured chat resource.
*/
function structuredChatResource(options) {
const config = ɵinjectHashbrownConfig();
const injector = inject(Injector);
const destroyRef = inject(DestroyRef);
const hashbrown = fryHashbrown({
apiUrl: options.apiUrl ?? config.baseUrl,
middleware: config.middleware?.map((m) => {
return (requestInit) => runInInjectionContext(injector, () => m(requestInit));
}),
system: readSignalLike(options.system),
messages: [...(options.messages ?? [])],
model: options.model,
tools: options.tools?.map((tool) => bindToolToInjector(tool, injector)),
responseSchema: options.schema,
debugName: options.debugName,
emulateStructuredOutput: config.emulateStructuredOutput,
debounce: options.debounce,
retries: options.retries,
transport: options.transport ?? config.transport,
ui: options.ui ?? false,
threadId: options.threadId ? readSignalLike(options.threadId) : undefined,
});
const optionsEffect = effect(() => {
const model = options.model;
const system = readSignalLike(options.system);
const threadId = options.threadId
? readSignalLike(options.threadId)
: undefined;
hashbrown.updateOptions({
model,
system,
ui: options.ui ?? false,
threadId,
});
}, ...(ngDevMode ? [{ debugName: "optionsEffect" }] : []));
const teardown = hashbrown.sizzle();
destroyRef.onDestroy(() => {
teardown();
optionsEffect.destroy();
});
const valueSignal = toNgSignal(hashbrown.messages, options.debugName && `${options.debugName}.value`);
const value = toDeepSignal(valueSignal);
const isReceiving = toNgSignal(hashbrown.isReceiving, options.debugName && `${options.debugName}.isReceiving`);
const isSending = toNgSignal(hashbrown.isSending, options.debugName && `${options.debugName}.isSending`);
const isGenerating = toNgSignal(hashbrown.isGenerating, options.debugName && `${options.debugName}.isGenerating`);
const isRunningToolCalls = toNgSignal(hashbrown.isRunningToolCalls, options.debugName && `${options.debugName}.isRunningToolCalls`);
const isLoading = toNgSignal(hashbrown.isLoading, options.debugName && `${options.debugName}.isLoading`);
const error = toNgSignal(hashbrown.error, options.debugName && `${options.debugName}.error`);
const sendingError = toNgSignal(hashbrown.sendingError, options.debugName && `${options.debugName}.sendingError`);
const generatingError = toNgSignal(hashbrown.generatingError, options.debugName && `${options.debugName}.generatingError`);
const lastAssistantMessage = toNgSignal(hashbrown.lastAssistantMessage, options.debugName && `${options.debugName}.lastAssistantMessage`);
const exhaustedRetries = toNgSignal(hashbrown.exhaustedRetries);
const isLoadingThread = toNgSignal(hashbrown.isLoadingThread, options.debugName && `${options.debugName}.isLoadingThread`);
const isSavingThread = toNgSignal(hashbrown.isSavingThread, options.debugName && `${options.debugName}.isSavingThread`);
const threadLoadError = toNgSignal(hashbrown.threadLoadError, options.debugName && `${options.debugName}.threadLoadError`);
const threadSaveError = toNgSignal(hashbrown.threadSaveError, options.debugName && `${options.debugName}.threadSaveError`);
const status = computed(() => {
if (isLoading()) {
return 'loading';
}
if (exhaustedRetries()) {
return 'error';
}
const hasAssistantMessage = value().some((message) => message.role === 'assistant');
if (hasAssistantMessage) {
return 'resolved';
}
return 'idle';
}, { debugName: options.debugName && `${options.debugName}.status` });
function reload() {
const lastMessage = value()[value().length - 1];
if (lastMessage.role === 'assistant') {
hashbrown.setMessages(value().slice(0, -1));
return true;
}
return false;
}
function hasValue() {
return value().some((message) => message.role === 'assistant');
}
function sendMessage(message) {
hashbrown.sendMessage(message);
}
function resendMessages() {
hashbrown.resendMessages();
}
function setMessages(messages) {
hashbrown.setMessages(messages);
}
function stop(clearStreamingMessage = false) {
hashbrown.stop(clearStreamingMessage);
}
return {
hasValue: hasValue,
status,
isLoading,
isGenerating,
isSending,
isReceiving,
isRunningToolCalls,
reload,
sendMessage,
resendMessages,
stop,
value,
error,
sendingError,
generatingError,
setMessages,
lastAssistantMessage,
isLoadingThread,
isSavingThread,
threadLoadError,
threadSaveError,
};
}
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Creates a structured completion resource.
*
* @public
* @param options - The options for the structured completion resource.
* @returns The structured completion resource.
*/
function structuredCompletionResource(options) {
const { model, input, schema, system, tools, debugName, apiUrl, retries, debounce, } = options;
const resource = structuredChatResource({
model,
system,
schema,
tools,
debugName,
apiUrl,
retries,
debounce,
transport: options.transport,
ui: options.ui ?? false,
threadId: options.threadId,
});
effect(() => {
const _input = input();
if (!_input) {
return;
}
resource.setMessages([
{
role: 'user',
content: _input,
},
]);
});
const valueSignal = computed(() => {
const lastMessage = resource.value()[resource.value().length - 1];
if (lastMessage