blob: 563c75d8a7ea93de9cb5469d141d551e508cf88b [file]
import type { InlineReference } from '@maka/core';
import { ChatTokenizedText, type ChatComposerToken } from '@astryxdesign/core';
import type { ReactNode } from 'react';
import { ICON_SIZE, Sparkles } from './icons.js';
type InlineReferenceVisual = Pick<InlineReference, 'kind' | 'value' | 'label'>;
export type WorkspaceFileReferencePosition = Pick<InlineReference, 'value' | 'start'>;
/** Last path segment of a POSIX-style relative path. */
export function inlineReferenceFileBasename(relativePath: string): string {
const segments = relativePath.split('/').filter(Boolean);
return segments[segments.length - 1] ?? relativePath;
}
export function workspaceFileInlineReference(relativePath: string): InlineReferenceVisual {
return {
kind: 'workspace_file',
value: `@${relativePath}`,
label: inlineReferenceFileBasename(relativePath),
};
}
/** Mirror Astryx serialization while recording the exact surviving file-token ranges. */
export function workspaceFileReferencePositions(root: Node): WorkspaceFileReferencePosition[] {
let serialized = '';
const positions: WorkspaceFileReferencePosition[] = [];
const visit = (node: Node) => {
for (const child of Array.from(node.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) {
serialized += child.textContent ?? '';
continue;
}
if (child.nodeType !== Node.ELEMENT_NODE) continue;
const element = child as HTMLElement;
if (element.hasAttribute('data-astryx-token')) {
const value = element.getAttribute('data-astryx-token-value') ?? '';
if (value.startsWith('@') && value.length > 1) {
positions.push({ value, start: serialized.length });
}
serialized += value;
} else if (element.tagName === 'BR') {
serialized += '\n';
} else {
visit(element);
}
}
};
visit(root);
const normalized = serialized.replace(/ /g, ' ');
const leadingTrim = normalized.length - normalized.trimStart().length;
const trailingBoundary = normalized.trimEnd().length;
return positions
.filter(
(reference) =>
reference.start >= leadingTrim &&
reference.start + reference.value.length <= trailingBoundary,
)
.map((reference) => ({ ...reference, start: reference.start - leadingTrim }));
}
/** The single visual projection shared by the Composer and sent transcript. */
export function inlineReferenceToken(reference: InlineReferenceVisual): ChatComposerToken {
return {
value: reference.value,
label: reference.label,
...(reference.kind === 'skill'
? { icon: <Sparkles size={ICON_SIZE.meta} aria-hidden="true" /> }
: {}),
};
}
export function InlineReferenceText(props: {
text: string;
references: readonly InlineReference[];
}) {
const parts: ReactNode[] = [];
let cursor = 0;
for (const reference of props.references) {
if (
!reference.value ||
!Number.isSafeInteger(reference.start) ||
reference.start < cursor ||
props.text.slice(reference.start, reference.start + reference.value.length) !==
reference.value
) {
continue;
}
if (reference.start > cursor) parts.push(props.text.slice(cursor, reference.start));
parts.push(
<ChatTokenizedText
key={`${reference.start}:${reference.value}`}
tokens={[inlineReferenceToken(reference)]}
>
{reference.value}
</ChatTokenizedText>,
);
cursor = reference.start + reference.value.length;
}
if (cursor < props.text.length) parts.push(props.text.slice(cursor));
return <span>{parts}</span>;
}