59b1c40cdf
The transcript becomes a contribution area (transcript.directives). A plugin
registers a named directive and the model addresses it by emitting
::name{key="value"} as its own paragraph; that leaf renders as the plugin's
component, wrapped in the contribution error boundary. Unclaimed or malformed
directives stay plain prose, so nothing changes for text that merely looks
like a directive (std::vector) or for users with the plugin disabled.
Core ships ::preview{file="..."} as the reference consumer (the existing
preview-attachment card), the desktop platform hint teaches the model the
syntax, and the SDK exports the area + types so runtime plugin.js files get
the surface through the normal plugins API.
83 lines
3.0 KiB
TypeScript
83 lines
3.0 KiB
TypeScript
import type { ReactNode } from 'react'
|
|
|
|
/**
|
|
* TRANSCRIPT DIRECTIVES — the transcript as a contribution area.
|
|
*
|
|
* A plugin registers a named directive; the model addresses it by emitting a
|
|
* paragraph of the form `::name{key="value"}` and that leaf renders as the
|
|
* plugin's component, inline in the assistant message. This is the deliberate
|
|
* counterpart to artifact promotion: artifacts are heuristic (substantial
|
|
* fences get promoted whether or not the model asked), directives are
|
|
* addressed (nothing renders unless a plugin claimed the name).
|
|
*
|
|
* The parse is deliberately narrow — a directive must be the entire
|
|
* paragraph, so it can never hijack mid-prose text, and an unclaimed or
|
|
* malformed directive falls back to the plain paragraph it always was.
|
|
* Attributes are untrusted model output: plugins validate their own fields.
|
|
*/
|
|
|
|
export const TRANSCRIPT_DIRECTIVE_AREA = 'transcript.directives'
|
|
|
|
/** Props handed to a directive contribution's `render`. */
|
|
export interface TranscriptDirectiveProps {
|
|
/** Parsed, untrusted attributes (e.g. `{ file: 'demo.html' }`). */
|
|
attrs: Readonly<Record<string, string>>
|
|
/** Original directive source text (diagnostics / fallback rendering). */
|
|
source: string
|
|
/** True while the surrounding message is still streaming. */
|
|
streaming: boolean
|
|
}
|
|
|
|
/** Payload of a `transcript.directives` contribution's `data`. */
|
|
export interface TranscriptDirectiveContribution {
|
|
/** The name the model addresses: `::<name>{...}`. Lowercase, `[a-z0-9-]`,
|
|
* unique across plugins — first registration wins on collision. */
|
|
name: string
|
|
/** Renders the directive leaf. Mounted inside the contribution error
|
|
* boundary, so a throw degrades to an inline error, not a dead message. */
|
|
render: (props: TranscriptDirectiveProps) => ReactNode
|
|
}
|
|
|
|
export interface ParsedTranscriptDirective {
|
|
name: string
|
|
attrs: Record<string, string>
|
|
source: string
|
|
}
|
|
|
|
// The whole paragraph, nothing else on the line: `::name` or `::name{...}`.
|
|
// Length caps bound the attr scan on adversarial input.
|
|
const DIRECTIVE_RE = /^::([a-z][a-z0-9-]{0,63})(?:\{([^{}]{0,1024})\})?$/
|
|
|
|
// `key="value"` pairs; single quotes accepted for model sloppiness.
|
|
const ATTR_RE = /([a-z][\w-]{0,63})=(?:"([^"]*)"|'([^']*)')/gi
|
|
|
|
/**
|
|
* Parse a paragraph as a transcript directive. Returns null unless the ENTIRE
|
|
* trimmed text is one directive — prose containing `::` stays prose.
|
|
* Pure and synchronous — safe to call during render.
|
|
*/
|
|
export function parseTranscriptDirective(text: string): ParsedTranscriptDirective | null {
|
|
const trimmed = text.trim()
|
|
|
|
// Cheap reject before the regex: directives are short single lines.
|
|
if (!trimmed.startsWith('::') || trimmed.length > 1200 || trimmed.includes('\n')) {
|
|
return null
|
|
}
|
|
|
|
const match = DIRECTIVE_RE.exec(trimmed)
|
|
|
|
if (!match) {
|
|
return null
|
|
}
|
|
|
|
const attrs: Record<string, string> = {}
|
|
|
|
if (match[2]) {
|
|
for (const pair of match[2].matchAll(ATTR_RE)) {
|
|
attrs[pair[1].toLowerCase()] = pair[2] ?? pair[3] ?? ''
|
|
}
|
|
}
|
|
|
|
return { name: match[1], attrs, source: trimmed }
|
|
}
|