Compare commits

..

3 Commits

Author SHA1 Message Date
Jamison Lahman
0f46e1e084 nit 2026-03-14 19:38:43 -07:00
Jamison Lahman
f4d379ceed fix(voice): plumb fatal errors to the frontend 2026-03-14 19:26:41 -07:00
Jamison Lahman
8f1076e69d chore(voice): support non-default FE ports for IS_DEV 2026-03-14 19:02:35 -07:00
101 changed files with 95 additions and 11415 deletions

32
.vscode/launch.json vendored
View File

@@ -15,7 +15,7 @@
{
"name": "Run All Onyx Services",
"configurations": [
// "Web Server",
"Web Server",
"Model Server",
"API Server",
"MCP Server",
@@ -95,7 +95,7 @@
"LOG_LEVEL": "DEBUG",
"PYTHONUNBUFFERED": "1"
},
"args": ["model_server.main:app", "--reload", "--port", "9010"],
"args": ["model_server.main:app", "--reload", "--port", "9000"],
"presentation": {
"group": "2"
},
@@ -113,7 +113,7 @@
"LOG_LEVEL": "DEBUG",
"PYTHONUNBUFFERED": "1"
},
"args": ["onyx.main:app", "--reload", "--port", "8090"],
"args": ["onyx.main:app", "--reload", "--port", "8080"],
"presentation": {
"group": "2"
},
@@ -165,7 +165,7 @@
"envFile": "${workspaceFolder}/.vscode/.env",
"env": {
"MCP_SERVER_ENABLED": "true",
"MCP_SERVER_PORT": "8100",
"MCP_SERVER_PORT": "8090",
"MCP_SERVER_CORS_ORIGINS": "http://localhost:*",
"LOG_LEVEL": "DEBUG",
"PYTHONUNBUFFERED": "1"
@@ -174,7 +174,7 @@
"onyx.mcp_server.api:mcp_app",
"--reload",
"--port",
"8100",
"8090",
"--timeout-graceful-shutdown",
"0"
],
@@ -526,7 +526,10 @@
"type": "node",
"request": "launch",
"runtimeExecutable": "uv",
"runtimeArgs": ["sync", "--all-extras"],
"runtimeArgs": [
"sync",
"--all-extras"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"presentation": {
@@ -650,7 +653,14 @@
"type": "node",
"request": "launch",
"runtimeExecutable": "uv",
"runtimeArgs": ["run", "--with", "onyx-devtools", "ods", "db", "upgrade"],
"runtimeArgs": [
"run",
"--with",
"onyx-devtools",
"ods",
"db",
"upgrade"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"presentation": {
@@ -669,11 +679,7 @@
"PYTHONUNBUFFERED": "1",
"PYTHONPATH": "backend"
},
"args": [
"--filename",
"backend/generated/openapi.json",
"--generate-python-client"
]
"args": ["--filename", "backend/generated/openapi.json", "--generate-python-client"]
},
{
// script to debug multi tenant db issues
@@ -702,7 +708,7 @@
"name": "Debug React Web App in Chrome",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3010",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/web"
}
]

View File

@@ -1,112 +0,0 @@
"""
GenUI system prompt for LLM integration.
This prompt teaches the LLM to output structured UI using GenUI Lang.
It's generated from the Onyx component library definitions and kept
in sync with the frontend @onyx/genui-onyx library.
TODO: Auto-generate this from the frontend library at build time
instead of maintaining a static copy.
"""
GENUI_SYSTEM_PROMPT = """# Structured UI Output (GenUI Lang)
When the user's request benefits from structured UI (tables, cards, buttons, layouts), respond using GenUI Lang — a compact, line-oriented markup. Otherwise respond in plain markdown.
## Syntax
Each line declares a variable: `name = expression`
Expressions:
- `ComponentName(arg1, arg2, key: value)` — component with positional or named args
- `[a, b, c]` — array
- `{key: value}` — object
- `"string"`, `42`, `true`, `false`, `null` — literals
- `variableName` — reference to a previously defined variable
Rules:
- PascalCase identifiers are component types
- camelCase identifiers are variable references
- Positional args map to props in the order defined below
- The last statement is the root element (or name one `root`)
- Lines inside brackets/parens can span multiple lines
- Lines that don't match `name = expression` are treated as plain text
## Available Components
### Layout
- `Stack(children?: unknown[], gap?: "none" | "xs" | "sm" | "md" | "lg" | "xl", align?: "start" | "center" | "end" | "stretch")` — Vertical stack layout — arranges children top to bottom
- `Row(children?: unknown[], gap?: "none" | "xs" | "sm" | "md" | "lg" | "xl", align?: "start" | "center" | "end" | "stretch", wrap?: boolean)` — Horizontal row layout — arranges children left to right
- `Column(children?: unknown[], width?: string)` — A column within a Row, with optional width control
- `Card(title?: string, padding?: "none" | "sm" | "md" | "lg")` — A container card with optional title and padding
- `Divider(spacing?: "sm" | "md" | "lg")` — A horizontal separator line
### Content
- `Text(children: string, headingH1?: boolean, headingH2?: boolean, headingH3?: boolean, muted?: boolean, mono?: boolean, bold?: boolean)` — Displays text with typography variants
- `Tag(title: string, color?: "green" | "purple" | "blue" | "gray" | "amber", size?: "sm" | "md")` — A small label tag with color
- `Table(columns: string[], rows: unknown[][], compact?: boolean)` — A data table with columns and rows
- `Code(children: string, language?: string, showCopyButton?: boolean)` — A code block with optional copy button
- `Image(src: string, alt?: string, width?: string, height?: string)` — Displays an image
- `Link(children: string, href: string, external?: boolean)` — A clickable hyperlink
- `List(items: string[], ordered?: boolean)` — An ordered or unordered list
### Interactive
- `Button(children: string, main?: boolean, action?: boolean, danger?: boolean, primary?: boolean, secondary?: boolean, tertiary?: boolean, size?: "lg" | "md", actionId?: string, disabled?: boolean)` — An interactive button that triggers an action
- `IconButton(icon: string, tooltip?: string, main?: boolean, action?: boolean, danger?: boolean, primary?: boolean, secondary?: boolean, actionId?: string, disabled?: boolean)` — A button that displays an icon with an optional tooltip
- `Input(placeholder?: string, value?: string, actionId?: string, readOnly?: boolean)` — A text input field
### Feedback
- `Alert(text: string, description?: string, level?: "default" | "info" | "success" | "warning" | "error", showIcon?: boolean)` — A status message banner (info, success, warning, error)
## Output Format
**CRITICAL: Output GenUI Lang directly as plain text. Do NOT wrap it in code fences (no ```genui or ``` blocks). The output is parsed as a streaming language, not displayed as code.**
## Streaming Guidelines
- Define variables before referencing them
- Each line is independently parseable — the UI updates as each line completes
- Keep variable names short and descriptive
- Build up complex UIs incrementally: define data first, then layout
## Examples
### Search results with table
```
title = Text("Search Results", headingH2: true)
row1 = ["Onyx Docs", Tag("PDF", color: "blue"), "2024-01-15"]
row2 = ["API Guide", Tag("MD", color: "green"), "2024-02-01"]
results = Table(["Name", "Type", "Date"], [row1, row2])
action = Button("View All", main: true, primary: true, actionId: "viewAll")
root = Stack([title, results, action], gap: "md")
```
### Status card with actions
```
status = Alert("Pipeline completed successfully", level: "success")
stats = Row([
Text("Processed: 1,234 docs"),
Text("Duration: 2m 34s", muted: true)
], gap: "lg")
actions = Row([
Button("View Results", main: true, primary: true, actionId: "viewResults"),
Button("Run Again", action: true, secondary: true, actionId: "rerun")
], gap: "sm")
root = Stack([status, stats, actions], gap: "md")
```
### Simple info display
```
root = Card(title: "Document Summary")
```
## Additional Guidelines
- Use Stack for vertical layouts and Row for horizontal layouts
- For tables, pass column headers as a string array and rows as arrays of values
- Tags are great for showing status, categories, or labels inline
- Use Alert for important status messages — choose the right level (info, success, warning, error)
- Buttons need an actionId to trigger events — the UI framework handles the callback
- Keep layouts simple — prefer flat structures over deeply nested ones
- For search results or document lists, use Table with relevant columns
- Use Card to visually group related content"""

View File

@@ -13,7 +13,6 @@ from onyx.chat.citation_processor import CitationMode
from onyx.chat.citation_processor import DynamicCitationProcessor
from onyx.chat.citation_utils import update_citation_processor_from_tool_response
from onyx.chat.emitter import Emitter
from onyx.chat.genui_prompt import GENUI_SYSTEM_PROMPT
from onyx.chat.llm_step import extract_tool_calls_from_response_text
from onyx.chat.llm_step import run_llm_step
from onyx.chat.models import ChatMessageSimple
@@ -27,7 +26,6 @@ from onyx.chat.prompt_utils import build_system_prompt
from onyx.chat.prompt_utils import (
get_default_base_system_prompt,
)
from onyx.configs.app_configs import GENUI_ENABLED
from onyx.configs.app_configs import INTEGRATION_TESTS_MODE
from onyx.configs.constants import DocumentSource
from onyx.configs.constants import MessageType
@@ -701,7 +699,6 @@ def run_llm_loop(
tools=tools,
should_cite_documents=should_cite_documents
or always_cite_documents,
genui_prompt=GENUI_SYSTEM_PROMPT if GENUI_ENABLED else None,
)
system_prompt = ChatMessageSimple(
message=system_prompt_str,
@@ -795,7 +792,6 @@ def run_llm_loop(
final_documents=gathered_documents,
user_identity=user_identity,
pre_answer_processing_time=pre_answer_processing_time,
use_genui=GENUI_ENABLED,
)
if has_reasoned:
reasoning_cycles += 1

View File

@@ -48,8 +48,6 @@ from onyx.server.query_and_chat.placement import Placement
from onyx.server.query_and_chat.streaming_models import AgentResponseDelta
from onyx.server.query_and_chat.streaming_models import AgentResponseStart
from onyx.server.query_and_chat.streaming_models import CitationInfo
from onyx.server.query_and_chat.streaming_models import GenUIDelta
from onyx.server.query_and_chat.streaming_models import GenUIStart
from onyx.server.query_and_chat.streaming_models import Packet
from onyx.server.query_and_chat.streaming_models import ReasoningDelta
from onyx.server.query_and_chat.streaming_models import ReasoningDone
@@ -933,7 +931,6 @@ def run_llm_step_pkt_generator(
is_deep_research: bool = False,
pre_answer_processing_time: float | None = None,
timeout_override: int | None = None,
use_genui: bool = False,
) -> Generator[Packet, None, tuple[LlmStepResult, bool]]:
"""Run an LLM step and stream the response as packets.
NOTE: DO NOT TOUCH THIS FUNCTION BEFORE ASKING YUHONG, this is very finicky and
@@ -969,8 +966,6 @@ def run_llm_step_pkt_generator(
pre_answer_processing_time: Optional time spent processing before the
answer started, recorded in state_container for analytics.
timeout_override: Optional timeout override for the LLM call.
use_genui: If True, emit GenUIStart/GenUIDelta packets instead of
AgentResponseStart/AgentResponseDelta.
Yields:
Packet: Streaming packets containing:
@@ -1117,7 +1112,6 @@ def run_llm_step_pkt_generator(
pre_answer_processing_time
)
# Always emit AgentResponseStart for text rendering
yield Packet(
placement=_current_placement(),
obj=AgentResponseStart(
@@ -1125,30 +1119,9 @@ def run_llm_step_pkt_generator(
pre_answer_processing_seconds=pre_answer_processing_time,
),
)
# When GenUI is enabled, also emit GenUIStart so the
# frontend can offer both text and structured views.
if use_genui:
yield Packet(
placement=_current_placement(),
obj=GenUIStart(),
)
answer_start = True
if use_genui:
accumulated_answer += content_chunk
if state_container:
state_container.set_answer_tokens(accumulated_answer)
# Emit both text and GenUI deltas so the frontend can
# toggle between plain text and structured rendering.
yield Packet(
placement=_current_placement(),
obj=AgentResponseDelta(content=content_chunk),
)
yield Packet(
placement=_current_placement(),
obj=GenUIDelta(content=content_chunk),
)
elif citation_processor:
if citation_processor:
yield from _emit_citation_results(
citation_processor.process_token(content_chunk)
)
@@ -1365,7 +1338,6 @@ def run_llm_step(
is_deep_research: bool = False,
pre_answer_processing_time: float | None = None,
timeout_override: int | None = None,
use_genui: bool = False,
) -> tuple[LlmStepResult, bool]:
"""Wrapper around run_llm_step_pkt_generator that consumes packets and emits them.
@@ -1389,7 +1361,6 @@ def run_llm_step(
is_deep_research=is_deep_research,
pre_answer_processing_time=pre_answer_processing_time,
timeout_override=timeout_override,
use_genui=use_genui,
)
while True:

View File

@@ -200,7 +200,6 @@ def build_system_prompt(
tools: Sequence[Tool] | None = None,
should_cite_documents: bool = False,
include_all_guidance: bool = False,
genui_prompt: str | None = None,
) -> str:
"""Should only be called with the default behavior system prompt.
If the user has replaced the default behavior prompt with their custom agent prompt, do not call this function.
@@ -289,7 +288,4 @@ def build_system_prompt(
if tool_guidance_sections:
system_prompt += TOOL_SECTION_HEADER + "\n".join(tool_guidance_sections)
if genui_prompt:
system_prompt += "\n\n" + genui_prompt
return system_prompt

View File

@@ -957,7 +957,7 @@ ENTERPRISE_EDITION_ENABLED = (
#####
# Image Generation Configuration (DEPRECATED)
# These environment variables will be deprecated soon.
# To configure image generation, please visit the Image Generation page in the Admin Settings.
# To configure image generation, please visit the Image Generation page in the Admin Panel.
#####
# Azure Image Configurations
AZURE_IMAGE_API_VERSION = os.environ.get("AZURE_IMAGE_API_VERSION") or os.environ.get(
@@ -1048,12 +1048,6 @@ DEV_MODE = os.environ.get("DEV_MODE", "").lower() == "true"
INTEGRATION_TESTS_MODE = os.environ.get("INTEGRATION_TESTS_MODE", "").lower() == "true"
#####
# GenUI Configuration
#####
# Enable GenUI structured UI rendering in chat responses
GENUI_ENABLED = os.environ.get("GENUI_ENABLED", "").lower() == "true"
#####
# Captcha Configuration (for cloud signup protection)
#####

View File

@@ -118,6 +118,12 @@ async def handle_streaming_transcription(
if result is None: # End of stream
logger.info("Streaming transcription: transcript stream ended")
break
if result.error:
logger.warning(
f"Streaming transcription: provider error: {result.error}"
)
await websocket.send_json({"type": "error", "message": result.error})
continue
# Send if text changed OR if VAD detected end of speech (for auto-send trigger)
if result.text and (result.text != last_transcript or result.is_vad_end):
last_transcript = result.text

View File

@@ -9,7 +9,6 @@ from pydantic import ValidationError
from sqlalchemy.orm import Session
from onyx.chat.citation_utils import extract_citation_order_from_text
from onyx.configs.app_configs import GENUI_ENABLED
from onyx.configs.constants import MessageType
from onyx.context.search.models import SavedSearchDoc
from onyx.context.search.models import SearchDoc
@@ -30,8 +29,6 @@ from onyx.server.query_and_chat.streaming_models import CustomToolStart
from onyx.server.query_and_chat.streaming_models import FileReaderResult
from onyx.server.query_and_chat.streaming_models import FileReaderStart
from onyx.server.query_and_chat.streaming_models import GeneratedImage
from onyx.server.query_and_chat.streaming_models import GenUIDelta
from onyx.server.query_and_chat.streaming_models import GenUIStart
from onyx.server.query_and_chat.streaming_models import ImageGenerationFinal
from onyx.server.query_and_chat.streaming_models import ImageGenerationToolStart
from onyx.server.query_and_chat.streaming_models import IntermediateReportDelta
@@ -92,16 +89,6 @@ def create_message_packets(
)
)
# When GenUI is enabled, also emit GenUIStart so the frontend
# can offer both text and structured views for old conversations.
if GENUI_ENABLED:
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=GenUIStart(),
)
)
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
@@ -111,16 +98,6 @@ def create_message_packets(
),
)
if GENUI_ENABLED:
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=GenUIDelta(
content=message_text,
),
),
)
packets.append(
Packet(
placement=Placement(turn_index=turn_index),

View File

@@ -55,9 +55,6 @@ class StreamingType(Enum):
INTERMEDIATE_REPORT_DELTA = "intermediate_report_delta"
INTERMEDIATE_REPORT_CITED_DOCS = "intermediate_report_cited_docs"
GENUI_START = "genui_start"
GENUI_DELTA = "genui_delta"
class BaseObj(BaseModel):
type: str = ""
@@ -370,18 +367,6 @@ class IntermediateReportCitedDocs(BaseObj):
cited_docs: list[SearchDoc] | None = None
################################################
# GenUI Packets
################################################
class GenUIStart(BaseObj):
type: Literal["genui_start"] = StreamingType.GENUI_START.value
class GenUIDelta(BaseObj):
type: Literal["genui_delta"] = StreamingType.GENUI_DELTA.value
content: str
################################################
# Packet Object
################################################
@@ -430,9 +415,6 @@ PacketObj = Union[
IntermediateReportStart,
IntermediateReportDelta,
IntermediateReportCitedDocs,
# GenUI Packets
GenUIStart,
GenUIDelta,
]

View File

@@ -15,6 +15,9 @@ class TranscriptResult(BaseModel):
is_vad_end: bool = False
"""True if VAD detected end of speech (silence). Use for auto-send."""
error: str | None = None
"""Provider error message to forward to the client, if any."""
class StreamingTranscriberProtocol(Protocol):
"""Protocol for streaming transcription sessions."""

View File

@@ -56,6 +56,17 @@ def _http_to_ws_url(http_url: str) -> str:
return http_url
_USER_FACING_ERROR_MESSAGES: dict[str, str] = {
"input_audio_buffer_commit_empty": (
"No audio was recorded. Please check your microphone and try again."
),
"invalid_api_key": "Voice service authentication failed. Please contact support.",
"rate_limit_exceeded": "Voice service is temporarily busy. Please try again shortly.",
}
_DEFAULT_USER_ERROR = "A voice transcription error occurred. Please try again."
class OpenAIStreamingTranscriber(StreamingTranscriberProtocol):
"""Streaming transcription using OpenAI Realtime API."""
@@ -142,6 +153,17 @@ class OpenAIStreamingTranscriber(StreamingTranscriberProtocol):
if msg_type == OpenAIRealtimeMessageType.ERROR:
error = data.get("error", {})
self._logger.error(f"OpenAI error: {error}")
error_code = error.get("code", "")
user_message = _USER_FACING_ERROR_MESSAGES.get(
error_code, _DEFAULT_USER_ERROR
)
await self._transcript_queue.put(
TranscriptResult(
text="",
is_vad_end=False,
error=user_message,
)
)
continue
# Handle VAD events

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +0,0 @@
{
"name": "@onyx/genui",
"version": "0.1.0",
"private": true,
"description": "Framework-agnostic structured UI rendering — parser, registry, prompt generation",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
}
}

View File

@@ -1,33 +0,0 @@
import { z } from "zod";
import type { ComponentDef } from "./types";
interface DefineComponentConfig<T extends z.ZodObject<z.ZodRawShape>> {
name: string;
description: string;
props: T;
component: unknown;
group?: string;
}
/**
* Define a GenUI component with typed props via Zod schema.
* The `component` field is framework-agnostic (typed as `unknown` in core).
* React bindings narrow this to `React.FC`.
*/
export function defineComponent<T extends z.ZodObject<z.ZodRawShape>>(
config: DefineComponentConfig<T>
): ComponentDef<T> {
if (!/^[A-Z][a-zA-Z0-9]*$/.test(config.name)) {
throw new Error(
`Component name "${config.name}" must be PascalCase (start with uppercase, alphanumeric only)`
);
}
return {
name: config.name,
description: config.description,
props: config.props,
component: config.component,
group: config.group,
};
}

View File

@@ -1,74 +0,0 @@
// ── Types ──
export type {
Token,
ASTNode,
ComponentNode,
ArgumentNode,
ArrayNode,
ObjectNode,
LiteralNode,
ReferenceNode,
ElementNode,
TextElementNode,
ResolvedNode,
Statement,
ParseError,
ParseResult,
ComponentDef,
ParamDef,
ParamMap,
Library,
PromptOptions,
ActionEvent,
} from "./types";
export { TokenType } from "./types";
// ── Component & Library ──
export { defineComponent } from "./component";
export { createLibrary } from "./library";
// ── Parser ──
export { Tokenizer } from "./parser/tokenizer";
export { Parser } from "./parser/parser";
export { autoClose } from "./parser/autoclose";
export { resolveReferences } from "./parser/resolver";
export { validateAndTransform } from "./parser/validator";
export { createStreamingParser } from "./parser/streaming";
export type { StreamParser } from "./parser/streaming";
// ── Prompt ──
export { generatePrompt } from "./prompt/generator";
export { zodToTypeString, schemaToSignature } from "./prompt/introspector";
// ── Convenience: one-shot parse ──
import type { Library, ParseResult, ElementNode, ASTNode } from "./types";
import { Parser } from "./parser/parser";
import { resolveReferences } from "./parser/resolver";
import { validateAndTransform } from "./parser/validator";
/**
* One-shot parse: tokenize → parse → resolve → validate.
*/
export function parse(input: string, library: Library): ParseResult {
const parser = Parser.fromSource(input);
const { statements, errors: parseErrors } = parser.parse();
const { root, errors: resolveErrors } = resolveReferences(statements);
const allErrors = [...parseErrors, ...resolveErrors];
let rootElement: ElementNode | null = null;
if (root) {
const { element, errors: validateErrors } = validateAndTransform(
root,
library
);
rootElement = element;
allErrors.push(...validateErrors);
}
return {
statements,
root: rootElement as ASTNode | null,
errors: allErrors,
};
}

View File

@@ -1,208 +0,0 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { defineComponent, createLibrary, parse } from "./index";
/**
* Integration tests: end-to-end from source → parsed element tree.
*/
function makeTestLibrary() {
return createLibrary([
defineComponent({
name: "Text",
description: "Displays text",
props: z.object({
children: z.string(),
headingH2: z.boolean().optional(),
}),
component: null,
group: "Content",
}),
defineComponent({
name: "Button",
description: "Interactive button",
props: z.object({
children: z.string(),
main: z.boolean().optional(),
primary: z.boolean().optional(),
actionId: z.string().optional(),
}),
component: null,
group: "Interactive",
}),
defineComponent({
name: "Tag",
description: "Label tag",
props: z.object({
title: z.string(),
color: z.enum(["green", "purple", "blue", "gray", "amber"]).optional(),
}),
component: null,
group: "Content",
}),
defineComponent({
name: "Table",
description: "Data table",
props: z.object({
columns: z.array(z.string()),
rows: z.array(z.array(z.unknown())),
}),
component: null,
group: "Content",
}),
defineComponent({
name: "Stack",
description: "Vertical layout",
props: z.object({
children: z.array(z.unknown()).optional(),
gap: z.enum(["none", "xs", "sm", "md", "lg", "xl"]).optional(),
}),
component: null,
group: "Layout",
}),
]);
}
describe("Integration: parse()", () => {
it("parses the spec example end-to-end", () => {
const lib = makeTestLibrary();
const input = `title = Text("Search Results", headingH2: true)
row1 = ["Onyx Docs", Tag("PDF", color: "blue"), "2024-01-15"]
row2 = ["API Guide", Tag("MD", color: "green"), "2024-02-01"]
results = Table(["Name", "Type", "Date"], [row1, row2])
action = Button("View All", main: true, primary: true, actionId: "viewAll")
root = Stack([title, results, action], gap: "md")`;
const result = parse(input, lib);
expect(result.root).not.toBeNull();
// Root should be a Stack element
if (result.root && "component" in result.root) {
expect((result.root as any).component).toBe("Stack");
}
});
it("parses a single component", () => {
const lib = makeTestLibrary();
const result = parse('x = Text("Hello World")', lib);
expect(result.root).not.toBeNull();
expect(
result.errors.filter((e) => !e.message.includes("Unknown"))
).toHaveLength(0);
});
it("handles unknown components gracefully", () => {
const lib = makeTestLibrary();
const result = parse('x = UnknownWidget("test")', lib);
expect(result.root).not.toBeNull();
expect(
result.errors.some((e) => e.message.includes("Unknown component"))
).toBe(true);
});
it("handles empty input", () => {
const lib = makeTestLibrary();
const result = parse("", lib);
expect(result.root).toBeNull();
expect(result.errors).toHaveLength(0);
});
});
describe("Integration: library.prompt()", () => {
it("generates a prompt with component signatures", () => {
const lib = makeTestLibrary();
const prompt = lib.prompt();
expect(prompt).toContain("GenUI Lang");
expect(prompt).toContain("Text(");
expect(prompt).toContain("Button(");
expect(prompt).toContain("Tag(");
expect(prompt).toContain("Table(");
expect(prompt).toContain("Stack(");
});
it("includes syntax rules", () => {
const lib = makeTestLibrary();
const prompt = lib.prompt();
expect(prompt).toContain("PascalCase");
expect(prompt).toContain("camelCase");
expect(prompt).toContain("positional");
});
it("includes streaming guidelines by default", () => {
const lib = makeTestLibrary();
const prompt = lib.prompt();
expect(prompt).toContain("Streaming");
});
it("can disable streaming guidelines", () => {
const lib = makeTestLibrary();
const prompt = lib.prompt({ streaming: false });
expect(prompt).not.toContain("Streaming Guidelines");
});
it("includes custom examples", () => {
const lib = makeTestLibrary();
const prompt = lib.prompt({
examples: [{ description: "Test example", code: 'x = Text("test")' }],
});
expect(prompt).toContain("Test example");
expect(prompt).toContain('x = Text("test")');
});
});
describe("Integration: defineComponent", () => {
it("rejects non-PascalCase names", () => {
expect(() =>
defineComponent({
name: "button",
description: "Invalid",
props: z.object({}),
component: null,
})
).toThrow("PascalCase");
});
it("accepts valid PascalCase names", () => {
expect(() =>
defineComponent({
name: "MyWidget",
description: "Valid",
props: z.object({}),
component: null,
})
).not.toThrow();
});
});
describe("Integration: createLibrary", () => {
it("rejects duplicate component names", () => {
const comp = defineComponent({
name: "Foo",
description: "Foo",
props: z.object({}),
component: null,
});
expect(() => createLibrary([comp, comp])).toThrow("Duplicate");
});
it("resolves components by name", () => {
const lib = makeTestLibrary();
expect(lib.resolve("Text")).toBeDefined();
expect(lib.resolve("NonExistent")).toBeUndefined();
});
it("generates param map", () => {
const lib = makeTestLibrary();
const paramMap = lib.paramMap();
const textParams = paramMap.get("Text");
expect(textParams).toBeDefined();
expect(textParams![0]!.name).toBe("children");
expect(textParams![0]!.required).toBe(true);
});
});

View File

@@ -1,84 +0,0 @@
import { z } from "zod";
import type {
ComponentDef,
Library,
ParamDef,
ParamMap,
PromptOptions,
} from "./types";
import { generatePrompt } from "./prompt/generator";
/**
* Build ordered param definitions from a Zod object schema.
* Ordering matches the shape key order (which is insertion order in JS objects).
*/
function buildParamDefs(schema: z.ZodObject<z.ZodRawShape>): ParamDef[] {
const shape = schema.shape;
return Object.entries(shape).map(([name, zodType]) => {
const unwrapped = zodType as z.ZodTypeAny;
const isOptional = unwrapped.isOptional();
return {
name,
required: !isOptional,
zodType: unwrapped,
};
});
}
interface CreateLibraryOptions {
/** Default prompt options merged with per-call options */
defaultPromptOptions?: PromptOptions;
}
/**
* Create a component library from an array of component definitions.
*/
export function createLibrary(
components: ComponentDef[],
options?: CreateLibraryOptions
): Library {
const map = new Map<string, ComponentDef>();
for (const comp of components) {
if (map.has(comp.name)) {
throw new Error(`Duplicate component name: "${comp.name}"`);
}
map.set(comp.name, comp);
}
const cachedParamMap = new Map<string, ParamDef[]>();
return {
components: map,
resolve(name: string): ComponentDef | undefined {
return map.get(name);
},
prompt(promptOptions?: PromptOptions): string {
const merged: PromptOptions = {
...options?.defaultPromptOptions,
...promptOptions,
additionalRules: [
...(options?.defaultPromptOptions?.additionalRules ?? []),
...(promptOptions?.additionalRules ?? []),
],
examples: [
...(options?.defaultPromptOptions?.examples ?? []),
...(promptOptions?.examples ?? []),
],
};
return generatePrompt(this, merged);
},
paramMap(): ParamMap {
if (cachedParamMap.size === 0) {
for (const [name, comp] of map) {
cachedParamMap.set(name, buildParamDefs(comp.props));
}
}
return cachedParamMap;
},
};
}

View File

@@ -1,321 +0,0 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { defineComponent, createLibrary, parse } from "./index";
/**
* Smoke test that mirrors the Onyx library assembly.
* Verifies all 16 component definitions register without errors
* and the library generates a valid prompt.
*/
describe("Onyx Library Assembly (smoke test)", () => {
// Re-define all components exactly as onyx/src/components/ does,
// to verify the schemas are valid without needing the onyx package import.
const components = [
defineComponent({
name: "Stack",
description: "Vertical stack layout",
group: "Layout",
props: z.object({
children: z.array(z.unknown()).optional(),
gap: z.enum(["none", "xs", "sm", "md", "lg", "xl"]).optional(),
align: z.enum(["start", "center", "end", "stretch"]).optional(),
}),
component: null,
}),
defineComponent({
name: "Row",
description: "Horizontal row layout",
group: "Layout",
props: z.object({
children: z.array(z.unknown()).optional(),
gap: z.enum(["none", "xs", "sm", "md", "lg", "xl"]).optional(),
align: z.enum(["start", "center", "end", "stretch"]).optional(),
wrap: z.boolean().optional(),
}),
component: null,
}),
defineComponent({
name: "Column",
description: "A column within a Row",
group: "Layout",
props: z.object({
children: z.array(z.unknown()).optional(),
width: z.string().optional(),
}),
component: null,
}),
defineComponent({
name: "Card",
description: "Container card",
group: "Layout",
props: z.object({
title: z.string().optional(),
padding: z.enum(["none", "sm", "md", "lg"]).optional(),
}),
component: null,
}),
defineComponent({
name: "Divider",
description: "Horizontal separator",
group: "Layout",
props: z.object({
spacing: z.enum(["sm", "md", "lg"]).optional(),
}),
component: null,
}),
defineComponent({
name: "Text",
description: "Displays text with typography variants",
group: "Content",
props: z.object({
children: z.string(),
headingH1: z.boolean().optional(),
headingH2: z.boolean().optional(),
headingH3: z.boolean().optional(),
muted: z.boolean().optional(),
mono: z.boolean().optional(),
bold: z.boolean().optional(),
}),
component: null,
}),
defineComponent({
name: "Tag",
description: "Label tag with color",
group: "Content",
props: z.object({
title: z.string(),
color: z.enum(["green", "purple", "blue", "gray", "amber"]).optional(),
size: z.enum(["sm", "md"]).optional(),
}),
component: null,
}),
defineComponent({
name: "Table",
description: "Data table",
group: "Content",
props: z.object({
columns: z.array(z.string()),
rows: z.array(z.array(z.unknown())),
compact: z.boolean().optional(),
}),
component: null,
}),
defineComponent({
name: "Code",
description: "Code block",
group: "Content",
props: z.object({
children: z.string(),
language: z.string().optional(),
showCopyButton: z.boolean().optional(),
}),
component: null,
}),
defineComponent({
name: "Image",
description: "Displays an image",
group: "Content",
props: z.object({
src: z.string(),
alt: z.string().optional(),
width: z.string().optional(),
height: z.string().optional(),
}),
component: null,
}),
defineComponent({
name: "Link",
description: "Hyperlink",
group: "Content",
props: z.object({
children: z.string(),
href: z.string(),
external: z.boolean().optional(),
}),
component: null,
}),
defineComponent({
name: "List",
description: "Ordered or unordered list",
group: "Content",
props: z.object({
items: z.array(z.string()),
ordered: z.boolean().optional(),
}),
component: null,
}),
defineComponent({
name: "Button",
description: "Interactive button",
group: "Interactive",
props: z.object({
children: z.string(),
main: z.boolean().optional(),
action: z.boolean().optional(),
danger: z.boolean().optional(),
primary: z.boolean().optional(),
secondary: z.boolean().optional(),
tertiary: z.boolean().optional(),
size: z.enum(["lg", "md"]).optional(),
actionId: z.string().optional(),
disabled: z.boolean().optional(),
}),
component: null,
}),
defineComponent({
name: "IconButton",
description: "Icon button with tooltip",
group: "Interactive",
props: z.object({
icon: z.string(),
tooltip: z.string().optional(),
main: z.boolean().optional(),
action: z.boolean().optional(),
danger: z.boolean().optional(),
primary: z.boolean().optional(),
secondary: z.boolean().optional(),
actionId: z.string().optional(),
disabled: z.boolean().optional(),
}),
component: null,
}),
defineComponent({
name: "Input",
description: "Text input field",
group: "Interactive",
props: z.object({
placeholder: z.string().optional(),
value: z.string().optional(),
actionId: z.string().optional(),
readOnly: z.boolean().optional(),
}),
component: null,
}),
defineComponent({
name: "Alert",
description: "Status message banner",
group: "Feedback",
props: z.object({
text: z.string(),
description: z.string().optional(),
level: z
.enum(["default", "info", "success", "warning", "error"])
.optional(),
showIcon: z.boolean().optional(),
}),
component: null,
}),
];
it("registers all 16 components without errors", () => {
expect(() => createLibrary(components)).not.toThrow();
});
it("creates a library with exactly 16 components", () => {
const lib = createLibrary(components);
expect(lib.components.size).toBe(16);
});
it("resolves every component by name", () => {
const lib = createLibrary(components);
const names = [
"Stack",
"Row",
"Column",
"Card",
"Divider",
"Text",
"Tag",
"Table",
"Code",
"Image",
"Link",
"List",
"Button",
"IconButton",
"Input",
"Alert",
];
for (const name of names) {
expect(lib.resolve(name)).toBeDefined();
}
});
it("generates param map for all components", () => {
const lib = createLibrary(components);
const paramMap = lib.paramMap();
expect(paramMap.size).toBe(16);
// Verify a few specific param orderings
const textParams = paramMap.get("Text")!;
expect(textParams[0]!.name).toBe("children");
expect(textParams[0]!.required).toBe(true);
const buttonParams = paramMap.get("Button")!;
expect(buttonParams[0]!.name).toBe("children");
expect(buttonParams.find((p) => p.name === "actionId")).toBeDefined();
const tagParams = paramMap.get("Tag")!;
expect(tagParams[0]!.name).toBe("title");
expect(tagParams[0]!.required).toBe(true);
});
it("generates a prompt containing all component signatures", () => {
const lib = createLibrary(components);
const prompt = lib.prompt();
// Every component name should appear
for (const [name] of lib.components) {
expect(prompt).toContain(name);
}
// Should contain group headers
expect(prompt).toContain("Layout");
expect(prompt).toContain("Content");
expect(prompt).toContain("Interactive");
expect(prompt).toContain("Feedback");
// Should have syntax section
expect(prompt).toContain("Syntax");
expect(prompt).toContain("Streaming");
});
it("generates a prompt with correct Button signature", () => {
const lib = createLibrary(components);
const prompt = lib.prompt();
// Button should show its required `children` param and optional params
expect(prompt).toContain("Button(children: string");
expect(prompt).toContain("actionId?");
});
it("parses a complex GenUI input using the full library", () => {
const lib = createLibrary(components);
const input = `heading = Text("Dashboard", headingH1: true)
status = Alert("All systems operational", level: "success")
row1 = ["API Server", Tag("Running", color: "green"), "99.9%"]
row2 = ["Database", Tag("Running", color: "green"), "99.8%"]
row3 = ["Cache", Tag("Warning", color: "amber"), "95.2%"]
table = Table(["Service", "Status", "Uptime"], [row1, row2, row3])
actions = Row([
Button("Refresh", main: true, primary: true, actionId: "refresh"),
Button("Settings", action: true, secondary: true, actionId: "settings")
], gap: "sm")
divider = Divider(spacing: "md")
code = Code("curl https://api.example.com/health", language: "bash")
root = Stack([heading, status, table, divider, actions, code], gap: "md")`;
const result = parse(input, lib);
expect(result.root).not.toBeNull();
expect(result.statements.length).toBeGreaterThanOrEqual(9);
// Should have no critical errors (unknown components, etc.)
const criticalErrors = result.errors.filter(
(e: { message: string }) => !e.message.includes("Unknown")
);
expect(criticalErrors).toHaveLength(0);
});
});

View File

@@ -1,42 +0,0 @@
import { describe, it, expect } from "vitest";
import { autoClose } from "./autoclose";
describe("autoClose", () => {
it("closes unmatched parentheses", () => {
expect(autoClose('Button("hello"')).toBe('Button("hello")');
});
it("closes unmatched brackets", () => {
expect(autoClose('["a", "b"')).toBe('["a", "b"]');
});
it("closes unmatched braces", () => {
expect(autoClose('{name: "test"')).toBe('{name: "test"}');
});
it("closes unmatched strings", () => {
expect(autoClose('"hello')).toBe('"hello"');
});
it("closes nested brackets", () => {
expect(autoClose("Foo([1, 2")).toBe("Foo([1, 2])");
});
it("handles already closed input", () => {
expect(autoClose('Button("ok")')).toBe('Button("ok")');
});
it("handles empty input", () => {
expect(autoClose("")).toBe("");
});
it("handles escaped quotes inside strings", () => {
expect(autoClose('"hello \\"world')).toBe('"hello \\"world"');
});
it("handles deeply nested structures", () => {
expect(autoClose('Stack([Row([Text("hi"')).toBe(
'Stack([Row([Text("hi")])])'
);
});
});

View File

@@ -1,63 +0,0 @@
/**
* Auto-close unmatched brackets and strings for streaming partial input.
*
* When the LLM is mid-stream, the last line may be incomplete — e.g. an
* unclosed `(`, `[`, `{`, or string. We append the matching closers so the
* parser can produce a valid (partial) tree from what we have so far.
*/
export function autoClose(input: string): string {
const closers: string[] = [];
let inString: string | null = null;
let escaped = false;
for (let i = 0; i < input.length; i++) {
const ch = input[i]!;
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (inString !== null) {
if (ch === inString) {
inString = null;
closers.pop(); // remove the string closer
}
continue;
}
if (ch === '"' || ch === "'") {
inString = ch;
closers.push(ch);
continue;
}
switch (ch) {
case "(":
closers.push(")");
break;
case "[":
closers.push("]");
break;
case "{":
closers.push("}");
break;
case ")":
case "]":
case "}":
// Pop the matching opener if present
if (closers.length > 0 && closers[closers.length - 1] === ch) {
closers.pop();
}
break;
}
}
// Append closers in reverse order
return input + closers.reverse().join("");
}

View File

@@ -1,542 +0,0 @@
import { describe, it, expect } from "vitest";
import { Tokenizer } from "./tokenizer";
import { Parser } from "./parser";
import { TokenType } from "../types";
// ── Helpers ──
function tokenize(input: string) {
return new Tokenizer(input).tokenize();
}
function tokenTypes(input: string): TokenType[] {
return tokenize(input).map((t) => t.type);
}
function tokenValues(input: string): string[] {
return tokenize(input).map((t) => t.value);
}
function parseStatements(input: string) {
return Parser.fromSource(input).parse();
}
// ────────────────────────────────────────────────────────────
// Tokenizer edge cases
// ────────────────────────────────────────────────────────────
describe("Tokenizer edge cases", () => {
it("handles empty string", () => {
const tokens = tokenize("");
expect(tokens).toHaveLength(1);
expect(tokens[0]!.type).toBe(TokenType.EOF);
});
it("handles only whitespace (spaces and tabs)", () => {
const tokens = tokenize(" \t\t ");
expect(tokens).toHaveLength(1);
expect(tokens[0]!.type).toBe(TokenType.EOF);
});
it("handles only newlines", () => {
const types = tokenTypes("\n\n\n");
// Each newline at bracket depth 0 produces a Newline token
expect(types.filter((t) => t === TokenType.Newline).length).toBe(3);
expect(types[types.length - 1]).toBe(TokenType.EOF);
});
it("handles unicode in string literals (emoji)", () => {
const tokens = tokenize('"hello \u{1F680}\u{1F525}"');
const str = tokens.find((t) => t.type === TokenType.String);
expect(str).toBeDefined();
expect(str!.value).toBe("hello \u{1F680}\u{1F525}");
});
it("handles unicode in string literals (CJK characters)", () => {
const tokens = tokenize('"\u4F60\u597D\u4E16\u754C"');
const str = tokens.find((t) => t.type === TokenType.String);
expect(str!.value).toBe("\u4F60\u597D\u4E16\u754C");
});
it("handles very long string literals (1000+ chars)", () => {
const longContent = "a".repeat(2000);
const tokens = tokenize(`"${longContent}"`);
const str = tokens.find((t) => t.type === TokenType.String);
expect(str!.value).toBe(longContent);
expect(str!.value.length).toBe(2000);
});
it("handles deeply nested brackets (10+ levels)", () => {
const open = "(".repeat(15);
const close = ")".repeat(15);
const input = `Foo${open}${close}`;
const tokens = tokenize(input);
const lParens = tokens.filter((t) => t.type === TokenType.LParen);
const rParens = tokens.filter((t) => t.type === TokenType.RParen);
expect(lParens).toHaveLength(15);
expect(rParens).toHaveLength(15);
});
it("suppresses newlines inside brackets", () => {
const input = '(\n\n"hello"\n\n)';
const types = tokenTypes(input);
// Newlines inside brackets should be suppressed
expect(types).not.toContain(TokenType.Newline);
expect(types).toContain(TokenType.LParen);
expect(types).toContain(TokenType.String);
expect(types).toContain(TokenType.RParen);
});
it("handles single-quoted strings", () => {
const tokens = tokenize("'hello world'");
const str = tokens.find((t) => t.type === TokenType.String);
expect(str!.value).toBe("hello world");
});
it("handles double-quoted strings", () => {
const tokens = tokenize('"hello world"');
const str = tokens.find((t) => t.type === TokenType.String);
expect(str!.value).toBe("hello world");
});
it("handles single quotes inside double-quoted strings without escaping", () => {
const tokens = tokenize('"it\'s fine"');
// The \' escape yields a literal '
const str = tokens.find((t) => t.type === TokenType.String);
expect(str!.value).toBe("it's fine");
});
it("handles negative decimals (-3.14)", () => {
const tokens = tokenize("-3.14");
const num = tokens.find((t) => t.type === TokenType.Number);
expect(num).toBeDefined();
expect(num!.value).toBe("-3.14");
});
it("handles negative integers", () => {
const tokens = tokenize("-42");
const num = tokens.find((t) => t.type === TokenType.Number);
expect(num!.value).toBe("-42");
});
it("handles multiple consecutive comments", () => {
const input = "// comment 1\n// comment 2\n// comment 3\nx";
const tokens = tokenize(input);
// Comments are skipped; we should get newlines and the identifier
const identifiers = tokens.filter((t) => t.type === TokenType.Identifier);
expect(identifiers).toHaveLength(1);
expect(identifiers[0]!.value).toBe("x");
});
it("handles comment at end of file (no trailing newline)", () => {
const input = "x = 1\n// trailing comment";
const tokens = tokenize(input);
// Should not crash, last token is EOF
expect(tokens[tokens.length - 1]!.type).toBe(TokenType.EOF);
// The identifier and number should be present
expect(
tokens.some((t) => t.type === TokenType.Identifier && t.value === "x")
).toBe(true);
expect(
tokens.some((t) => t.type === TokenType.Number && t.value === "1")
).toBe(true);
});
it("handles all escape sequences in strings", () => {
const input = '"\\n\\t\\\\\\"\\\'"';
const tokens = tokenize(input);
const str = tokens.find((t) => t.type === TokenType.String);
expect(str!.value).toBe("\n\t\\\"'");
});
it("handles unknown escape sequences by preserving the escaped char", () => {
const tokens = tokenize('"\\x"');
const str = tokens.find((t) => t.type === TokenType.String);
expect(str!.value).toBe("x");
});
it("handles unterminated string (EOF inside string)", () => {
// Should not throw; tokenizer consumes until EOF
const tokens = tokenize('"unterminated');
const str = tokens.find((t) => t.type === TokenType.String);
expect(str).toBeDefined();
expect(str!.value).toBe("unterminated");
});
it("handles bracket depth never going below zero on unmatched closing brackets", () => {
// Extra closing parens should not crash
const tokens = tokenize(")))]]]");
expect(tokens[tokens.length - 1]!.type).toBe(TokenType.EOF);
});
it("skips unknown characters silently", () => {
const tokens = tokenize("@ # $ %");
// All unknown chars are skipped, only EOF remains
expect(tokens).toHaveLength(1);
expect(tokens[0]!.type).toBe(TokenType.EOF);
});
it("tracks line and column correctly across newlines", () => {
const tokens = tokenize("x\ny");
const x = tokens.find((t) => t.value === "x");
const y = tokens.find((t) => t.value === "y");
expect(x!.line).toBe(1);
expect(x!.column).toBe(1);
expect(y!.line).toBe(2);
expect(y!.column).toBe(1);
});
it("treats identifier starting with underscore as valid", () => {
const tokens = tokenize("_foo _bar123");
const idents = tokens.filter((t) => t.type === TokenType.Identifier);
expect(idents).toHaveLength(2);
expect(idents[0]!.value).toBe("_foo");
expect(idents[1]!.value).toBe("_bar123");
});
it("tokenizes number with trailing dot as number then unknown", () => {
// "42." => number "42." (reads the dot as part of decimal), then EOF
const tokens = tokenize("42.");
const num = tokens.find((t) => t.type === TokenType.Number);
expect(num!.value).toBe("42.");
});
});
// ────────────────────────────────────────────────────────────
// Parser edge cases
// ────────────────────────────────────────────────────────────
describe("Parser edge cases", () => {
it("handles empty input", () => {
const { statements, errors } = parseStatements("");
expect(statements).toHaveLength(0);
expect(errors).toHaveLength(0);
});
it("handles single identifier with no assignment (error recovery)", () => {
const { statements, errors } = parseStatements("foo");
// Should produce an error because it expects `=` after identifier
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]!.message).toContain("Expected Equals");
});
it("handles assignment with no value (error recovery)", () => {
const { statements, errors } = parseStatements("x =");
// Should produce an error because there's no expression after `=`
expect(errors.length).toBeGreaterThan(0);
});
it("parses component with 0 args: Foo()", () => {
const { statements, errors } = parseStatements("x = Foo()");
expect(errors).toHaveLength(0);
expect(statements).toHaveLength(1);
const node = statements[0]!.value;
expect(node.kind).toBe("component");
if (node.kind === "component") {
expect(node.name).toBe("Foo");
expect(node.args).toHaveLength(0);
}
});
it("parses component with only named args", () => {
const { statements, errors } = parseStatements("x = Foo(a: 1, b: 2)");
expect(errors).toHaveLength(0);
const node = statements[0]!.value;
if (node.kind === "component") {
expect(node.args).toHaveLength(2);
expect(node.args[0]!.key).toBe("a");
expect(node.args[0]!.value).toEqual({ kind: "literal", value: 1 });
expect(node.args[1]!.key).toBe("b");
expect(node.args[1]!.value).toEqual({ kind: "literal", value: 2 });
}
});
it("parses deeply nested components", () => {
const { statements, errors } = parseStatements('x = A(B(C(D("deep"))))');
expect(errors).toHaveLength(0);
const a = statements[0]!.value;
expect(a.kind).toBe("component");
if (a.kind === "component") {
expect(a.name).toBe("A");
const b = a.args[0]!.value;
expect(b.kind).toBe("component");
if (b.kind === "component") {
expect(b.name).toBe("B");
const c = b.args[0]!.value;
expect(c.kind).toBe("component");
if (c.kind === "component") {
expect(c.name).toBe("C");
const d = c.args[0]!.value;
expect(d.kind).toBe("component");
if (d.kind === "component") {
expect(d.name).toBe("D");
expect(d.args[0]!.value).toEqual({
kind: "literal",
value: "deep",
});
}
}
}
}
});
it("parses array of arrays", () => {
const { statements, errors } = parseStatements("x = [[1, 2], [3, 4]]");
expect(errors).toHaveLength(0);
const node = statements[0]!.value;
expect(node.kind).toBe("array");
if (node.kind === "array") {
expect(node.elements).toHaveLength(2);
const first = node.elements[0]!;
expect(first.kind).toBe("array");
if (first.kind === "array") {
expect(first.elements).toEqual([
{ kind: "literal", value: 1 },
{ kind: "literal", value: 2 },
]);
}
const second = node.elements[1]!;
if (second.kind === "array") {
expect(second.elements).toEqual([
{ kind: "literal", value: 3 },
{ kind: "literal", value: 4 },
]);
}
}
});
it("parses object with string keys (including spaces)", () => {
const { statements, errors } = parseStatements(
'x = {"key with spaces": 1, "another key": 2}'
);
expect(errors).toHaveLength(0);
const node = statements[0]!.value;
expect(node.kind).toBe("object");
if (node.kind === "object") {
expect(node.entries).toHaveLength(2);
expect(node.entries[0]!.key).toBe("key with spaces");
expect(node.entries[0]!.value).toEqual({ kind: "literal", value: 1 });
expect(node.entries[1]!.key).toBe("another key");
}
});
it("handles trailing newlines gracefully", () => {
const { statements, errors } = parseStatements('x = "hello"\n\n\n');
expect(errors).toHaveLength(0);
expect(statements).toHaveLength(1);
});
it("handles leading newlines gracefully", () => {
const { statements, errors } = parseStatements('\n\n\nx = "hello"');
expect(errors).toHaveLength(0);
expect(statements).toHaveLength(1);
expect(statements[0]!.name).toBe("x");
});
it("handles multiple empty lines between statements", () => {
const { statements, errors } = parseStatements("x = 1\n\n\n\n\ny = 2");
expect(errors).toHaveLength(0);
expect(statements).toHaveLength(2);
expect(statements[0]!.name).toBe("x");
expect(statements[1]!.name).toBe("y");
});
it("treats PascalCase identifiers as components, not keywords: True", () => {
// `True` is PascalCase, so it should be parsed as a component call (not boolean)
// when followed by parens
const { statements, errors } = parseStatements("x = True()");
expect(errors).toHaveLength(0);
const node = statements[0]!.value;
expect(node.kind).toBe("component");
if (node.kind === "component") {
expect(node.name).toBe("True");
}
});
it("treats PascalCase identifiers as components: Null", () => {
const { statements, errors } = parseStatements("x = Null()");
expect(errors).toHaveLength(0);
const node = statements[0]!.value;
expect(node.kind).toBe("component");
if (node.kind === "component") {
expect(node.name).toBe("Null");
}
});
it("treats lowercase 'true' as boolean literal, not reference", () => {
const { statements } = parseStatements("x = true");
expect(statements[0]!.value).toEqual({ kind: "literal", value: true });
});
it("treats lowercase 'null' as null literal, not reference", () => {
const { statements } = parseStatements("x = null");
expect(statements[0]!.value).toEqual({ kind: "literal", value: null });
});
it("handles very long identifier names", () => {
const longName = "a".repeat(500);
const { statements, errors } = parseStatements(`${longName} = 42`);
expect(errors).toHaveLength(0);
expect(statements[0]!.name).toBe(longName);
});
it("parses mixed named and positional args", () => {
const { statements, errors } = parseStatements(
'x = Foo("pos", named: "val", "pos2")'
);
expect(errors).toHaveLength(0);
const node = statements[0]!.value;
if (node.kind === "component") {
expect(node.args).toHaveLength(3);
// First: positional
expect(node.args[0]!.key).toBeNull();
expect(node.args[0]!.value).toEqual({ kind: "literal", value: "pos" });
// Second: named
expect(node.args[1]!.key).toBe("named");
expect(node.args[1]!.value).toEqual({ kind: "literal", value: "val" });
// Third: positional
expect(node.args[2]!.key).toBeNull();
expect(node.args[2]!.value).toEqual({ kind: "literal", value: "pos2" });
}
});
it("handles trailing comma in component args", () => {
const { statements, errors } = parseStatements("x = Foo(1, 2,)");
expect(errors).toHaveLength(0);
const node = statements[0]!.value;
if (node.kind === "component") {
expect(node.args).toHaveLength(2);
}
});
it("handles trailing comma in arrays", () => {
const { statements, errors } = parseStatements("x = [1, 2, 3,]");
expect(errors).toHaveLength(0);
const node = statements[0]!.value;
if (node.kind === "array") {
expect(node.elements).toHaveLength(3);
}
});
it("handles trailing comma in objects", () => {
const { statements, errors } = parseStatements("x = {a: 1, b: 2,}");
expect(errors).toHaveLength(0);
const node = statements[0]!.value;
if (node.kind === "object") {
expect(node.entries).toHaveLength(2);
}
});
it("recovers from error and parses subsequent statements", () => {
const { statements, errors } = parseStatements("bad\ny = 42");
// First statement is invalid (no `=`), second is valid
expect(errors.length).toBeGreaterThan(0);
expect(statements).toHaveLength(1);
expect(statements[0]!.name).toBe("y");
expect(statements[0]!.value).toEqual({ kind: "literal", value: 42 });
});
it("parses camelCase identifier as reference", () => {
const { statements, errors } = parseStatements("x = myRef");
expect(errors).toHaveLength(0);
const node = statements[0]!.value;
expect(node.kind).toBe("reference");
if (node.kind === "reference") {
expect(node.name).toBe("myRef");
}
});
it("parses PascalCase identifier without parens as reference", () => {
// PascalCase but no `(` following => treated as a reference, not component
const { statements, errors } = parseStatements("x = MyComponent");
expect(errors).toHaveLength(0);
const node = statements[0]!.value;
expect(node.kind).toBe("reference");
if (node.kind === "reference") {
expect(node.name).toBe("MyComponent");
}
});
it("parses empty array", () => {
const { statements, errors } = parseStatements("x = []");
expect(errors).toHaveLength(0);
const node = statements[0]!.value;
expect(node.kind).toBe("array");
if (node.kind === "array") {
expect(node.elements).toHaveLength(0);
}
});
it("parses empty object", () => {
const { statements, errors } = parseStatements("x = {}");
expect(errors).toHaveLength(0);
const node = statements[0]!.value;
expect(node.kind).toBe("object");
if (node.kind === "object") {
expect(node.entries).toHaveLength(0);
}
});
it("parses component as named arg value", () => {
const { statements, errors } = parseStatements(
'x = Layout(header: Header("Title"))'
);
expect(errors).toHaveLength(0);
const node = statements[0]!.value;
if (node.kind === "component") {
expect(node.name).toBe("Layout");
expect(node.args[0]!.key).toBe("header");
const headerVal = node.args[0]!.value;
expect(headerVal.kind).toBe("component");
if (headerVal.kind === "component") {
expect(headerVal.name).toBe("Header");
}
}
});
it("parses negative number in expression position", () => {
const { statements, errors } = parseStatements("x = -3.14");
expect(errors).toHaveLength(0);
expect(statements[0]!.value).toEqual({ kind: "literal", value: -3.14 });
});
it("parses component with array arg", () => {
const { statements, errors } = parseStatements(
"x = List(items: [1, 2, 3])"
);
expect(errors).toHaveLength(0);
const node = statements[0]!.value;
if (node.kind === "component") {
expect(node.args[0]!.key).toBe("items");
expect(node.args[0]!.value.kind).toBe("array");
}
});
it("handles comments between statements", () => {
const input = "x = 1\n// comment\ny = 2";
const { statements, errors } = parseStatements(input);
expect(errors).toHaveLength(0);
expect(statements).toHaveLength(2);
});
it("handles comment on the same line as a statement", () => {
// The comment eats everything after //, so `x = 1` is before the comment on a new line
const input = "// header comment\nx = 1";
const { statements, errors } = parseStatements(input);
expect(errors).toHaveLength(0);
expect(statements).toHaveLength(1);
expect(statements[0]!.name).toBe("x");
});
it("parses object with mixed identifier and string keys", () => {
const { statements, errors } = parseStatements(
'x = {name: "Alice", "full name": "Alice B"}'
);
expect(errors).toHaveLength(0);
const node = statements[0]!.value;
if (node.kind === "object") {
expect(node.entries[0]!.key).toBe("name");
expect(node.entries[1]!.key).toBe("full name");
}
});
});

View File

@@ -1,7 +0,0 @@
export { Tokenizer } from "./tokenizer";
export { Parser } from "./parser";
export { autoClose } from "./autoclose";
export { resolveReferences } from "./resolver";
export { validateAndTransform } from "./validator";
export { createStreamingParser } from "./streaming";
export type { StreamParser } from "./streaming";

View File

@@ -1,132 +0,0 @@
import { describe, it, expect } from "vitest";
import { Parser } from "./parser";
describe("Parser", () => {
function parseStatements(input: string) {
const parser = Parser.fromSource(input);
return parser.parse();
}
it("parses a simple literal assignment", () => {
const { statements, errors } = parseStatements('x = "hello"');
expect(errors).toHaveLength(0);
expect(statements).toHaveLength(1);
expect(statements[0]!.name).toBe("x");
expect(statements[0]!.value).toEqual({ kind: "literal", value: "hello" });
});
it("parses number literals", () => {
const { statements } = parseStatements("n = 42");
expect(statements[0]!.value).toEqual({ kind: "literal", value: 42 });
});
it("parses boolean literals", () => {
const { statements } = parseStatements("b = true");
expect(statements[0]!.value).toEqual({ kind: "literal", value: true });
});
it("parses null", () => {
const { statements } = parseStatements("x = null");
expect(statements[0]!.value).toEqual({ kind: "literal", value: null });
});
it("parses a component call with positional args", () => {
const { statements, errors } = parseStatements('btn = Button("Click me")');
expect(errors).toHaveLength(0);
expect(statements[0]!.value).toEqual({
kind: "component",
name: "Button",
args: [{ key: null, value: { kind: "literal", value: "Click me" } }],
});
});
it("parses a component call with named args", () => {
const { statements } = parseStatements('t = Tag("PDF", color: "blue")');
const comp = statements[0]!.value;
expect(comp.kind).toBe("component");
if (comp.kind === "component") {
expect(comp.args).toHaveLength(2);
expect(comp.args[0]!.key).toBeNull();
expect(comp.args[1]!.key).toBe("color");
}
});
it("parses nested components", () => {
const { statements, errors } = parseStatements(
'row = Row([Button("A"), Button("B")])'
);
expect(errors).toHaveLength(0);
const comp = statements[0]!.value;
expect(comp.kind).toBe("component");
});
it("parses arrays", () => {
const { statements } = parseStatements('items = ["a", "b", "c"]');
expect(statements[0]!.value).toEqual({
kind: "array",
elements: [
{ kind: "literal", value: "a" },
{ kind: "literal", value: "b" },
{ kind: "literal", value: "c" },
],
});
});
it("parses objects", () => {
const { statements } = parseStatements('opts = {name: "test", count: 5}');
expect(statements[0]!.value).toEqual({
kind: "object",
entries: [
{ key: "name", value: { kind: "literal", value: "test" } },
{ key: "count", value: { kind: "literal", value: 5 } },
],
});
});
it("parses variable references", () => {
const { statements } = parseStatements("ref = myVar");
expect(statements[0]!.value).toEqual({ kind: "reference", name: "myVar" });
});
it("parses multiple statements", () => {
const { statements, errors } = parseStatements(
'title = Text("Hello")\nbtn = Button("Click")'
);
expect(errors).toHaveLength(0);
expect(statements).toHaveLength(2);
expect(statements[0]!.name).toBe("title");
expect(statements[1]!.name).toBe("btn");
});
it("handles trailing commas", () => {
const { statements, errors } = parseStatements('x = Button("a", "b",)');
expect(errors).toHaveLength(0);
const comp = statements[0]!.value;
if (comp.kind === "component") {
expect(comp.args).toHaveLength(2);
}
});
it("recovers from parse errors", () => {
const { statements, errors } = parseStatements(
'!!invalid!!\ny = Text("valid")'
);
expect(errors.length).toBeGreaterThan(0);
// Should still parse the valid second line
expect(statements.length).toBeGreaterThanOrEqual(1);
});
it("parses the full example from the spec", () => {
const input = `title = Text("Search Results", headingH2: true)
row1 = ["Onyx Docs", Tag("PDF", color: "blue"), "2024-01-15"]
row2 = ["API Guide", Tag("MD", color: "green"), "2024-02-01"]
results = Table(["Name", "Type", "Date"], [row1, row2])
action = Button("View All", main: true, primary: true, actionId: "viewAll")
root = Stack([title, results, action], gap: "md")`;
const { statements, errors } = parseStatements(input);
expect(errors).toHaveLength(0);
expect(statements).toHaveLength(6);
expect(statements[5]!.name).toBe("root");
});
});

View File

@@ -1,305 +0,0 @@
import type {
ASTNode,
ArgumentNode,
Statement,
ParseError,
Token,
} from "../types";
import { TokenType } from "../types";
import { Tokenizer } from "./tokenizer";
/**
* Recursive descent parser for GenUI Lang.
*
* Grammar:
* program = statement*
* statement = identifier "=" expression NEWLINE
* expression = component | array | object | literal | reference
* component = PascalCase "(" arglist? ")"
* arglist = arg ("," arg)*
* arg = namedArg | expression
* namedArg = identifier ":" expression
* array = "[" (expression ("," expression)*)? "]"
* object = "{" (pair ("," pair)*)? "}"
* pair = (identifier | string) ":" expression
* literal = string | number | boolean | null
* reference = camelCase identifier (doesn't start with uppercase)
*/
export class Parser {
private tokens: Token[];
private pos = 0;
private errors: ParseError[] = [];
constructor(tokens: Token[]) {
this.tokens = tokens;
}
static fromSource(source: string): Parser {
const tokenizer = new Tokenizer(source);
return new Parser(tokenizer.tokenize());
}
parse(): { statements: Statement[]; errors: ParseError[] } {
const statements: Statement[] = [];
this.skipNewlines();
while (!this.isAtEnd()) {
try {
const stmt = this.parseStatement();
if (stmt) {
statements.push(stmt);
}
} catch (e) {
if (e instanceof ParseErrorException) {
this.errors.push(e.toParseError());
}
// Skip to next line to recover
this.skipToNextStatement();
}
this.skipNewlines();
}
return { statements, errors: this.errors };
}
private parseStatement(): Statement | null {
if (this.isAtEnd()) return null;
const ident = this.expect(TokenType.Identifier);
this.expect(TokenType.Equals);
const value = this.parseExpression();
return { name: ident.value, value };
}
private parseExpression(): ASTNode {
const token = this.current();
if (token.type === TokenType.LBracket) {
return this.parseArray();
}
if (token.type === TokenType.LBrace) {
return this.parseObject();
}
if (token.type === TokenType.String) {
this.advance();
return { kind: "literal", value: token.value };
}
if (token.type === TokenType.Number) {
this.advance();
return { kind: "literal", value: Number(token.value) };
}
if (token.type === TokenType.Boolean) {
this.advance();
return { kind: "literal", value: token.value === "true" };
}
if (token.type === TokenType.Null) {
this.advance();
return { kind: "literal", value: null };
}
if (token.type === TokenType.Identifier) {
const isPascalCase = /^[A-Z]/.test(token.value);
if (isPascalCase && this.peek()?.type === TokenType.LParen) {
return this.parseComponent();
}
// camelCase identifier = variable reference
this.advance();
return { kind: "reference", name: token.value };
}
throw this.error(`Unexpected token: ${token.type} "${token.value}"`);
}
private parseComponent(): ASTNode {
const name = this.expect(TokenType.Identifier);
this.expect(TokenType.LParen);
const args: ArgumentNode[] = [];
if (this.current().type !== TokenType.RParen) {
args.push(this.parseArg());
while (this.current().type === TokenType.Comma) {
this.advance(); // skip comma
if (this.current().type === TokenType.RParen) break; // trailing comma
args.push(this.parseArg());
}
}
this.expect(TokenType.RParen);
return { kind: "component", name: name.value, args };
}
private parseArg(): ArgumentNode {
// Look ahead: if we see `identifier ":"`, it's a named arg
if (
this.current().type === TokenType.Identifier &&
this.peek()?.type === TokenType.Colon
) {
// But only if the identifier is NOT PascalCase (which would be a component)
const isPascalCase = /^[A-Z]/.test(this.current().value);
if (!isPascalCase) {
const key = this.current().value;
this.advance(); // identifier
this.advance(); // colon
const value = this.parseExpression();
return { key, value };
}
}
// Positional argument
const value = this.parseExpression();
return { key: null, value };
}
private parseArray(): ASTNode {
this.expect(TokenType.LBracket);
const elements: ASTNode[] = [];
if (this.current().type !== TokenType.RBracket) {
elements.push(this.parseExpression());
while (this.current().type === TokenType.Comma) {
this.advance();
if (this.current().type === TokenType.RBracket) break;
elements.push(this.parseExpression());
}
}
this.expect(TokenType.RBracket);
return { kind: "array", elements };
}
private parseObject(): ASTNode {
this.expect(TokenType.LBrace);
const entries: { key: string; value: ASTNode }[] = [];
if (this.current().type !== TokenType.RBrace) {
entries.push(this.parseObjectEntry());
while (this.current().type === TokenType.Comma) {
this.advance();
if (this.current().type === TokenType.RBrace) break;
entries.push(this.parseObjectEntry());
}
}
this.expect(TokenType.RBrace);
return { kind: "object", entries };
}
private parseObjectEntry(): { key: string; value: ASTNode } {
let key: string;
if (this.current().type === TokenType.String) {
key = this.current().value;
this.advance();
} else if (this.current().type === TokenType.Identifier) {
key = this.current().value;
this.advance();
} else {
throw this.error(`Expected object key, got ${this.current().type}`);
}
this.expect(TokenType.Colon);
const value = this.parseExpression();
return { key, value };
}
// ── Helpers ──
private current(): Token {
return (
this.tokens[this.pos] ?? {
type: TokenType.EOF,
value: "",
offset: -1,
line: -1,
column: -1,
}
);
}
private peek(): Token | undefined {
return this.tokens[this.pos + 1];
}
private advance(): Token {
const token = this.current();
if (this.pos < this.tokens.length) this.pos++;
return token;
}
private expect(type: TokenType): Token {
const token = this.current();
if (token.type !== type) {
throw this.error(`Expected ${type}, got ${token.type} "${token.value}"`);
}
this.advance();
return token;
}
private isAtEnd(): boolean {
return this.current().type === TokenType.EOF;
}
private skipNewlines(): void {
while (this.current().type === TokenType.Newline) {
this.advance();
}
}
private skipToNextStatement(): void {
while (!this.isAtEnd() && this.current().type !== TokenType.Newline) {
this.advance();
}
this.skipNewlines();
}
private error(message: string): ParseErrorException {
const token = this.current();
return new ParseErrorException(
message,
token.line,
token.column,
token.offset
);
}
}
class ParseErrorException extends Error {
line: number;
column: number;
offset: number;
constructor(message: string, line: number, column: number, offset: number) {
super(message);
this.line = line;
this.column = column;
this.offset = offset;
}
toParseError(): ParseError {
return {
message: this.message,
line: this.line,
column: this.column,
offset: this.offset,
};
}
}

View File

@@ -1,100 +0,0 @@
import { describe, it, expect } from "vitest";
import { resolveReferences } from "./resolver";
import type { Statement } from "../types";
describe("resolveReferences", () => {
it("resolves simple variable references", () => {
const statements: Statement[] = [
{ name: "a", value: { kind: "literal", value: "hello" } },
{ name: "b", value: { kind: "reference", name: "a" } },
];
const { resolved, errors } = resolveReferences(statements);
expect(errors).toHaveLength(0);
expect(resolved.get("b")).toEqual({ kind: "literal", value: "hello" });
});
it("resolves nested references in components", () => {
const statements: Statement[] = [
{ name: "label", value: { kind: "literal", value: "Click me" } },
{
name: "btn",
value: {
kind: "component",
name: "Button",
args: [{ key: null, value: { kind: "reference", name: "label" } }],
},
},
];
const { resolved, errors } = resolveReferences(statements);
expect(errors).toHaveLength(0);
const btn = resolved.get("btn");
expect(btn?.kind).toBe("component");
if (btn?.kind === "component") {
expect(btn.args[0]!.value).toEqual({
kind: "literal",
value: "Click me",
});
}
});
it("detects circular references", () => {
const statements: Statement[] = [
{ name: "a", value: { kind: "reference", name: "b" } },
{ name: "b", value: { kind: "reference", name: "a" } },
];
const { errors } = resolveReferences(statements);
expect(errors.some((e) => e.message.includes("Circular"))).toBe(true);
});
it("leaves unknown references as-is", () => {
const statements: Statement[] = [
{ name: "x", value: { kind: "reference", name: "unknown" } },
];
const { resolved, errors } = resolveReferences(statements);
expect(errors).toHaveLength(0);
expect(resolved.get("x")).toEqual({ kind: "reference", name: "unknown" });
});
it("uses last statement as root by default", () => {
const statements: Statement[] = [
{ name: "a", value: { kind: "literal", value: 1 } },
{ name: "b", value: { kind: "literal", value: 2 } },
];
const { root } = resolveReferences(statements);
expect(root).toEqual({ kind: "literal", value: 2 });
});
it("uses statement named 'root' as root", () => {
const statements: Statement[] = [
{ name: "root", value: { kind: "literal", value: "I am root" } },
{ name: "other", value: { kind: "literal", value: "not root" } },
];
const { root } = resolveReferences(statements);
expect(root).toEqual({ kind: "literal", value: "I am root" });
});
it("resolves references in arrays", () => {
const statements: Statement[] = [
{ name: "item", value: { kind: "literal", value: "hello" } },
{
name: "list",
value: {
kind: "array",
elements: [{ kind: "reference", name: "item" }],
},
},
];
const { resolved } = resolveReferences(statements);
const list = resolved.get("list");
if (list?.kind === "array") {
expect(list.elements[0]).toEqual({ kind: "literal", value: "hello" });
}
});
});

View File

@@ -1,135 +0,0 @@
import type { ASTNode, Statement, ParseError } from "../types";
/**
* Resolve variable references in the AST.
*
* Each statement defines `name = expression`. Later expressions can reference
* earlier variable names. This pass replaces ReferenceNodes with the actual
* subtree they point to, detecting cycles.
*/
export function resolveReferences(statements: Statement[]): {
resolved: Map<string, ASTNode>;
root: ASTNode | null;
errors: ParseError[];
} {
const definitions = new Map<string, ASTNode>();
const resolved = new Map<string, ASTNode>();
const errors: ParseError[] = [];
// Build definition map
for (const stmt of statements) {
definitions.set(stmt.name, stmt.value);
}
// Resolve each statement
for (const stmt of statements) {
const resolving = new Set<string>();
const result = resolveNode(
stmt.value,
definitions,
resolved,
resolving,
errors
);
resolved.set(stmt.name, result);
}
// Root is the last statement or the one named "root"
let root: ASTNode | null = null;
if (resolved.has("root")) {
root = resolved.get("root")!;
} else if (statements.length > 0) {
const lastStmt = statements[statements.length - 1]!;
root = resolved.get(lastStmt.name) ?? null;
}
return { resolved, root, errors };
}
function resolveNode(
node: ASTNode,
definitions: Map<string, ASTNode>,
resolved: Map<string, ASTNode>,
resolving: Set<string>,
errors: ParseError[]
): ASTNode {
switch (node.kind) {
case "reference": {
const { name } = node;
// Already resolved
if (resolved.has(name)) {
return resolved.get(name)!;
}
// Cycle detection
if (resolving.has(name)) {
errors.push({
message: `Circular reference detected: "${name}"`,
line: 0,
column: 0,
});
return { kind: "literal", value: null };
}
// Unknown reference — leave as-is (may be defined later in streaming)
const definition = definitions.get(name);
if (!definition) {
return node; // keep as unresolved reference
}
resolving.add(name);
const result = resolveNode(
definition,
definitions,
resolved,
resolving,
errors
);
resolving.delete(name);
resolved.set(name, result);
return result;
}
case "component":
return {
...node,
args: node.args.map((arg) => ({
...arg,
value: resolveNode(
arg.value,
definitions,
resolved,
resolving,
errors
),
})),
};
case "array":
return {
...node,
elements: node.elements.map((el) =>
resolveNode(el, definitions, resolved, resolving, errors)
),
};
case "object":
return {
...node,
entries: node.entries.map((entry) => ({
...entry,
value: resolveNode(
entry.value,
definitions,
resolved,
resolving,
errors
),
})),
};
case "literal":
return node;
}
}

View File

@@ -1,280 +0,0 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { createStreamingParser } from "./streaming";
import { createLibrary } from "../library";
import { defineComponent } from "../component";
import { autoClose } from "./autoclose";
function makeTestLibrary() {
return createLibrary([
defineComponent({
name: "Text",
description: "Displays text",
props: z.object({ children: z.string() }),
component: null,
}),
defineComponent({
name: "Button",
description: "Clickable button",
props: z.object({
children: z.string(),
main: z.boolean().optional(),
actionId: z.string().optional(),
}),
component: null,
}),
defineComponent({
name: "Stack",
description: "Vertical stack layout",
props: z.object({
children: z.array(z.unknown()).optional(),
gap: z.string().optional(),
}),
component: null,
}),
]);
}
describe("Streaming edge cases", () => {
it("single character at a time streaming", () => {
const lib = makeTestLibrary();
const parser = createStreamingParser(lib);
const input = 'title = Text("Hello")\n';
let result;
for (const ch of input) {
result = parser.push(ch);
}
expect(result!.statements).toHaveLength(1);
expect(result!.statements[0]!.name).toBe("title");
expect(result!.root).not.toBeNull();
});
it("token split across chunks — component name", () => {
const lib = makeTestLibrary();
const parser = createStreamingParser(lib);
// "Text" split as "Tex" + "t"
parser.push("a = Tex");
const result = parser.push('t("hello")\n');
expect(result.statements).toHaveLength(1);
expect(result.statements[0]!.value).toMatchObject({
kind: "component",
name: "Text",
});
});
it("string split mid-escape sequence", () => {
const lib = makeTestLibrary();
const parser = createStreamingParser(lib);
// Split right before the escaped quote
parser.push('a = Text("hel');
const result = parser.push('lo \\"world\\"")\n');
expect(result.statements).toHaveLength(1);
expect(result.root).not.toBeNull();
});
it("multi-line component split across chunks", () => {
const lib = makeTestLibrary();
const parser = createStreamingParser(lib);
// The streaming parser splits on newlines, so multi-line expressions
// need to be on a single line or use variables. Test that a long
// single-line expression streamed in chunks works correctly.
parser.push('root = Stack([Text("line 1"), Text("line');
const result = parser.push(' 2")])\n');
expect(result.statements).toHaveLength(1);
expect(result.root).not.toBeNull();
});
it("empty chunks do not corrupt state", () => {
const lib = makeTestLibrary();
const parser = createStreamingParser(lib);
parser.push("");
parser.push('a = Text("hi")');
parser.push("");
parser.push("");
const result = parser.push("\n");
expect(result.statements).toHaveLength(1);
expect(result.statements[0]!.name).toBe("a");
});
it("very large single chunk with multiple complete statements", () => {
const lib = makeTestLibrary();
const parser = createStreamingParser(lib);
const lines =
Array.from({ length: 50 }, (_, i) => `v${i} = Text("item ${i}")`).join(
"\n"
) + "\n";
const result = parser.push(lines);
expect(result.statements).toHaveLength(50);
expect(result.root).not.toBeNull();
});
it("interleaved complete and partial lines", () => {
const lib = makeTestLibrary();
const parser = createStreamingParser(lib);
// Complete line followed by partial
parser.push('a = Text("done")\nb = Text("part');
let result = parser.result();
// "a" is cached complete, "b" is partial but auto-closed
expect(result.statements.length).toBeGreaterThanOrEqual(1);
// Now finish the partial and add another complete
result = parser.push('ial")\nc = Text("also done")\n');
expect(result.statements).toHaveLength(3);
expect(result.statements.map((s) => s.name)).toEqual(["a", "b", "c"]);
});
it("variable reference before definition — streaming order matters", () => {
const lib = makeTestLibrary();
const parser = createStreamingParser(lib);
// Reference "label" before it's defined
parser.push("root = Stack([label])\n");
let result = parser.result();
// At this point "label" is an unresolved reference — should not crash
expect(result.statements).toHaveLength(1);
expect(result.errors.length).toBeGreaterThanOrEqual(0);
// Now define it
result = parser.push('label = Text("Hi")\n');
// After defining, root should pick it up via resolution
expect(result.statements).toHaveLength(2);
});
it("repeated push after complete response is idempotent", () => {
const lib = makeTestLibrary();
const parser = createStreamingParser(lib);
const full = 'a = Text("done")\n';
const first = parser.push(full);
// Push empty strings — result should remain stable
const second = parser.push("");
const third = parser.push("");
expect(second.statements).toEqual(first.statements);
expect(third.statements).toEqual(first.statements);
expect(third.root).toEqual(first.root);
});
it("unicode characters split across chunk boundaries", () => {
const lib = makeTestLibrary();
const parser = createStreamingParser(lib);
// JS strings are UTF-16, so multi-byte chars like emoji are fine as
// string splits — but let's verify the parser handles them gracefully
parser.push('a = Text("hello ');
parser.push("🌍");
parser.push(" world");
const result = parser.push('")\n');
expect(result.statements).toHaveLength(1);
expect(result.root).not.toBeNull();
});
it("unicode CJK characters streamed char by char", () => {
const lib = makeTestLibrary();
const parser = createStreamingParser(lib);
const input = 'a = Text("你好世界")\n';
let result;
for (const ch of input) {
result = parser.push(ch);
}
expect(result!.statements).toHaveLength(1);
expect(result!.root).not.toBeNull();
});
});
describe("autoClose additional edge cases", () => {
it("mixed bracket types — ([{", () => {
const result = autoClose("([{");
expect(result).toBe("([{}])");
});
it("string containing bracket chars is not counted", () => {
// The ( inside the string should not produce a closer
const result = autoClose('"hello (world"');
// String is closed, paren inside string is ignored — no extra closers
expect(result).toBe('"hello (world"');
});
it("unclosed string containing bracket chars", () => {
// Unclosed string with brackets inside — brackets are ignored, string gets closed
const result = autoClose('"hello (world');
expect(result).toBe('"hello (world"');
});
it("only opening brackets — (((", () => {
const result = autoClose("(((");
expect(result).toBe("((()))");
});
it("alternating open/close with extras — (()(", () => {
const result = autoClose("(()(");
// Stack: push ( → push ( → pop for ) → push ( → closers left: (, (
expect(result).toBe("(()())");
});
it("all bracket types deeply nested", () => {
const result = autoClose("({[");
expect(result).toBe("({[]})");
});
it("partial close leaves remaining openers", () => {
// ( [ ] — bracket closed, paren still open
const result = autoClose("([]");
expect(result).toBe("([])");
});
it("escaped quote at end of string does not close it", () => {
// The backslash escapes the quote, so the string is still open
const result = autoClose('"hello\\');
// escaped flag is set, next char would be escaped — string still open
expect(result).toBe('"hello\\"');
});
it("single quotes work the same as double quotes", () => {
const result = autoClose("'hello");
expect(result).toBe("'hello'");
});
it("mixed string types — only the active one matters", () => {
// Double-quoted string containing a single quote — single quote is literal
const result = autoClose("\"it's");
expect(result).toBe('"it\'s"');
});
it("empty string input returns empty", () => {
expect(autoClose("")).toBe("");
});
it("already balanced input returns unchanged", () => {
expect(autoClose("({[]})")).toBe("({[]})");
});
it("mismatched close bracket is tolerated", () => {
// A ] with no matching [ — should not crash, just ignored
const result = autoClose("(]");
// The ] doesn't match (, so it's ignored — ( still needs closing
expect(result).toBe("(])");
});
});

View File

@@ -1,101 +0,0 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { createStreamingParser } from "./streaming";
import { createLibrary } from "../library";
import { defineComponent } from "../component";
function makeTestLibrary() {
return createLibrary([
defineComponent({
name: "Text",
description: "Text",
props: z.object({ children: z.string() }),
component: null,
}),
defineComponent({
name: "Button",
description: "Button",
props: z.object({
children: z.string(),
main: z.boolean().optional(),
actionId: z.string().optional(),
}),
component: null,
}),
defineComponent({
name: "Stack",
description: "Stack",
props: z.object({
children: z.array(z.unknown()).optional(),
gap: z.string().optional(),
}),
component: null,
}),
]);
}
describe("StreamingParser", () => {
it("parses a complete response", () => {
const lib = makeTestLibrary();
const parser = createStreamingParser(lib);
const result = parser.push('title = Text("Hello World")\n');
expect(result.statements).toHaveLength(1);
expect(result.root).not.toBeNull();
});
it("handles incremental streaming", () => {
const lib = makeTestLibrary();
const parser = createStreamingParser(lib);
// First chunk — partial line
let result = parser.push('title = Text("He');
expect(result.statements.length).toBeGreaterThanOrEqual(0);
// Complete the line
result = parser.push('llo World")\n');
expect(result.statements).toHaveLength(1);
});
it("handles multi-line streaming", () => {
const lib = makeTestLibrary();
const parser = createStreamingParser(lib);
parser.push('a = Text("Line 1")\n');
const result = parser.push('b = Text("Line 2")\n');
expect(result.statements).toHaveLength(2);
});
it("caches complete lines and only re-parses partial", () => {
const lib = makeTestLibrary();
const parser = createStreamingParser(lib);
// First complete line
parser.push('a = Text("First")\n');
// Partial second line — should still have first line cached
const result = parser.push('b = Text("Sec');
expect(result.statements.length).toBeGreaterThanOrEqual(1);
});
it("resets on shorter input", () => {
const lib = makeTestLibrary();
const parser = createStreamingParser(lib);
parser.push('a = Text("Hello")\n');
parser.reset();
const result = parser.push('x = Text("Fresh")\n');
expect(result.statements).toHaveLength(1);
expect(result.statements[0]!.name).toBe("x");
});
it("result() returns last parse result", () => {
const lib = makeTestLibrary();
const parser = createStreamingParser(lib);
parser.push('a = Text("Hello")\n');
const result = parser.result();
expect(result.statements).toHaveLength(1);
});
});

View File

@@ -1,112 +0,0 @@
import type {
ASTNode,
ElementNode,
Library,
ParseError,
ParseResult,
Statement,
} from "../types";
import { Parser } from "./parser";
import { autoClose } from "./autoclose";
import { resolveReferences } from "./resolver";
import { validateAndTransform } from "./validator";
/**
* Streaming parser for GenUI Lang.
*
* Design: each `push(chunk)` appends to the buffer. We split on newlines,
* cache results for complete lines, and re-parse only the last (partial) line
* with auto-closing applied.
*
* This gives us O(1) work per chunk for complete lines and O(n) only for the
* current partial line — ideal for LLM token-by-token streaming.
*/
export interface StreamParser {
push(chunk: string): ParseResult;
result(): ParseResult;
reset(): void;
}
export function createStreamingParser(library: Library): StreamParser {
let buffer = "";
let cachedStatements: Statement[] = [];
let cachedLineCount = 0;
let lastResult: ParseResult = { statements: [], root: null, errors: [] };
function parseAll(): ParseResult {
const allErrors: ParseError[] = [];
// Split into lines
const lines = buffer.split("\n");
const completeLines = lines.slice(0, -1);
const partialLine = lines[lines.length - 1] ?? "";
// Re-use cached statements for lines we've already parsed
const newCompleteCount = completeLines.length;
if (newCompleteCount > cachedLineCount) {
// Parse new complete lines
const newLines = completeLines.slice(cachedLineCount).join("\n");
if (newLines.trim()) {
const parser = Parser.fromSource(newLines);
const { statements, errors } = parser.parse();
cachedStatements = [...cachedStatements, ...statements];
allErrors.push(...errors);
}
cachedLineCount = newCompleteCount;
}
// Parse partial line with auto-closing
let partialStatements: Statement[] = [];
if (partialLine.trim()) {
const closed = autoClose(partialLine);
const parser = Parser.fromSource(closed);
const { statements, errors } = parser.parse();
partialStatements = statements;
// Don't report errors for partial lines — they're expected during streaming
void errors;
}
const allStatements = [...cachedStatements, ...partialStatements];
// Resolve references
const { root, errors: resolveErrors } = resolveReferences(allStatements);
allErrors.push(...resolveErrors);
// Transform to element tree
let rootElement: ElementNode | null = null;
if (root) {
const { element, errors: validateErrors } = validateAndTransform(
root,
library
);
rootElement = element;
allErrors.push(...validateErrors);
}
lastResult = {
statements: allStatements,
root: rootElement as ASTNode | null,
errors: allErrors,
};
return lastResult;
}
return {
push(chunk: string): ParseResult {
buffer += chunk;
return parseAll();
},
result(): ParseResult {
return lastResult;
},
reset(): void {
buffer = "";
cachedStatements = [];
cachedLineCount = 0;
lastResult = { statements: [], root: null, errors: [] };
},
};
}

View File

@@ -1,111 +0,0 @@
import { describe, it, expect } from "vitest";
import { Tokenizer } from "./tokenizer";
import { TokenType } from "../types";
describe("Tokenizer", () => {
function tokenTypes(input: string): TokenType[] {
return new Tokenizer(input).tokenize().map((t) => t.type);
}
function tokenValues(input: string): string[] {
return new Tokenizer(input).tokenize().map((t) => t.value);
}
it("tokenizes a simple assignment", () => {
const tokens = new Tokenizer('x = "hello"').tokenize();
expect(tokens.map((t) => t.type)).toEqual([
TokenType.Identifier,
TokenType.Equals,
TokenType.String,
TokenType.EOF,
]);
expect(tokens[0]!.value).toBe("x");
expect(tokens[2]!.value).toBe("hello");
});
it("tokenizes a component call", () => {
expect(tokenTypes('Button("Click me", main: true)')).toEqual([
TokenType.Identifier,
TokenType.LParen,
TokenType.String,
TokenType.Comma,
TokenType.Identifier,
TokenType.Colon,
TokenType.Boolean,
TokenType.RParen,
TokenType.EOF,
]);
});
it("tokenizes arrays", () => {
expect(tokenTypes('["a", "b", "c"]')).toEqual([
TokenType.LBracket,
TokenType.String,
TokenType.Comma,
TokenType.String,
TokenType.Comma,
TokenType.String,
TokenType.RBracket,
TokenType.EOF,
]);
});
it("tokenizes objects", () => {
expect(tokenTypes('{name: "Alice", age: 30}')).toEqual([
TokenType.LBrace,
TokenType.Identifier,
TokenType.Colon,
TokenType.String,
TokenType.Comma,
TokenType.Identifier,
TokenType.Colon,
TokenType.Number,
TokenType.RBrace,
TokenType.EOF,
]);
});
it("tokenizes numbers including negatives and decimals", () => {
const tokens = new Tokenizer("42 -7 3.14").tokenize();
const numbers = tokens.filter((t) => t.type === TokenType.Number);
expect(numbers.map((t) => t.value)).toEqual(["42", "-7", "3.14"]);
});
it("tokenizes booleans and null", () => {
expect(tokenTypes("true false null")).toEqual([
TokenType.Boolean,
TokenType.Boolean,
TokenType.Null,
TokenType.EOF,
]);
});
it("handles escaped strings", () => {
const tokens = new Tokenizer('"hello \\"world\\""').tokenize();
expect(tokens[0]!.value).toBe('hello "world"');
});
it("emits newlines only at bracket depth 0", () => {
// Inside parens — newlines suppressed
const inside = tokenTypes('Foo(\n"a",\n"b"\n)');
expect(inside.filter((t) => t === TokenType.Newline)).toHaveLength(0);
// At top level — newlines emitted
const outside = tokenTypes("x = 1\ny = 2");
expect(outside.filter((t) => t === TokenType.Newline)).toHaveLength(1);
});
it("skips line comments", () => {
const tokens = new Tokenizer(
"x = 1 // this is a comment\ny = 2"
).tokenize();
const idents = tokens.filter((t) => t.type === TokenType.Identifier);
expect(idents.map((t) => t.value)).toEqual(["x", "y"]);
});
it("tracks line and column numbers", () => {
const tokens = new Tokenizer("x = 1\ny = 2").tokenize();
const y = tokens.find((t) => t.value === "y");
expect(y?.line).toBe(2);
});
});

View File

@@ -1,294 +0,0 @@
import { Token, TokenType } from "../types";
const WHITESPACE = /[ \t\r]/;
const DIGIT = /[0-9]/;
const IDENT_START = /[a-zA-Z_]/;
const IDENT_CHAR = /[a-zA-Z0-9_]/;
export class Tokenizer {
private input: string;
private pos = 0;
private line = 1;
private column = 1;
private bracketDepth = 0;
constructor(input: string) {
this.input = input;
}
tokenize(): Token[] {
const tokens: Token[] = [];
while (this.pos < this.input.length) {
this.skipWhitespace();
if (this.pos >= this.input.length) break;
const ch = this.input[this.pos]!;
// Comments — skip to end of line
if (ch === "/" && this.input[this.pos + 1] === "/") {
this.skipLineComment();
continue;
}
if (ch === "\n") {
// Newlines only matter at bracket depth 0
if (this.bracketDepth === 0) {
tokens.push(this.makeToken(TokenType.Newline, "\n"));
}
this.advance();
this.line++;
this.column = 1;
continue;
}
if (ch === '"' || ch === "'") {
tokens.push(this.readString(ch));
continue;
}
if (
DIGIT.test(ch) ||
(ch === "-" && this.peek(1) !== undefined && DIGIT.test(this.peek(1)!))
) {
tokens.push(this.readNumber());
continue;
}
if (IDENT_START.test(ch)) {
tokens.push(this.readIdentifier());
continue;
}
switch (ch) {
case "=":
tokens.push(this.makeToken(TokenType.Equals, "="));
this.advance();
break;
case ":":
tokens.push(this.makeToken(TokenType.Colon, ":"));
this.advance();
break;
case ",":
tokens.push(this.makeToken(TokenType.Comma, ","));
this.advance();
break;
case "(":
this.bracketDepth++;
tokens.push(this.makeToken(TokenType.LParen, "("));
this.advance();
break;
case ")":
this.bracketDepth = Math.max(0, this.bracketDepth - 1);
tokens.push(this.makeToken(TokenType.RParen, ")"));
this.advance();
break;
case "[":
this.bracketDepth++;
tokens.push(this.makeToken(TokenType.LBracket, "["));
this.advance();
break;
case "]":
this.bracketDepth = Math.max(0, this.bracketDepth - 1);
tokens.push(this.makeToken(TokenType.RBracket, "]"));
this.advance();
break;
case "{":
this.bracketDepth++;
tokens.push(this.makeToken(TokenType.LBrace, "{"));
this.advance();
break;
case "}":
this.bracketDepth = Math.max(0, this.bracketDepth - 1);
tokens.push(this.makeToken(TokenType.RBrace, "}"));
this.advance();
break;
default:
// Skip unknown characters
this.advance();
break;
}
}
tokens.push(this.makeToken(TokenType.EOF, ""));
return tokens;
}
private skipWhitespace(): void {
while (
this.pos < this.input.length &&
WHITESPACE.test(this.input[this.pos]!)
) {
this.advance();
}
}
private skipLineComment(): void {
while (this.pos < this.input.length && this.input[this.pos] !== "\n") {
this.advance();
}
}
private readString(quote: string): Token {
const startOffset = this.pos;
const startLine = this.line;
const startCol = this.column;
this.advance(); // skip opening quote
let value = "";
while (this.pos < this.input.length) {
const ch = this.input[this.pos]!;
if (ch === "\\") {
this.advance();
if (this.pos < this.input.length) {
const escaped = this.input[this.pos]!;
switch (escaped) {
case "n":
value += "\n";
break;
case "t":
value += "\t";
break;
case "\\":
value += "\\";
break;
case '"':
value += '"';
break;
case "'":
value += "'";
break;
default:
value += escaped;
}
this.advance();
}
continue;
}
if (ch === quote) {
this.advance(); // skip closing quote
break;
}
if (ch === "\n") {
this.line++;
this.column = 0;
}
value += ch;
this.advance();
}
return {
type: TokenType.String,
value,
offset: startOffset,
line: startLine,
column: startCol,
};
}
private readNumber(): Token {
const startOffset = this.pos;
const startLine = this.line;
const startCol = this.column;
let value = "";
if (this.input[this.pos] === "-") {
value += "-";
this.advance();
}
while (this.pos < this.input.length && DIGIT.test(this.input[this.pos]!)) {
value += this.input[this.pos]!;
this.advance();
}
if (this.pos < this.input.length && this.input[this.pos] === ".") {
value += ".";
this.advance();
while (
this.pos < this.input.length &&
DIGIT.test(this.input[this.pos]!)
) {
value += this.input[this.pos]!;
this.advance();
}
}
return {
type: TokenType.Number,
value,
offset: startOffset,
line: startLine,
column: startCol,
};
}
private readIdentifier(): Token {
const startOffset = this.pos;
const startLine = this.line;
const startCol = this.column;
let value = "";
while (
this.pos < this.input.length &&
IDENT_CHAR.test(this.input[this.pos]!)
) {
value += this.input[this.pos]!;
this.advance();
}
// Check for keywords
if (value === "true" || value === "false") {
return {
type: TokenType.Boolean,
value,
offset: startOffset,
line: startLine,
column: startCol,
};
}
if (value === "null") {
return {
type: TokenType.Null,
value,
offset: startOffset,
line: startLine,
column: startCol,
};
}
return {
type: TokenType.Identifier,
value,
offset: startOffset,
line: startLine,
column: startCol,
};
}
private makeToken(type: TokenType, value: string): Token {
return {
type,
value,
offset: this.pos,
line: this.line,
column: this.column,
};
}
private advance(): void {
this.pos++;
this.column++;
}
private peek(offset: number): string | undefined {
return this.input[this.pos + offset];
}
}

View File

@@ -1,473 +0,0 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { validateAndTransform } from "./validator";
import { defineComponent } from "../component";
import { createLibrary } from "../library";
import type { ASTNode, ComponentNode, Library } from "../types";
// ── Test library setup ──
const ButtonDef = defineComponent({
name: "Button",
description: "A clickable button",
props: z.object({
label: z.string(),
variant: z.enum(["primary", "secondary"]).optional(),
}),
component: null,
});
const TextDef = defineComponent({
name: "Text",
description: "A text element",
props: z.object({
children: z.string(),
}),
component: null,
});
const CardDef = defineComponent({
name: "Card",
description: "A card container",
props: z.object({
title: z.string(),
subtitle: z.string().optional(),
}),
component: null,
});
const InputDef = defineComponent({
name: "Input",
description: "A text input",
props: z.object({
placeholder: z.string().optional(),
disabled: z.boolean().optional(),
}),
component: null,
});
const EmptyDef = defineComponent({
name: "Divider",
description: "A divider with no props",
props: z.object({}),
component: null,
});
function makeLibrary(): Library {
return createLibrary([ButtonDef, TextDef, CardDef, InputDef, EmptyDef]);
}
// ── Helpers for building AST nodes ──
function lit(value: string | number | boolean | null): ASTNode {
return { kind: "literal", value };
}
function comp(
name: string,
args: { key: string | null; value: ASTNode }[]
): ComponentNode {
return { kind: "component", name, args };
}
function ref(name: string): ASTNode {
return { kind: "reference", name };
}
function arr(elements: ASTNode[]): ASTNode {
return { kind: "array", elements };
}
function obj(entries: { key: string; value: ASTNode }[]): ASTNode {
return { kind: "object", entries };
}
// ── Tests ──
describe("validateAndTransform", () => {
const library = makeLibrary();
describe("positional argument mapping", () => {
it("maps first positional arg to first prop", () => {
const node = comp("Button", [{ key: null, value: lit("Click me") }]);
const { element, errors } = validateAndTransform(node, library);
expect(element).not.toBeNull();
expect(element!.component).toBe("Button");
expect(element!.props.label).toBe("Click me");
// variant is optional so no validation error for missing it
expect(errors).toHaveLength(0);
});
it("maps second positional arg to second prop", () => {
const node = comp("Button", [
{ key: null, value: lit("Click me") },
{ key: null, value: lit("secondary") },
]);
const { element, errors } = validateAndTransform(node, library);
expect(element!.props.label).toBe("Click me");
expect(element!.props.variant).toBe("secondary");
expect(errors).toHaveLength(0);
});
it("maps multiple positional args in order for Card", () => {
const node = comp("Card", [
{ key: null, value: lit("My Title") },
{ key: null, value: lit("My Subtitle") },
]);
const { element, errors } = validateAndTransform(node, library);
expect(element!.props.title).toBe("My Title");
expect(element!.props.subtitle).toBe("My Subtitle");
expect(errors).toHaveLength(0);
});
});
describe("named arguments", () => {
it("passes named args through correctly", () => {
const node = comp("Button", [
{ key: "label", value: lit("OK") },
{ key: "variant", value: lit("primary") },
]);
const { element, errors } = validateAndTransform(node, library);
expect(element!.props.label).toBe("OK");
expect(element!.props.variant).toBe("primary");
expect(errors).toHaveLength(0);
});
it("handles named args in any order", () => {
const node = comp("Button", [
{ key: "variant", value: lit("secondary") },
{ key: "label", value: lit("Cancel") },
]);
const { element, errors } = validateAndTransform(node, library);
expect(element!.props.label).toBe("Cancel");
expect(element!.props.variant).toBe("secondary");
expect(errors).toHaveLength(0);
});
});
describe("mixed positional + named arguments", () => {
it("maps positional first then named", () => {
const node = comp("Button", [
{ key: null, value: lit("Submit") },
{ key: "variant", value: lit("primary") },
]);
const { element, errors } = validateAndTransform(node, library);
expect(element!.props.label).toBe("Submit");
expect(element!.props.variant).toBe("primary");
expect(errors).toHaveLength(0);
});
});
describe("unknown component", () => {
it("produces error but still returns element", () => {
const node = comp("Nonexistent", [{ key: null, value: lit("hello") }]);
const { element, errors } = validateAndTransform(node, library);
expect(element).not.toBeNull();
expect(element!.component).toBe("Nonexistent");
expect(errors).toHaveLength(1);
expect(errors[0]!.message).toContain('Unknown component: "Nonexistent"');
});
it("still assigns positional args as generic props for unknown component", () => {
// Unknown component has no paramDefs, so all positional args become children
const node = comp("Foo", [
{ key: null, value: comp("Button", [{ key: null, value: lit("hi") }]) },
]);
const { element } = validateAndTransform(node, library);
// The positional arg should become a child since there are no param defs
expect(element!.children).toHaveLength(1);
expect(element!.children[0]!.component).toBe("Button");
});
it("passes named args through even for unknown component", () => {
const node = comp("Unknown", [{ key: "title", value: lit("hey") }]);
const { element } = validateAndTransform(node, library);
expect(element!.props.title).toBe("hey");
});
});
describe("literal string wrapping", () => {
it("wraps a literal string in a Text element", () => {
const node = lit("Hello world");
const { element, errors } = validateAndTransform(node, library);
expect(element).not.toBeNull();
expect(element!.component).toBe("Text");
expect(element!.props.children).toBe("Hello world");
expect(element!.children).toHaveLength(0);
expect(errors).toHaveLength(0);
});
it("returns null for non-string literals", () => {
const numNode = lit(42);
expect(validateAndTransform(numNode, library).element).toBeNull();
const boolNode = lit(true);
expect(validateAndTransform(boolNode, library).element).toBeNull();
const nullNode = lit(null);
expect(validateAndTransform(nullNode, library).element).toBeNull();
});
});
describe("array wrapping", () => {
it("wraps array in a Stack element", () => {
const node = arr([
comp("Button", [{ key: null, value: lit("A") }]),
comp("Button", [{ key: null, value: lit("B") }]),
]);
const { element, errors } = validateAndTransform(node, library);
expect(element).not.toBeNull();
expect(element!.component).toBe("Stack");
expect(element!.children).toHaveLength(2);
expect(element!.children[0]!.component).toBe("Button");
expect(element!.children[1]!.component).toBe("Button");
expect(errors).toHaveLength(0);
});
it("filters out null elements from array children", () => {
// A number literal returns null from transformNode
const node = arr([
comp("Button", [{ key: null, value: lit("OK") }]),
lit(42),
]);
const { element } = validateAndTransform(node, library);
expect(element!.component).toBe("Stack");
expect(element!.children).toHaveLength(1);
});
it("wraps empty array as empty Stack", () => {
const node = arr([]);
const { element } = validateAndTransform(node, library);
expect(element!.component).toBe("Stack");
expect(element!.children).toHaveLength(0);
});
});
describe("object literal", () => {
it("produces error and returns null", () => {
const node = obj([{ key: "a", value: lit("b") }]);
const { element, errors } = validateAndTransform(node, library);
expect(element).toBeNull();
expect(errors).toHaveLength(1);
expect(errors[0]!.message).toContain("Object literal cannot be rendered");
});
});
describe("unresolved reference", () => {
it("produces __Unresolved placeholder", () => {
const node = ref("someVar");
const { element, errors } = validateAndTransform(node, library);
expect(element).not.toBeNull();
expect(element!.component).toBe("__Unresolved");
expect(element!.props.name).toBe("someVar");
expect(element!.children).toHaveLength(0);
expect(errors).toHaveLength(0);
});
});
describe("nested components", () => {
it("handles component as a named arg value", () => {
// Card with title as a string, but imagine a prop that accepts a component
// Since the validator calls astToValue for component nodes, it returns an ElementNode
const innerButton = comp("Button", [{ key: null, value: lit("Inner") }]);
const node = comp("Card", [{ key: "title", value: innerButton }]);
const { element } = validateAndTransform(node, library);
// The inner button becomes an ElementNode object in props
expect(element!.props.title).toEqual({
kind: "element",
component: "Button",
props: { label: "Inner" },
children: [],
});
});
it("handles component as positional arg", () => {
const innerText = comp("Text", [{ key: null, value: lit("hello") }]);
const node = comp("Card", [{ key: null, value: innerText }]);
const { element } = validateAndTransform(node, library);
// First positional maps to "title" param — value is the ElementNode
expect(element!.props.title).toEqual(
expect.objectContaining({ kind: "element", component: "Text" })
);
});
});
describe("Zod validation errors", () => {
it("reports error when required prop is missing", () => {
// Button requires label (z.string()), pass no args
const node = comp("Button", []);
const { element, errors } = validateAndTransform(node, library);
expect(element).not.toBeNull(); // still returns the element
expect(element!.component).toBe("Button");
expect(errors.length).toBeGreaterThan(0);
expect(errors.some((e) => e.message.includes("Button"))).toBe(true);
});
it("reports error for wrong type", () => {
// Button label expects string, pass a number
const node = comp("Button", [{ key: null, value: lit(42) }]);
const { element, errors } = validateAndTransform(node, library);
expect(element).not.toBeNull();
expect(errors.length).toBeGreaterThan(0);
expect(errors.some((e) => e.message.includes("label"))).toBe(true);
});
it("reports error for invalid enum value", () => {
const node = comp("Button", [
{ key: null, value: lit("OK") },
{ key: null, value: lit("invalid-variant") },
]);
const { element, errors } = validateAndTransform(node, library);
expect(element).not.toBeNull();
expect(errors.length).toBeGreaterThan(0);
expect(errors.some((e) => e.message.includes("variant"))).toBe(true);
});
it("reports error for wrong boolean type", () => {
// Input.disabled expects boolean, pass a string
const node = comp("Input", [{ key: "disabled", value: lit("yes") }]);
const { element, errors } = validateAndTransform(node, library);
expect(element).not.toBeNull();
expect(errors.length).toBeGreaterThan(0);
expect(errors.some((e) => e.message.includes("disabled"))).toBe(true);
});
});
describe("extra positional args beyond param count", () => {
it("treats extra positional args as children", () => {
// Button has 2 params (label, variant). Third positional should become a child.
const extraChild = comp("Text", [{ key: null, value: lit("extra") }]);
const node = comp("Button", [
{ key: null, value: lit("Click") },
{ key: null, value: lit("primary") },
{ key: null, value: extraChild },
]);
const { element } = validateAndTransform(node, library);
expect(element!.children).toHaveLength(1);
expect(element!.children[0]!.component).toBe("Text");
});
it("handles multiple extra positional args as children", () => {
const child1 = comp("Button", [{ key: null, value: lit("A") }]);
const child2 = comp("Button", [{ key: null, value: lit("B") }]);
const node = comp("Button", [
{ key: null, value: lit("Parent") },
{ key: null, value: lit("primary") },
{ key: null, value: child1 },
{ key: null, value: child2 },
]);
const { element } = validateAndTransform(node, library);
expect(element!.children).toHaveLength(2);
});
it("skips null children from extra positional args", () => {
// A number literal transforms to null, so it should be filtered out
const node = comp("Button", [
{ key: null, value: lit("Click") },
{ key: null, value: lit("primary") },
{ key: null, value: lit(999) }, // number literal → null child
]);
const { element } = validateAndTransform(node, library);
expect(element!.children).toHaveLength(0);
});
});
describe("component with no args", () => {
it("renders component with empty props and no errors when all props are optional", () => {
const node = comp("Input", []);
const { element, errors } = validateAndTransform(node, library);
expect(element).not.toBeNull();
expect(element!.component).toBe("Input");
expect(element!.props).toEqual({});
expect(element!.children).toHaveLength(0);
expect(errors).toHaveLength(0);
});
it("renders component with empty props and no children for empty schema", () => {
const node = comp("Divider", []);
const { element, errors } = validateAndTransform(node, library);
expect(element!.component).toBe("Divider");
expect(element!.props).toEqual({});
expect(errors).toHaveLength(0);
});
});
describe("astToValue edge cases", () => {
it("converts array prop values with nested components", () => {
// Pass an array as a named prop — components inside become ElementNodes
const node = comp("Card", [
{
key: "title",
value: arr([comp("Button", [{ key: null, value: lit("A") }])]),
},
]);
const { element } = validateAndTransform(node, library);
const titleProp = element!.props.title as unknown[];
expect(Array.isArray(titleProp)).toBe(true);
expect(titleProp[0]).toEqual(
expect.objectContaining({ kind: "element", component: "Button" })
);
});
it("converts object prop values to plain objects", () => {
const node = comp("Card", [
{
key: "title",
value: obj([
{ key: "text", value: lit("hello") },
{ key: "bold", value: lit(true) },
]),
},
]);
const { element } = validateAndTransform(node, library);
expect(element!.props.title).toEqual({ text: "hello", bold: true });
});
it("converts reference in prop to { __ref: name } placeholder", () => {
const node = comp("Card", [{ key: "title", value: ref("myVar") }]);
const { element } = validateAndTransform(node, library);
expect(element!.props.title).toEqual({ __ref: "myVar" });
});
it("converts literal prop values directly", () => {
const node = comp("Input", [
{ key: "placeholder", value: lit("Type here") },
{ key: "disabled", value: lit(false) },
]);
const { element, errors } = validateAndTransform(node, library);
expect(element!.props.placeholder).toBe("Type here");
expect(element!.props.disabled).toBe(false);
expect(errors).toHaveLength(0);
});
});
});

View File

@@ -1,179 +0,0 @@
import type {
ASTNode,
ComponentNode,
ElementNode,
ParseError,
ParamDef,
} from "../types";
import type { Library } from "../types";
/**
* Convert a resolved AST into an ElementNode tree.
*
* - Maps positional arguments to named props using ParamDef ordering
* - Validates prop values against Zod schemas
* - Unknown components produce errors but still render (as generic elements)
*/
export function validateAndTransform(
node: ASTNode,
library: Library
): { element: ElementNode | null; errors: ParseError[] } {
const errors: ParseError[] = [];
const element = transformNode(node, library, errors);
return { element, errors };
}
function transformNode(
node: ASTNode,
library: Library,
errors: ParseError[]
): ElementNode | null {
switch (node.kind) {
case "component":
return transformComponent(node, library, errors);
case "literal":
// Wrap literal strings in a Text element
if (typeof node.value === "string") {
return {
kind: "element",
component: "Text",
props: { children: node.value },
children: [],
};
}
return null;
case "array":
// Array at root level → Stack wrapper
return {
kind: "element",
component: "Stack",
props: {},
children: node.elements
.map((el) => transformNode(el, library, errors))
.filter((el): el is ElementNode => el !== null),
};
case "object":
// Objects can't directly render — treat as props error
errors.push({
message: "Object literal cannot be rendered as a component",
line: 0,
column: 0,
});
return null;
case "reference":
// Unresolved reference — placeholder
return {
kind: "element",
component: "__Unresolved",
props: { name: node.name },
children: [],
};
}
}
function transformComponent(
node: ComponentNode,
library: Library,
errors: ParseError[]
): ElementNode {
const def = library.resolve(node.name);
const paramDefs = def ? library.paramMap().get(node.name) ?? [] : [];
// Map positional args to named props
const props: Record<string, unknown> = {};
const children: ElementNode[] = [];
let positionalIndex = 0;
for (const arg of node.args) {
if (arg.key !== null) {
// Named argument
props[arg.key] = astToValue(arg.value, library, errors, children);
} else {
// Positional argument — map to param def by index
const paramDef = paramDefs[positionalIndex] as ParamDef | undefined;
if (paramDef) {
props[paramDef.name] = astToValue(arg.value, library, errors, children);
} else {
// Extra positional arg with no param def — treat as child
const childElement = transformNode(arg.value, library, errors);
if (childElement) {
children.push(childElement);
}
}
positionalIndex++;
}
}
// Validate props against Zod schema if component is known
if (def) {
const result = def.props.safeParse(props);
if (!result.success) {
for (const issue of result.error.issues) {
errors.push({
message: `${node.name}: ${issue.path.join(".")}: ${issue.message}`,
line: 0,
column: 0,
});
}
}
} else {
errors.push({
message: `Unknown component: "${node.name}"`,
line: 0,
column: 0,
});
}
return {
kind: "element",
component: node.name,
props,
children,
};
}
/**
* Convert an AST node to a plain JS value for use as a prop.
*/
function astToValue(
node: ASTNode,
library: Library,
errors: ParseError[],
children: ElementNode[]
): unknown {
switch (node.kind) {
case "literal":
return node.value;
case "array":
return node.elements.map((el) => {
// Nested components become ElementNodes
if (el.kind === "component") {
return transformComponent(el, library, errors);
}
return astToValue(el, library, errors, children);
});
case "object": {
const obj: Record<string, unknown> = {};
for (const entry of node.entries) {
obj[entry.key] = astToValue(entry.value, library, errors, children);
}
return obj;
}
case "component":
return transformComponent(node, library, errors);
case "reference":
// Unresolved reference — return placeholder
return { __ref: node.name };
}
}

View File

@@ -1,114 +0,0 @@
import type { Library, PromptOptions } from "../types";
import { schemaToSignature } from "./introspector";
/**
* Auto-generate a system prompt section from a component library.
*
* The generated prompt teaches the LLM:
* 1. The GenUI Lang syntax
* 2. Available components with signatures
* 3. Streaming guidelines
* 4. User-provided examples and rules
*/
export function generatePrompt(
library: Library,
options?: PromptOptions
): string {
const sections: string[] = [];
// ── Header ──
sections.push(`# Structured UI Output (GenUI Lang)
When the user's request benefits from structured UI (tables, cards, buttons, layouts), respond using GenUI Lang — a compact, line-oriented markup. Otherwise respond in plain markdown.`);
// ── Syntax ──
sections.push(`## Syntax
Each line declares a variable: \`name = expression\`
Expressions:
- \`ComponentName(arg1, arg2, key: value)\` — component with positional or named args
- \`[a, b, c]\` — array
- \`{key: value}\` — object
- \`"string"\`, \`42\`, \`true\`, \`false\`, \`null\` — literals
- \`variableName\` — reference to a previously defined variable
Rules:
- PascalCase identifiers are component types
- camelCase identifiers are variable references
- Positional args map to props in the order defined below
- The last statement is the root element (or name one \`root\`)
- Lines inside brackets/parens can span multiple lines
- Lines that don't match \`name = expression\` are treated as plain text`);
// ── Components ──
const grouped = groupComponents(library);
const componentLines: string[] = [];
for (const [group, components] of grouped) {
if (group) {
componentLines.push(`\n### ${group}`);
}
for (const comp of components) {
const sig = schemaToSignature(comp.name, comp.props);
componentLines.push(`- \`${sig}\`${comp.description}`);
}
}
sections.push(`## Available Components\n${componentLines.join("\n")}`);
// ── Streaming Guidelines ──
if (options?.streaming !== false) {
sections.push(`## Streaming Guidelines
- Define variables before referencing them
- Each line is independently parseable — the UI updates as each line completes
- Keep variable names short and descriptive
- Build up complex UIs incrementally: define data first, then layout`);
}
// ── Examples ──
if (options?.examples && options.examples.length > 0) {
const exampleLines = options.examples.map(
(ex) => `### ${ex.description}\n\`\`\`\n${ex.code}\n\`\`\``
);
sections.push(`## Examples\n\n${exampleLines.join("\n\n")}`);
}
// ── Additional Rules ──
if (options?.additionalRules && options.additionalRules.length > 0) {
const ruleLines = options.additionalRules.map((r) => `- ${r}`);
sections.push(`## Additional Guidelines\n\n${ruleLines.join("\n")}`);
}
return sections.join("\n\n");
}
function groupComponents(library: Library): [
string | undefined,
{
name: string;
description: string;
props: import("zod").ZodObject<import("zod").ZodRawShape>;
}[],
][] {
const groups = new Map<string | undefined, typeof result>();
type ComponentEntry = {
name: string;
description: string;
props: import("zod").ZodObject<import("zod").ZodRawShape>;
};
const result: ComponentEntry[] = [];
for (const [, comp] of library.components) {
const group = comp.group;
if (!groups.has(group)) {
groups.set(group, []);
}
groups.get(group)!.push(comp);
}
return Array.from(groups.entries());
}

View File

@@ -1,2 +0,0 @@
export { generatePrompt } from "./generator";
export { zodToTypeString, schemaToSignature } from "./introspector";

View File

@@ -1,75 +0,0 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { zodToTypeString, schemaToSignature } from "./introspector";
describe("zodToTypeString", () => {
it("handles primitives", () => {
expect(zodToTypeString(z.string())).toBe("string");
expect(zodToTypeString(z.number())).toBe("number");
expect(zodToTypeString(z.boolean())).toBe("boolean");
expect(zodToTypeString(z.null())).toBe("null");
});
it("handles optional", () => {
expect(zodToTypeString(z.string().optional())).toBe("string?");
});
it("handles nullable", () => {
expect(zodToTypeString(z.string().nullable())).toBe("string | null");
});
it("handles enums", () => {
expect(zodToTypeString(z.enum(["a", "b", "c"]))).toBe('"a" | "b" | "c"');
});
it("handles arrays", () => {
expect(zodToTypeString(z.array(z.string()))).toBe("string[]");
});
it("handles objects", () => {
const schema = z.object({
name: z.string(),
age: z.number().optional(),
});
expect(zodToTypeString(schema)).toBe("{ name: string, age?: number }");
});
it("handles defaults", () => {
expect(zodToTypeString(z.string().default("hello"))).toBe("string?");
});
});
describe("schemaToSignature", () => {
it("generates a function-like signature", () => {
const schema = z.object({
label: z.string(),
main: z.boolean().optional(),
primary: z.boolean().optional(),
});
expect(schemaToSignature("Button", schema)).toBe(
"Button(label: string, main?: boolean, primary?: boolean)"
);
});
it("handles required-only params", () => {
const schema = z.object({
title: z.string(),
color: z.string(),
});
expect(schemaToSignature("Tag", schema)).toBe(
"Tag(title: string, color: string)"
);
});
it("handles enum params", () => {
const schema = z.object({
size: z.enum(["sm", "md", "lg"]).optional(),
});
expect(schemaToSignature("Widget", schema)).toBe(
'Widget(size?: "sm" | "md" | "lg")'
);
});
});

View File

@@ -1,140 +0,0 @@
import { z } from "zod";
/**
* Convert a Zod schema to a human-readable type string for LLM prompts.
*
* Uses `_def.typeName` instead of `instanceof` to avoid issues with
* multiple Zod copies in the module graph.
*/
export function zodToTypeString(schema: z.ZodTypeAny): string {
return describeType(schema, false);
}
function describeType(
schema: z.ZodTypeAny,
isOptionalContext: boolean
): string {
const typeName = schema._def?.typeName as string | undefined;
// Unwrap optional/nullable
if (typeName === "ZodOptional") {
const inner = describeType(
(schema as z.ZodOptional<z.ZodTypeAny>).unwrap(),
true
);
return `${inner}?`;
}
if (typeName === "ZodNullable") {
const inner = describeType(
(schema as z.ZodNullable<z.ZodTypeAny>).unwrap(),
false
);
return `${inner} | null`;
}
if (typeName === "ZodDefault") {
const inner = describeType(
(schema as z.ZodDefault<z.ZodTypeAny>).removeDefault(),
true
);
const suffix = isOptionalContext ? "" : "?";
return `${inner}${suffix}`;
}
// Primitives
if (typeName === "ZodString") return "string";
if (typeName === "ZodNumber") return "number";
if (typeName === "ZodBoolean") return "boolean";
if (typeName === "ZodNull") return "null";
// Enum
if (typeName === "ZodEnum") {
const values = (schema as z.ZodEnum<[string, ...string[]]>)
.options as string[];
return values.map((v) => `"${v}"`).join(" | ");
}
if (typeName === "ZodNativeEnum") {
return "enum";
}
// Literal
if (typeName === "ZodLiteral") {
const val = (schema as z.ZodLiteral<unknown>).value;
if (typeof val === "string") return `"${val}"`;
return String(val);
}
// Array
if (typeName === "ZodArray") {
const inner = describeType(
(schema as z.ZodArray<z.ZodTypeAny>).element,
false
);
const needsParens = inner.includes("|") || inner.includes("&");
return needsParens ? `(${inner})[]` : `${inner}[]`;
}
// Object
if (typeName === "ZodObject") {
const shape = (schema as z.ZodObject<z.ZodRawShape>).shape as Record<
string,
z.ZodTypeAny
>;
const entries = Object.entries(shape).map(([key, val]) => {
const typeStr = describeType(val, false);
const isOpt = val.isOptional();
return `${key}${isOpt ? "?" : ""}: ${typeStr.replace(/\?$/, "")}`;
});
return `{ ${entries.join(", ")} }`;
}
// Union
if (typeName === "ZodUnion") {
const options = (schema as z.ZodUnion<[z.ZodTypeAny, ...z.ZodTypeAny[]]>)
.options;
return options.map((o: z.ZodTypeAny) => describeType(o, false)).join(" | ");
}
// Record
if (typeName === "ZodRecord") {
const valueType = describeType((schema as z.ZodRecord).element, false);
return `Record<string, ${valueType}>`;
}
// Tuple
if (typeName === "ZodTuple") {
const items = (schema as z.ZodTuple<[z.ZodTypeAny, ...z.ZodTypeAny[]]>)
.items;
return `[${items
.map((i: z.ZodTypeAny) => describeType(i, false))
.join(", ")}]`;
}
// Any / Unknown
if (typeName === "ZodAny") return "any";
if (typeName === "ZodUnknown") return "unknown";
return "unknown";
}
/**
* Generate a function-signature-style string for a component's props schema.
*
* Example: `Button(label: string, main?: boolean, primary?: boolean)`
*/
export function schemaToSignature(
name: string,
schema: z.ZodObject<z.ZodRawShape>
): string {
const shape = schema.shape;
const params = Object.entries(shape).map(([key, zodType]) => {
const type = zodType as z.ZodTypeAny;
const isOpt = type.isOptional();
const typeStr = zodToTypeString(type).replace(/\?$/, "");
return `${key}${isOpt ? "?" : ""}: ${typeStr}`;
});
return `${name}(${params.join(", ")})`;
}

View File

@@ -1,156 +0,0 @@
import { z } from "zod";
// ── Token types produced by the tokenizer ──
export enum TokenType {
Identifier = "Identifier",
String = "String",
Number = "Number",
Boolean = "Boolean",
Null = "Null",
Equals = "Equals",
Colon = "Colon",
Comma = "Comma",
LParen = "LParen",
RParen = "RParen",
LBracket = "LBracket",
RBracket = "RBracket",
LBrace = "LBrace",
RBrace = "RBrace",
Newline = "Newline",
EOF = "EOF",
}
export interface Token {
type: TokenType;
value: string;
offset: number;
line: number;
column: number;
}
// ── AST nodes produced by the parser ──
export type ASTNode =
| ComponentNode
| ArrayNode
| ObjectNode
| LiteralNode
| ReferenceNode;
export interface ComponentNode {
kind: "component";
name: string;
args: ArgumentNode[];
}
export interface ArgumentNode {
key: string | null; // null = positional
value: ASTNode;
}
export interface ArrayNode {
kind: "array";
elements: ASTNode[];
}
export interface ObjectNode {
kind: "object";
entries: { key: string; value: ASTNode }[];
}
export interface LiteralNode {
kind: "literal";
value: string | number | boolean | null;
}
export interface ReferenceNode {
kind: "reference";
name: string;
}
// ── Resolved element tree (post-resolution) ──
export interface ElementNode {
kind: "element";
component: string;
props: Record<string, unknown>;
children: ElementNode[];
}
export interface TextElementNode {
kind: "text";
content: string;
}
export type ResolvedNode = ElementNode | TextElementNode;
// ── Statement = one line binding ──
export interface Statement {
name: string;
value: ASTNode;
}
// ── Parse result ──
export interface ParseError {
message: string;
line: number;
column: number;
offset?: number;
}
export interface ParseResult {
statements: Statement[];
root: ASTNode | null;
errors: ParseError[];
}
// ── Component definition ──
export interface ComponentDef<
T extends z.ZodObject<z.ZodRawShape> = z.ZodObject<z.ZodRawShape>,
> {
name: string;
description: string;
props: T;
component: unknown; // framework-agnostic — React renderer narrows this
group?: string;
}
// ── Param mapping (for positional → named resolution) ──
export interface ParamDef {
name: string;
required: boolean;
description?: string;
zodType: z.ZodTypeAny;
}
export type ParamMap = Map<string, ParamDef[]>;
// ── Library ──
export interface Library {
components: ReadonlyMap<string, ComponentDef>;
resolve(name: string): ComponentDef | undefined;
prompt(options?: PromptOptions): string;
paramMap(): ParamMap;
}
export interface PromptOptions {
/** Extra rules or guidelines appended to the prompt */
additionalRules?: string[];
/** Example GenUI Lang snippets */
examples?: { description: string; code: string }[];
/** If true, include streaming guidelines */
streaming?: boolean;
}
// ── Action events (from interactive components) ──
export interface ActionEvent {
actionId: string;
payload?: Record<string, unknown>;
}

View File

@@ -1,22 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View File

@@ -1,7 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
},
});

View File

@@ -1,17 +0,0 @@
{
"name": "@onyx/genui-onyx",
"version": "0.1.0",
"private": true,
"description": "Onyx component bindings for GenUI",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
}
}

View File

@@ -1,46 +0,0 @@
import React from "react";
import { z } from "zod";
import { defineComponent } from "@onyx/genui";
import Message from "@/refresh-components/messages/Message";
export const alertComponent = defineComponent({
name: "Alert",
description: "A status message banner (info, success, warning, error)",
group: "Feedback",
props: z.object({
text: z.string().describe("Alert message text"),
description: z.string().optional().describe("Additional description"),
level: z
.enum(["default", "info", "success", "warning", "error"])
.optional()
.describe("Alert severity level"),
showIcon: z.boolean().optional().describe("Show status icon"),
}),
component: ({
props,
}: {
props: {
text: string;
description?: string;
level?: "default" | "info" | "success" | "warning" | "error";
showIcon?: boolean;
};
}) => {
const level = props.level ?? "default";
return (
<Message
static
text={props.text}
description={props.description}
default={level === "default"}
info={level === "info"}
success={level === "success"}
warning={level === "warning"}
error={level === "error"}
icon={props.showIcon !== false}
close={false}
/>
);
},
});

View File

@@ -1,62 +0,0 @@
import React from "react";
import { z } from "zod";
import { defineComponent } from "@onyx/genui";
import Button from "@/refresh-components/buttons/Button";
import { useTriggerAction } from "@onyx/genui-react";
export const buttonComponent = defineComponent({
name: "Button",
description: "An interactive button that triggers an action",
group: "Interactive",
props: z.object({
children: z.string().describe("Button label text"),
main: z.boolean().optional().describe("Main variant styling"),
action: z.boolean().optional().describe("Action variant styling"),
danger: z.boolean().optional().describe("Danger/destructive variant"),
primary: z.boolean().optional().describe("Primary sub-variant"),
secondary: z.boolean().optional().describe("Secondary sub-variant"),
tertiary: z.boolean().optional().describe("Tertiary sub-variant"),
size: z.enum(["lg", "md"]).optional().describe("Button size"),
actionId: z
.string()
.optional()
.describe("Action identifier for event handling"),
disabled: z.boolean().optional().describe("Disable the button"),
}),
component: ({
props,
}: {
props: {
children: string;
main?: boolean;
action?: boolean;
danger?: boolean;
primary?: boolean;
secondary?: boolean;
tertiary?: boolean;
size?: "lg" | "md";
actionId?: string;
disabled?: boolean;
};
}) => {
const triggerAction = useTriggerAction();
return (
<Button
main={props.main}
action={props.action}
danger={props.danger}
primary={props.primary}
secondary={props.secondary}
tertiary={props.tertiary}
size={props.size}
disabled={props.disabled}
onClick={
props.actionId ? () => triggerAction(props.actionId!) : undefined
}
>
{props.children}
</Button>
);
},
});

View File

@@ -1,34 +0,0 @@
import React from "react";
import { z } from "zod";
import { defineComponent } from "@onyx/genui";
import Card from "@/refresh-components/cards/Card";
import Text from "@/refresh-components/texts/Text";
export const cardComponent = defineComponent({
name: "Card",
description: "A container card with optional title and padding",
group: "Layout",
props: z.object({
title: z.string().optional().describe("Card heading"),
padding: z
.enum(["none", "sm", "md", "lg"])
.optional()
.describe("Inner padding"),
}),
component: ({
props,
children,
}: {
props: { title?: string; padding?: string };
children?: React.ReactNode;
}) => (
<Card variant="primary">
{props.title && (
<Text headingH3 text05>
{props.title}
</Text>
)}
{children}
</Card>
),
});

View File

@@ -1,30 +0,0 @@
import React from "react";
import { z } from "zod";
import { defineComponent } from "@onyx/genui";
import Code from "@/refresh-components/Code";
export const codeComponent = defineComponent({
name: "Code",
description: "A code block with optional copy button",
group: "Content",
props: z.object({
children: z.string().describe("The code content"),
language: z
.string()
.optional()
.describe("Programming language for syntax highlighting"),
showCopyButton: z
.boolean()
.optional()
.describe("Show copy-to-clipboard button"),
}),
component: ({
props,
}: {
props: {
children: string;
language?: string;
showCopyButton?: boolean;
};
}) => <Code showCopyButton={props.showCopyButton}>{props.children}</Code>,
});

View File

@@ -1,19 +0,0 @@
import React from "react";
import { z } from "zod";
import { defineComponent } from "@onyx/genui";
import Separator from "@/refresh-components/Separator";
export const dividerComponent = defineComponent({
name: "Divider",
description: "A horizontal separator line",
group: "Layout",
props: z.object({
spacing: z
.enum(["sm", "md", "lg"])
.optional()
.describe("Vertical spacing around the divider"),
}),
component: ({ props }: { props: { spacing?: string } }) => (
<Separator noPadding={props.spacing === "sm"} />
),
});

View File

@@ -1,89 +0,0 @@
import React from "react";
import { z } from "zod";
import { defineComponent } from "@onyx/genui";
import IconButton from "@/refresh-components/buttons/IconButton";
import { useTriggerAction } from "@onyx/genui-react";
import {
SvgCopy,
SvgDownload,
SvgExternalLink,
SvgMoreHorizontal,
SvgPlus,
SvgRefreshCw,
SvgSearch,
SvgSettings,
SvgTrash,
SvgX,
} from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
const iconMap: Record<string, IconFunctionComponent> = {
copy: SvgCopy,
download: SvgDownload,
"external-link": SvgExternalLink,
more: SvgMoreHorizontal,
plus: SvgPlus,
refresh: SvgRefreshCw,
search: SvgSearch,
settings: SvgSettings,
trash: SvgTrash,
close: SvgX,
};
export const iconButtonComponent = defineComponent({
name: "IconButton",
description: "A button that displays an icon with an optional tooltip",
group: "Interactive",
props: z.object({
icon: z
.string()
.describe(
"Icon name (copy, download, external-link, more, plus, refresh, search, settings, trash, close)"
),
tooltip: z.string().optional().describe("Tooltip text on hover"),
main: z.boolean().optional().describe("Main variant styling"),
action: z.boolean().optional().describe("Action variant styling"),
danger: z.boolean().optional().describe("Danger/destructive variant"),
primary: z.boolean().optional().describe("Primary sub-variant"),
secondary: z.boolean().optional().describe("Secondary sub-variant"),
actionId: z
.string()
.optional()
.describe("Action identifier for event handling"),
disabled: z.boolean().optional().describe("Disable the button"),
}),
component: ({
props,
}: {
props: {
icon: string;
tooltip?: string;
main?: boolean;
action?: boolean;
danger?: boolean;
primary?: boolean;
secondary?: boolean;
actionId?: string;
disabled?: boolean;
};
}) => {
const triggerAction = useTriggerAction();
const IconComponent = iconMap[props.icon] ?? SvgMoreHorizontal;
return (
<IconButton
icon={IconComponent}
tooltip={props.tooltip}
main={props.main}
action={props.action}
danger={props.danger}
primary={props.primary}
secondary={props.secondary}
disabled={props.disabled}
onClick={
props.actionId ? () => triggerAction(props.actionId!) : undefined
}
/>
);
},
});

View File

@@ -1,39 +0,0 @@
import React from "react";
import { z } from "zod";
import { defineComponent } from "@onyx/genui";
import PreviewImage from "@/refresh-components/PreviewImage";
export const imageComponent = defineComponent({
name: "Image",
description: "Displays an image",
group: "Content",
props: z.object({
src: z.string().describe("Image URL"),
alt: z.string().optional().describe("Alt text for accessibility"),
width: z.string().optional().describe("CSS width"),
height: z.string().optional().describe("CSS height"),
}),
component: ({
props,
}: {
props: {
src: string;
alt?: string;
width?: string;
height?: string;
};
}) => (
<PreviewImage
src={props.src}
alt={props.alt ?? ""}
className={
[
props.width ? `w-[${props.width}]` : undefined,
props.height ? `h-[${props.height}]` : undefined,
]
.filter(Boolean)
.join(" ") || undefined
}
/>
),
});

View File

@@ -1,59 +0,0 @@
import React, { useState, useCallback } from "react";
import { z } from "zod";
import { defineComponent } from "@onyx/genui";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import { useTriggerAction } from "@onyx/genui-react";
export const inputComponent = defineComponent({
name: "Input",
description: "A text input field",
group: "Interactive",
props: z.object({
placeholder: z.string().optional().describe("Placeholder text"),
value: z.string().optional().describe("Initial value"),
actionId: z
.string()
.optional()
.describe("Action identifier for value changes"),
readOnly: z.boolean().optional().describe("Make the input read-only"),
}),
component: ({
props,
}: {
props: {
placeholder?: string;
value?: string;
actionId?: string;
readOnly?: boolean;
};
}) => {
const triggerAction = useTriggerAction();
const [value, setValue] = useState(props.value ?? "");
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
},
[]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && props.actionId) {
triggerAction(props.actionId, { value });
}
},
[props.actionId, triggerAction, value]
);
return (
<InputTypeIn
placeholder={props.placeholder}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
variant={props.readOnly ? "readOnly" : "primary"}
/>
);
},
});

View File

@@ -1,114 +0,0 @@
import React from "react";
import { z } from "zod";
import { defineComponent } from "@onyx/genui";
import { cn } from "@/lib/utils";
const gapMap: Record<string, string> = {
none: "gap-0",
xs: "gap-1",
sm: "gap-2",
md: "gap-4",
lg: "gap-6",
xl: "gap-8",
};
const alignMap: Record<string, string> = {
start: "items-start",
center: "items-center",
end: "items-end",
stretch: "items-stretch",
};
const gapSchema = z
.enum(["none", "xs", "sm", "md", "lg", "xl"])
.optional()
.describe("Gap between children");
const alignSchema = z
.enum(["start", "center", "end", "stretch"])
.optional()
.describe("Cross-axis alignment");
export const stackComponent = defineComponent({
name: "Stack",
description: "Vertical stack layout — arranges children top to bottom",
group: "Layout",
props: z.object({
children: z.array(z.unknown()).optional().describe("Child elements"),
gap: gapSchema,
align: alignSchema,
}),
component: ({
props,
}: {
props: { children?: React.ReactNode[]; gap?: string; align?: string };
}) => (
<div
className={cn(
"flex flex-col",
gapMap[props.gap ?? "sm"],
props.align && alignMap[props.align]
)}
>
{props.children}
</div>
),
});
export const rowComponent = defineComponent({
name: "Row",
description: "Horizontal row layout — arranges children left to right",
group: "Layout",
props: z.object({
children: z.array(z.unknown()).optional().describe("Child elements"),
gap: gapSchema,
align: alignSchema,
wrap: z.boolean().optional().describe("Allow wrapping to next line"),
}),
component: ({
props,
}: {
props: {
children?: React.ReactNode[];
gap?: string;
align?: string;
wrap?: boolean;
};
}) => (
<div
className={cn(
"flex flex-row",
gapMap[props.gap ?? "sm"],
props.align && alignMap[props.align],
props.wrap && "flex-wrap"
)}
>
{props.children}
</div>
),
});
export const columnComponent = defineComponent({
name: "Column",
description: "A column within a Row, with optional width control",
group: "Layout",
props: z.object({
children: z.array(z.unknown()).optional().describe("Child elements"),
width: z
.string()
.optional()
.describe("CSS width (e.g. '50%', '200px', 'auto')"),
}),
component: ({
props,
}: {
props: { children?: React.ReactNode[]; width?: string };
}) => (
<div
className="flex flex-col"
style={props.width ? { width: props.width } : undefined}
>
{props.children}
</div>
),
});

View File

@@ -1,43 +0,0 @@
import React from "react";
import { z } from "zod";
import { defineComponent } from "@onyx/genui";
import InlineExternalLink from "@/refresh-components/InlineExternalLink";
import Text from "@/refresh-components/texts/Text";
export const linkComponent = defineComponent({
name: "Link",
description: "A clickable hyperlink",
group: "Content",
props: z.object({
children: z.string().describe("Link text"),
href: z.string().describe("URL to link to"),
external: z.boolean().optional().describe("Open in new tab"),
}),
component: ({
props,
}: {
props: {
children: string;
href: string;
external?: boolean;
};
}) => {
if (props.external !== false) {
return (
<InlineExternalLink href={props.href}>
<Text mainContentBody text05 as="span" className="underline">
{props.children}
</Text>
</InlineExternalLink>
);
}
return (
<a href={props.href} className="underline">
<Text mainContentBody text05 as="span" className="underline">
{props.children}
</Text>
</a>
);
},
});

View File

@@ -1,45 +0,0 @@
import React from "react";
import { z } from "zod";
import { defineComponent } from "@onyx/genui";
import Text from "@/refresh-components/texts/Text";
export const listComponent = defineComponent({
name: "List",
description: "An ordered or unordered list",
group: "Content",
props: z.object({
items: z.array(z.string()).describe("List item texts"),
ordered: z
.boolean()
.optional()
.describe("Use numbered list instead of bullets"),
}),
component: ({
props,
}: {
props: {
items: string[];
ordered?: boolean;
};
}) => {
const Tag = props.ordered ? "ol" : "ul";
return (
<Tag
className={
props.ordered
? "list-decimal pl-6 space-y-1"
: "list-disc pl-6 space-y-1"
}
>
{(props.items ?? []).map((item, i) => (
<li key={i}>
<Text mainContentBody text05 as="span">
{item}
</Text>
</li>
))}
</Tag>
);
},
});

View File

@@ -1,103 +0,0 @@
import React from "react";
import { z } from "zod";
import { defineComponent, type ElementNode } from "@onyx/genui";
import Text from "@/refresh-components/texts/Text";
/**
* Lightweight table renderer for GenUI.
*
* We don't use DataTable here because it requires TanStack column definitions
* and typed data — overkill for LLM-generated tables. Instead we render a
* simple HTML table styled with Onyx design tokens.
*/
export const tableComponent = defineComponent({
name: "Table",
description: "A data table with columns and rows",
group: "Content",
props: z.object({
columns: z.array(z.string()).describe("Column header labels"),
rows: z
.array(z.array(z.unknown()))
.describe("Row data as arrays of values"),
compact: z.boolean().optional().describe("Use compact row height"),
}),
component: ({
props,
}: {
props: {
columns: string[];
rows: unknown[][];
compact?: boolean;
};
}) => {
const cellPadding = props.compact ? "px-3 py-1.5" : "px-3 py-2.5";
const columns = props.columns ?? [];
const rows = props.rows ?? [];
return (
<div className="w-full overflow-x-auto rounded-12 border border-border-01">
<table className="w-full border-collapse">
<thead>
<tr className="bg-background-neutral-01">
{columns.map((col, i) => (
<th
key={i}
className={`${cellPadding} text-left border-b border-border-01`}
>
<Text mainUiAction text03>
{col}
</Text>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIdx) => {
// Defensive: row might not be an array if resolver
// returned a rendered element or an object
const cells = Array.isArray(row) ? row : [row];
return (
<tr
key={rowIdx}
className="border-b border-border-01 last:border-b-0"
>
{cells.map((cell, cellIdx) => (
<td key={cellIdx} className={cellPadding}>
{renderCell(cell)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
);
},
});
function renderCell(cell: unknown): React.ReactNode {
// If it's a rendered React element (from NodeRenderer), return it directly
if (React.isValidElement(cell)) {
return cell;
}
// Primitive values → text
if (
typeof cell === "string" ||
typeof cell === "number" ||
typeof cell === "boolean"
) {
return (
<Text mainContentBody text05>
{String(cell)}
</Text>
);
}
return (
<Text mainContentBody text03>
</Text>
);
}

View File

@@ -1,46 +0,0 @@
import React from "react";
import { z } from "zod";
import { defineComponent } from "@onyx/genui";
import { Tag } from "@opal/components";
const VALID_TAG_COLORS = new Set<string>([
"green",
"purple",
"blue",
"gray",
"amber",
]);
type TagColor = "green" | "purple" | "blue" | "gray" | "amber";
export const tagComponent = defineComponent({
name: "Tag",
description: "A small label tag with color",
group: "Content",
props: z.object({
title: z.string().describe("Tag text"),
color: z
.enum(["green", "purple", "blue", "gray", "amber"])
.optional()
.describe("Tag color"),
size: z.enum(["sm", "md"]).optional().describe("Tag size"),
}),
component: ({
props,
}: {
props: {
title: string;
color?: string;
size?: "sm" | "md";
};
}) => {
const safeColor: TagColor =
props.color && VALID_TAG_COLORS.has(props.color)
? (props.color as TagColor)
: "gray";
return (
<Tag title={props.title ?? ""} color={safeColor} size={props.size} />
);
},
});

View File

@@ -1,63 +0,0 @@
import React from "react";
import { z } from "zod";
import { defineComponent } from "@onyx/genui";
import Text from "@/refresh-components/texts/Text";
export const textComponent = defineComponent({
name: "Text",
description: "Displays text with typography variants",
group: "Content",
props: z.object({
children: z.string().describe("The text content"),
headingH1: z.boolean().optional().describe("Heading level 1"),
headingH2: z.boolean().optional().describe("Heading level 2"),
headingH3: z.boolean().optional().describe("Heading level 3"),
muted: z.boolean().optional().describe("Muted/secondary style"),
mono: z.boolean().optional().describe("Monospace font"),
bold: z.boolean().optional().describe("Bold emphasis"),
}),
component: ({
props,
}: {
props: {
children: string;
headingH1?: boolean;
headingH2?: boolean;
headingH3?: boolean;
muted?: boolean;
mono?: boolean;
bold?: boolean;
};
}) => {
const as = props.headingH1
? ("p" as const)
: props.headingH2
? ("p" as const)
: props.headingH3
? ("p" as const)
: ("span" as const);
return (
<Text
as={as}
headingH1={props.headingH1}
headingH2={props.headingH2}
headingH3={props.headingH3}
mainContentMuted={props.muted}
mainContentMono={props.mono}
mainContentEmphasis={props.bold}
mainContentBody={
!props.headingH1 &&
!props.headingH2 &&
!props.headingH3 &&
!props.muted &&
!props.mono &&
!props.bold
}
text05
>
{props.children}
</Text>
);
},
});

View File

@@ -1,25 +0,0 @@
// ── Library ──
export { onyxLibrary } from "./library";
// ── Prompt addons ──
export { onyxPromptAddons } from "./prompt-addons";
// ── Individual component definitions (for selective use) ──
export { textComponent } from "./components/text";
export { buttonComponent } from "./components/button";
export { cardComponent } from "./components/card";
export { tagComponent } from "./components/tag";
export { tableComponent } from "./components/table";
export { inputComponent } from "./components/input";
export { iconButtonComponent } from "./components/icon-button";
export { codeComponent } from "./components/code";
export { dividerComponent } from "./components/divider";
export {
stackComponent,
rowComponent,
columnComponent,
} from "./components/layout";
export { imageComponent } from "./components/image";
export { linkComponent } from "./components/link";
export { alertComponent } from "./components/alert";
export { listComponent } from "./components/list";

View File

@@ -1,62 +0,0 @@
import { createLibrary } from "@onyx/genui";
import { onyxPromptAddons } from "./prompt-addons";
// Component definitions (real React bindings)
import { textComponent } from "./components/text";
import { buttonComponent } from "./components/button";
import { cardComponent } from "./components/card";
import { tagComponent } from "./components/tag";
import { tableComponent } from "./components/table";
import { inputComponent } from "./components/input";
import { iconButtonComponent } from "./components/icon-button";
import { codeComponent } from "./components/code";
import { dividerComponent } from "./components/divider";
import {
stackComponent,
rowComponent,
columnComponent,
} from "./components/layout";
import { imageComponent } from "./components/image";
import { linkComponent } from "./components/link";
import { alertComponent } from "./components/alert";
import { listComponent } from "./components/list";
/**
* The assembled Onyx component library for GenUI.
*
* All components are bound to real Opal / refresh-components React components.
*/
export const onyxLibrary = createLibrary(
[
// Layout
stackComponent,
rowComponent,
columnComponent,
cardComponent,
dividerComponent,
// Content
textComponent,
tagComponent,
tableComponent,
codeComponent,
imageComponent,
linkComponent,
listComponent,
// Interactive
buttonComponent,
iconButtonComponent,
inputComponent,
// Feedback
alertComponent,
],
{
defaultPromptOptions: {
streaming: true,
additionalRules: onyxPromptAddons.rules,
examples: onyxPromptAddons.examples,
},
}
);

View File

@@ -1,45 +0,0 @@
/**
* Onyx-specific prompt rules and examples that augment the
* auto-generated component documentation.
*/
export const onyxPromptAddons = {
rules: [
"Use Stack for vertical layouts and Row for horizontal layouts",
"For tables, pass column headers as a string array and rows as arrays of values",
"Tags are great for showing status, categories, or labels inline",
"Use Alert for important status messages — choose the right level (info, success, warning, error)",
"Buttons need an actionId to trigger events — the UI framework handles the callback",
"Keep layouts simple — prefer flat structures over deeply nested ones",
"For search results or document lists, use Table with relevant columns",
"Use Card to visually group related content",
],
examples: [
{
description: "Search results with table",
code: `title = Text("Search Results", headingH2: true)
row1 = ["Onyx Docs", Tag("PDF", color: "blue"), "2024-01-15"]
row2 = ["API Guide", Tag("MD", color: "green"), "2024-02-01"]
results = Table(["Name", "Type", "Date"], [row1, row2])
action = Button("View All", main: true, primary: true, actionId: "viewAll")
root = Stack([title, results, action], gap: "md")`,
},
{
description: "Status card with actions",
code: `status = Alert("Pipeline completed successfully", level: "success")
stats = Row([
Text("Processed: 1,234 docs"),
Text("Duration: 2m 34s", muted: true)
], gap: "lg")
actions = Row([
Button("View Results", main: true, primary: true, actionId: "viewResults"),
Button("Run Again", action: true, secondary: true, actionId: "rerun")
], gap: "sm")
root = Stack([status, stats, actions], gap: "md")`,
},
{
description: "Simple info display",
code: `root = Card(title: "Document Summary")`,
},
],
};

View File

@@ -1,27 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist", "**/*.test.ts"],
"references": [
{ "path": "../core" },
{ "path": "../react" }
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +0,0 @@
{
"name": "@onyx/genui-react",
"version": "0.1.0",
"private": true,
"description": "React renderer for GenUI structured UI output",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
}
}

View File

@@ -1,56 +0,0 @@
import React from "react";
interface ErrorBoundaryProps {
componentName: string;
children: React.ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
/**
* Per-component error boundary.
* Prevents a single broken component from crashing the entire GenUI output.
*/
export class ErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
<div
style={{
padding: "8px 12px",
border: "1px solid #ef4444",
borderRadius: "4px",
backgroundColor: "#fef2f2",
color: "#991b1b",
fontSize: "13px",
fontFamily: "monospace",
}}
>
<strong>{this.props.componentName}</strong> failed to render
{this.state.error && (
<div style={{ marginTop: 4, opacity: 0.8 }}>
{this.state.error.message}
</div>
)}
</div>
);
}
return this.props.children;
}
}

View File

@@ -1,44 +0,0 @@
import React from "react";
import { render } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { FallbackRenderer } from "./FallbackRenderer";
describe("FallbackRenderer", () => {
it("renders plain text content", () => {
const { container } = render(
<FallbackRenderer content="Hello, this is plain text." />
);
expect(container.textContent).toContain("Hello, this is plain text.");
});
it("splits paragraphs on double newlines", () => {
const { container } = render(
<FallbackRenderer content={"First paragraph.\n\nSecond paragraph."} />
);
const paragraphs = container.querySelectorAll("p");
expect(paragraphs.length).toBe(2);
expect(paragraphs[0]!.textContent).toBe("First paragraph.");
expect(paragraphs[1]!.textContent).toBe("Second paragraph.");
});
it("renders code blocks in pre/code tags", () => {
const { container } = render(
<FallbackRenderer content={"```js\nconsole.log('hi');\n```"} />
);
const pre = container.querySelector("pre");
expect(pre).not.toBeNull();
const code = container.querySelector("code");
expect(code!.textContent).toBe("console.log('hi');");
});
it("handles empty content", () => {
const { container } = render(<FallbackRenderer content="" />);
// Should render without crashing
expect(container).toBeDefined();
});
it("handles content with only newlines", () => {
const { container } = render(<FallbackRenderer content="\n\n\n" />);
expect(container).toBeDefined();
});
});

View File

@@ -1,50 +0,0 @@
import React from "react";
interface FallbackRendererProps {
content: string;
}
/**
* Fallback renderer for responses that aren't valid GenUI Lang.
* Renders as plain text with basic formatting.
*
* In the Onyx integration, this would be replaced with the existing
* markdown renderer. This is a minimal standalone fallback.
*/
export function FallbackRenderer({ content }: FallbackRendererProps) {
// Split into paragraphs, preserving code blocks
const blocks = content.split(/\n\n+/);
return (
<div style={{ whiteSpace: "pre-wrap", lineHeight: 1.6 }}>
{blocks.map((block, i) => {
// Code blocks
if (block.startsWith("```")) {
const lines = block.split("\n");
const code = lines.slice(1, -1).join("\n");
return (
<pre
key={i}
style={{
backgroundColor: "#f3f4f6",
padding: "12px",
borderRadius: "6px",
overflow: "auto",
fontSize: "13px",
fontFamily: "monospace",
}}
>
<code>{code}</code>
</pre>
);
}
return (
<p key={i} style={{ margin: "0 0 1em 0" }}>
{block}
</p>
);
})}
</div>
);
}

View File

@@ -1,156 +0,0 @@
import React from "react";
import type { ElementNode } from "@onyx/genui";
import { useLibrary } from "./context";
import { ErrorBoundary } from "./ErrorBoundary";
interface NodeRendererProps {
node: ElementNode;
}
/**
* Check if a value is an ElementNode (has kind: "element" and component string).
*/
function isElementNode(value: unknown): value is ElementNode {
return (
typeof value === "object" &&
value !== null &&
"kind" in value &&
(value as Record<string, unknown>)["kind"] === "element" &&
"component" in value
);
}
/**
* Recursively resolve prop values — any ElementNode found in props
* (or nested in arrays) gets rendered to a React element.
*/
function resolvePropsForRender(
props: Record<string, unknown>,
library: ReturnType<typeof useLibrary>
): Record<string, unknown> {
const resolved: Record<string, unknown> = {};
for (const [key, value] of Object.entries(props)) {
resolved[key] = resolveValue(value, library);
}
return resolved;
}
/**
* Check if a value is an unresolved reference placeholder from the parser.
*/
function isUnresolvedRef(value: unknown): value is { __ref: string } {
return (
typeof value === "object" &&
value !== null &&
"__ref" in value &&
typeof (value as Record<string, unknown>)["__ref"] === "string"
);
}
function resolveValue(
value: unknown,
library: ReturnType<typeof useLibrary>
): unknown {
if (isElementNode(value)) {
return <NodeRenderer node={value} />;
}
// Unresolved variable references — render as placeholder text
if (isUnresolvedRef(value)) {
return null;
}
if (Array.isArray(value)) {
return value.map((item, i) => {
if (isElementNode(item)) {
return <NodeRenderer key={i} node={item} />;
}
if (isUnresolvedRef(item)) {
return null;
}
// Recurse into nested arrays
if (Array.isArray(item)) {
return resolveValue(item, library);
}
// Guard against any other non-renderable objects
if (
typeof item === "object" &&
item !== null &&
!React.isValidElement(item)
) {
return null;
}
return item;
});
}
// Guard against any other plain objects that React can't render
if (
typeof value === "object" &&
value !== null &&
!React.isValidElement(value)
) {
return null;
}
return value;
}
/**
* Recursively renders an ElementNode by looking up the component
* in the library and passing validated props.
*/
export function NodeRenderer({ node }: NodeRendererProps) {
const library = useLibrary();
// Handle unresolved references
if (node.component === "__Unresolved") {
return (
<span style={{ opacity: 0.5, fontStyle: "italic" }}>
{String(node.props["name"] ?? "...")}
</span>
);
}
const def = library.resolve(node.component);
if (!def) {
// Unknown component — render children or show placeholder
if (node.children.length > 0) {
return (
<>
{node.children.map((child, i) => (
<NodeRenderer key={i} node={child} />
))}
</>
);
}
return (
<span style={{ color: "#9ca3af", fontStyle: "italic" }}>
[{node.component}]
</span>
);
}
// Resolve ElementNodes within props into rendered React elements
const resolvedProps = resolvePropsForRender(node.props, library);
// Render explicit children from node.children
const renderedChildren =
node.children.length > 0
? node.children.map((child, i) => <NodeRenderer key={i} node={child} />)
: undefined;
const Component = def.component as React.FC<{
props: Record<string, unknown>;
children?: React.ReactNode;
}>;
return (
<ErrorBoundary componentName={node.component}>
<Component props={resolvedProps}>{renderedChildren}</Component>
</ErrorBoundary>
);
}

View File

@@ -1,263 +0,0 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { z } from "zod";
import { defineComponent, createLibrary } from "@onyx/genui";
import type { Library, ActionEvent } from "@onyx/genui";
import { Renderer } from "./Renderer";
/**
* Create a test library with simple React components.
* Each component renders its props as data attributes for easy assertion.
*/
function makeTestLibrary(): Library {
return createLibrary([
defineComponent({
name: "Text",
description: "Text display",
props: z.object({
children: z.string(),
headingH2: z.boolean().optional(),
}),
component: ({
props,
}: {
props: { children: string; headingH2?: boolean };
}) => {
const Tag = props.headingH2 ? "h2" : "span";
return <Tag data-testid="text">{props.children}</Tag>;
},
}),
defineComponent({
name: "Button",
description: "Interactive button",
props: z.object({
children: z.string(),
main: z.boolean().optional(),
primary: z.boolean().optional(),
actionId: z.string().optional(),
}),
component: ({
props,
}: {
props: {
children: string;
main?: boolean;
primary?: boolean;
actionId?: string;
};
}) => (
<button data-testid="button" data-action-id={props.actionId}>
{props.children}
</button>
),
}),
defineComponent({
name: "Stack",
description: "Vertical layout",
props: z.object({
children: z.array(z.unknown()).optional(),
gap: z.enum(["none", "xs", "sm", "md", "lg", "xl"]).optional(),
}),
component: ({
props,
}: {
props: { children?: unknown[]; gap?: string };
}) => (
<div data-testid="stack" data-gap={props.gap}>
{props.children}
</div>
),
}),
defineComponent({
name: "Tag",
description: "Label tag",
props: z.object({
title: z.string(),
color: z.enum(["green", "purple", "blue", "gray", "amber"]).optional(),
}),
component: ({ props }: { props: { title: string; color?: string } }) => (
<span data-testid="tag" data-color={props.color}>
{props.title}
</span>
),
}),
]);
}
describe("Renderer", () => {
it("returns null for null response", () => {
const lib = makeTestLibrary();
const { container } = render(<Renderer response={null} library={lib} />);
expect(container.innerHTML).toBe("");
});
it("renders a simple Text component", () => {
const lib = makeTestLibrary();
render(<Renderer response='root = Text("Hello World")' library={lib} />);
expect(screen.getByTestId("text")).toHaveTextContent("Hello World");
});
it("renders a component with named args", () => {
const lib = makeTestLibrary();
render(
<Renderer response='root = Tag("Status", color: "green")' library={lib} />
);
const tag = screen.getByTestId("tag");
expect(tag).toHaveTextContent("Status");
expect(tag.dataset["color"]).toBe("green");
});
it("renders nested components via variable references", () => {
const lib = makeTestLibrary();
const input = `title = Text("Hello", headingH2: true)
btn = Button("Click me")
root = Stack([title, btn], gap: "md")`;
render(<Renderer response={input} library={lib} />);
expect(screen.getByTestId("stack")).toBeInTheDocument();
expect(screen.getByTestId("text")).toHaveTextContent("Hello");
expect(screen.getByTestId("button")).toHaveTextContent("Click me");
});
it("falls back to plain text for non-GenUI responses", () => {
const lib = makeTestLibrary();
const { container } = render(
<Renderer
response="Just a plain text response with no components."
library={lib}
fallbackToMarkdown={true}
/>
);
expect(container.textContent).toContain("Just a plain text response");
});
it("returns null for non-GenUI when fallback disabled", () => {
const lib = makeTestLibrary();
const { container } = render(
<Renderer
response="plain text"
library={lib}
fallbackToMarkdown={false}
/>
);
// Should render nothing meaningful (no parsed root, no fallback)
// The div wrapper is still rendered
const wrapper = container.firstChild as HTMLElement;
// Inner content should be empty or minimal
expect(wrapper.textContent).toBe("");
});
it("applies className to wrapper div", () => {
const lib = makeTestLibrary();
const { container } = render(
<Renderer
response='root = Text("test")'
library={lib}
className="my-custom-class"
/>
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.classList.contains("my-custom-class")).toBe(true);
});
it("renders unknown components with placeholder", () => {
const lib = makeTestLibrary();
// Unknown component with no children renders as [ComponentName]
const { container } = render(
<Renderer response="root = UnknownWidget()" library={lib} />
);
expect(container.textContent).toContain("[UnknownWidget]");
});
it("handles the full spec example", () => {
const lib = makeTestLibrary();
const input = `title = Text("Search Results", headingH2: true)
btn = Button("View All", main: true, primary: true, actionId: "viewAll")
root = Stack([title, btn], gap: "md")`;
render(<Renderer response={input} library={lib} />);
const heading = screen.getByTestId("text");
expect(heading.tagName).toBe("H2");
expect(heading).toHaveTextContent("Search Results");
const button = screen.getByTestId("button");
expect(button).toHaveTextContent("View All");
expect(button.dataset["actionId"]).toBe("viewAll");
const stack = screen.getByTestId("stack");
expect(stack.dataset["gap"]).toBe("md");
});
});
describe("Renderer — Error Boundary", () => {
it("catches component render errors without crashing", () => {
const lib = createLibrary([
defineComponent({
name: "Broken",
description: "Always throws",
props: z.object({ children: z.string() }),
component: () => {
throw new Error("Intentional test error");
},
}),
]);
// Should not throw — error boundary catches it
const { container } = render(
<Renderer response='root = Broken("crash")' library={lib} />
);
expect(container.textContent).toContain("failed to render");
});
});
describe("Renderer — Streaming simulation", () => {
it("re-renders as response grows", () => {
const lib = makeTestLibrary();
// Start with partial response
const { rerender, container } = render(
<Renderer response='title = Text("Hel' library={lib} isStreaming={true} />
);
// Complete the response
rerender(
<Renderer
response='title = Text("Hello World")\n'
library={lib}
isStreaming={false}
/>
);
expect(screen.getByTestId("text")).toHaveTextContent("Hello World");
});
it("handles response reset (regeneration)", () => {
const lib = makeTestLibrary();
// First response
const { rerender } = render(
<Renderer
response='root = Text("First")\n'
library={lib}
isStreaming={false}
/>
);
expect(screen.getByTestId("text")).toHaveTextContent("First");
// New response (shorter — indicates reset)
rerender(
<Renderer
response='root = Text("New")\n'
library={lib}
isStreaming={false}
/>
);
expect(screen.getByTestId("text")).toHaveTextContent("New");
});
});

View File

@@ -1,52 +0,0 @@
import React from "react";
import type { Library, ActionEvent } from "@onyx/genui";
import { LibraryContext, StreamingContext, ActionContext } from "./context";
import { StreamingRenderer } from "./StreamingRenderer";
export interface RendererProps {
/** Raw GenUI Lang string from the LLM */
response: string | null;
/** Component library to render with */
library: Library;
/** Is the LLM still generating? */
isStreaming?: boolean;
/** Callback for interactive component events */
onAction?: (event: ActionEvent) => void;
/** Fall back to plain text for non-parseable responses */
fallbackToMarkdown?: boolean;
/** CSS class for the wrapper element */
className?: string;
}
/**
* Main entry point for rendering GenUI Lang output.
*
* Wraps the streaming renderer with all required contexts.
*/
export function Renderer({
response,
library,
isStreaming = false,
onAction,
fallbackToMarkdown = true,
className,
}: RendererProps) {
if (!response) return null;
return (
<LibraryContext.Provider value={library}>
<StreamingContext.Provider value={{ isStreaming }}>
<ActionContext.Provider value={onAction ?? null}>
<div className={className}>
<StreamingRenderer
response={response}
library={library}
isStreaming={isStreaming}
fallbackToMarkdown={fallbackToMarkdown}
/>
</div>
</ActionContext.Provider>
</StreamingContext.Provider>
</LibraryContext.Provider>
);
}

View File

@@ -1,73 +0,0 @@
import React, { useRef, useMemo } from "react";
import { createStreamingParser } from "@onyx/genui";
import type { Library, ElementNode } from "@onyx/genui";
import { NodeRenderer } from "./NodeRenderer";
import { FallbackRenderer } from "./FallbackRenderer";
interface StreamingRendererProps {
response: string;
library: Library;
isStreaming: boolean;
fallbackToMarkdown?: boolean;
}
/**
* Manages a StreamParser instance and feeds it the response string.
* Re-parses on each update and renders the resulting element tree.
*/
export function StreamingRenderer({
response,
library,
isStreaming,
fallbackToMarkdown = true,
}: StreamingRendererProps) {
const lastResponseLenRef = useRef(0);
// Create parser once per library identity
const parser = useMemo(() => {
lastResponseLenRef.current = 0;
return createStreamingParser(library);
}, [library]);
// Feed new chunks to the parser
if (response.length > lastResponseLenRef.current) {
const newChunk = response.slice(lastResponseLenRef.current);
parser.push(newChunk);
lastResponseLenRef.current = response.length;
} else if (response.length < lastResponseLenRef.current) {
// Response was reset (e.g. regeneration)
parser.reset();
if (response.length > 0) {
parser.push(response);
}
lastResponseLenRef.current = response.length;
}
const result = parser.result();
// If parsing produced no root and fallback is enabled, render as plain text
if (!result.root && fallbackToMarkdown) {
return <FallbackRenderer content={response} />;
}
if (!result.root) {
return null;
}
// The root from ParseResult is typed as ASTNode but after validation
// it's actually an ElementNode. Cast safely.
const rootElement = result.root as unknown as ElementNode;
if (rootElement.kind !== "element") {
if (fallbackToMarkdown) {
return <FallbackRenderer content={response} />;
}
return null;
}
return (
<div data-genui-root="true">
<NodeRenderer node={rootElement} />
</div>
);
}

View File

@@ -1,36 +0,0 @@
import { createContext, useContext } from "react";
import type { Library, ActionEvent } from "@onyx/genui";
// ── Library Context ──
export const LibraryContext = createContext<Library | null>(null);
export function useLibrary(): Library {
const library = useContext(LibraryContext);
if (!library) {
throw new Error(
"useLibrary must be used within a <Renderer> or <LibraryContext.Provider>"
);
}
return library;
}
// ── Streaming Context ──
export interface StreamingState {
isStreaming: boolean;
}
export const StreamingContext = createContext<StreamingState>({
isStreaming: false,
});
// ── Action Context ──
export type ActionHandler = (event: ActionEvent) => void;
export const ActionContext = createContext<ActionHandler | null>(null);
export function useActionHandler(): ActionHandler | null {
return useContext(ActionContext);
}

View File

@@ -1,80 +0,0 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { useIsStreaming, useTriggerAction } from "./hooks";
import { StreamingContext, ActionContext } from "./context";
function StreamingIndicator() {
const isStreaming = useIsStreaming();
return <span data-testid="streaming">{isStreaming ? "yes" : "no"}</span>;
}
function ActionButton({ actionId }: { actionId: string }) {
const trigger = useTriggerAction();
return (
<button
data-testid="action-btn"
onClick={() => trigger(actionId, { extra: "data" })}
>
Fire
</button>
);
}
describe("useIsStreaming", () => {
it("returns false by default", () => {
render(<StreamingIndicator />);
expect(screen.getByTestId("streaming")).toHaveTextContent("no");
});
it("returns true when streaming context is true", () => {
render(
<StreamingContext.Provider value={{ isStreaming: true }}>
<StreamingIndicator />
</StreamingContext.Provider>
);
expect(screen.getByTestId("streaming")).toHaveTextContent("yes");
});
it("returns false when streaming context is false", () => {
render(
<StreamingContext.Provider value={{ isStreaming: false }}>
<StreamingIndicator />
</StreamingContext.Provider>
);
expect(screen.getByTestId("streaming")).toHaveTextContent("no");
});
});
describe("useTriggerAction", () => {
it("calls action handler with actionId and payload", () => {
const handler = vi.fn();
render(
<ActionContext.Provider value={handler}>
<ActionButton actionId="test-action" />
</ActionContext.Provider>
);
fireEvent.click(screen.getByTestId("action-btn"));
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledWith({
actionId: "test-action",
payload: { extra: "data" },
});
});
it("does nothing when no action handler is provided", () => {
// Should not throw
render(
<ActionContext.Provider value={null}>
<ActionButton actionId="orphan" />
</ActionContext.Provider>
);
expect(() => {
fireEvent.click(screen.getByTestId("action-btn"));
}).not.toThrow();
});
});

View File

@@ -1,31 +0,0 @@
import { useContext, useCallback } from "react";
import type { ActionEvent } from "@onyx/genui";
import { StreamingContext, ActionContext } from "./context";
/**
* Returns true while the LLM is still generating output.
*/
export function useIsStreaming(): boolean {
return useContext(StreamingContext).isStreaming;
}
/**
* Returns a function to trigger an action event (e.g. button click).
* Components call this with an actionId and optional payload.
*/
export function useTriggerAction(): (
actionId: string,
payload?: Record<string, unknown>
) => void {
const handler = useContext(ActionContext);
return useCallback(
(actionId: string, payload?: Record<string, unknown>) => {
if (handler) {
const event: ActionEvent = { actionId, payload };
handler(event);
}
},
[handler]
);
}

View File

@@ -1,22 +0,0 @@
// ── Main entry point ──
export { Renderer } from "./Renderer";
export type { RendererProps } from "./Renderer";
// ── Sub-components ──
export { StreamingRenderer } from "./StreamingRenderer";
export { NodeRenderer } from "./NodeRenderer";
export { FallbackRenderer } from "./FallbackRenderer";
export { ErrorBoundary } from "./ErrorBoundary";
// ── Contexts ──
export {
LibraryContext,
StreamingContext,
ActionContext,
useLibrary,
useActionHandler,
} from "./context";
export type { StreamingState, ActionHandler } from "./context";
// ── Hooks ──
export { useIsStreaming, useTriggerAction } from "./hooks";

View File

@@ -1,7 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});

View File

@@ -1,26 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"],
"references": [
{ "path": "../core" }
]
}

View File

@@ -1,15 +0,0 @@
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
environment: "jsdom",
setupFiles: ["src/test-setup.ts"],
},
resolve: {
alias: {
"@onyx/genui": path.resolve(__dirname, "../core/src"),
},
},
});

View File

@@ -19,12 +19,7 @@ const cspHeader = `
const nextConfig = {
productionBrowserSourceMaps: false,
output: "standalone",
transpilePackages: [
"@onyx/opal",
"@onyx/genui",
"@onyx/genui-react",
"@onyx/genui-onyx",
],
transpilePackages: ["@onyx/opal"],
typedRoutes: true,
reactCompiler: true,
images: {

83
web/package-lock.json generated
View File

@@ -18,9 +18,6 @@
"@emotion/stylis": "^0.8.5",
"@headlessui/react": "^2.2.0",
"@headlessui/tailwindcss": "^0.2.1",
"@onyx/genui": "./lib/genui-core",
"@onyx/genui-onyx": "./lib/genui-onyx",
"@onyx/genui-react": "./lib/genui-react",
"@onyx/opal": "./lib/opal",
"@phosphor-icons/react": "^2.0.8",
"@radix-ui/react-accordion": "^1.2.2",
@@ -136,70 +133,6 @@
"whatwg-fetch": "^3.6.20"
}
},
"../packages/genui/core": {
"name": "@onyx/genui",
"version": "0.1.0",
"extraneous": true,
"dependencies": {
"zod": "^3.23.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"vitest": "^1.6.0"
}
},
"../packages/genui/onyx": {
"name": "@onyx/genui-onyx",
"version": "0.1.0",
"extraneous": true,
"dependencies": {
"@onyx/genui": "../core",
"@onyx/genui-react": "../react"
},
"devDependencies": {
"@types/react": "^19.0.0",
"react": "^19.0.0",
"typescript": "^5.4.0",
"vitest": "^1.6.0",
"zod": "^3.23.0"
}
},
"../packages/genui/react": {
"name": "@onyx/genui-react",
"version": "0.1.0",
"extraneous": true,
"dependencies": {
"@onyx/genui": "../core"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"jsdom": "^28.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.4.0",
"vitest": "^1.6.1",
"zod": "^3.25.76"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"lib/genui-core": {
"name": "@onyx/genui",
"version": "0.1.0"
},
"lib/genui-onyx": {
"name": "@onyx/genui-onyx",
"version": "0.1.0"
},
"lib/genui-react": {
"name": "@onyx/genui-react",
"version": "0.1.0"
},
"lib/opal": {
"name": "@onyx/opal",
"version": "0.0.1"
@@ -3173,18 +3106,6 @@
"node": ">=12.4.0"
}
},
"node_modules/@onyx/genui": {
"resolved": "lib/genui-core",
"link": true
},
"node_modules/@onyx/genui-onyx": {
"resolved": "lib/genui-onyx",
"link": true
},
"node_modules/@onyx/genui-react": {
"resolved": "lib/genui-react",
"link": true
},
"node_modules/@onyx/opal": {
"resolved": "lib/opal",
"link": true
@@ -7830,9 +7751,7 @@
"peer": true
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"version": "8.15.0",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"

View File

@@ -36,9 +36,6 @@
"@emotion/stylis": "^0.8.5",
"@headlessui/react": "^2.2.0",
"@headlessui/tailwindcss": "^0.2.1",
"@onyx/genui": "./lib/genui-core",
"@onyx/genui-onyx": "./lib/genui-onyx",
"@onyx/genui-react": "./lib/genui-react",
"@onyx/opal": "./lib/opal",
"@phosphor-icons/react": "^2.0.8",
"@radix-ui/react-accordion": "^1.2.2",

View File

@@ -464,7 +464,7 @@ export default function VoiceConfigurationPage() {
return (
<>
<AdminPageTitle
title="Voice Mode"
title="Voice"
icon={SvgMicrophone}
includeDivider={false}
/>
@@ -484,7 +484,7 @@ export default function VoiceConfigurationPage() {
return (
<>
<AdminPageTitle
title="Voice Mode"
title="Voice"
icon={SvgMicrophone}
includeDivider={false}
/>
@@ -497,7 +497,7 @@ export default function VoiceConfigurationPage() {
return (
<>
<AdminPageTitle icon={SvgAudio} title="Voice Mode" />
<AdminPageTitle icon={SvgAudio} title="Voice" />
<div className="pt-4 pb-4">
<Text as="p" secondaryBody text03>
Speech to text (STT) and text to speech (TTS) capabilities.

View File

@@ -1,9 +1,7 @@
import React, { JSX, memo } from "react";
import { useGenUIViewStore } from "../../stores/useGenUIViewStore";
import {
ChatPacket,
CODE_INTERPRETER_TOOL_TYPES,
GenUIPacket,
ImageGenerationToolPacket,
Packet,
PacketType,
@@ -16,6 +14,7 @@ import {
FullChatState,
MessageRenderer,
RenderType,
RendererResult,
RendererOutput,
} from "./interfaces";
import { MessageTextRenderer } from "./renderers/MessageTextRenderer";
@@ -30,7 +29,6 @@ import { DeepResearchPlanRenderer } from "./timeline/renderers/deepresearch/Deep
import { ResearchAgentRenderer } from "./timeline/renderers/deepresearch/ResearchAgentRenderer";
import { WebSearchToolRenderer } from "./timeline/renderers/search/WebSearchToolRenderer";
import { InternalSearchToolRenderer } from "./timeline/renderers/search/InternalSearchToolRenderer";
import { GenUIRenderer } from "./renderers/GenUIRenderer";
// Different types of chat packets using discriminated unions
interface GroupedPackets {
@@ -103,10 +101,6 @@ function isDeepResearchPlanPacket(packet: Packet) {
);
}
function isGenUIPacket(packet: Packet) {
return packet.obj.type === PacketType.GENUI_START;
}
function isResearchAgentPacket(packet: Packet) {
// Check for any packet type that indicates a research agent group
return (
@@ -161,13 +155,6 @@ export function findRenderer(
if (groupedPackets.packets.some((packet) => isMemoryToolPacket(packet))) {
return MemoryToolRenderer;
}
// GenUI must be checked BEFORE reasoning because isReasoningPacket matches
// SECTION_END/ERROR (shared packet types injected into all groups on STOP).
// Without this ordering, GenUI groups get misrouted to ReasoningRenderer
// once the STOP packet injects SECTION_END.
if (groupedPackets.packets.some((packet) => isGenUIPacket(packet))) {
return GenUIRenderer;
}
if (groupedPackets.packets.some((packet) => isReasoningPacket(packet))) {
return ReasoningRenderer;
}
@@ -227,67 +214,6 @@ function MixedContentHandler({
);
}
// Handles display groups containing both chat text and GenUI packets.
// Which view is active is controlled by the external showGenUI prop
// (toggle lives in MessageToolbar).
function GenUIToggleHandler({
chatPackets,
genuiPackets,
showGenUI,
chatState,
messageNodeId,
hasTimelineThinking,
onComplete,
animate,
stopPacketSeen,
stopReason,
children,
}: {
chatPackets: Packet[];
genuiPackets: Packet[];
showGenUI: boolean;
chatState: FullChatState;
messageNodeId?: number;
hasTimelineThinking?: boolean;
onComplete: () => void;
animate: boolean;
stopPacketSeen: boolean;
stopReason?: StopReason;
children: (result: RendererOutput) => JSX.Element;
}) {
if (showGenUI) {
return (
<GenUIRenderer
packets={genuiPackets as GenUIPacket[]}
state={chatState}
onComplete={onComplete}
animate={animate}
renderType={RenderType.FULL}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
>
{children}
</GenUIRenderer>
);
}
return (
<MessageTextRenderer
packets={chatPackets as ChatPacket[]}
state={chatState}
messageNodeId={messageNodeId}
hasTimelineThinking={hasTimelineThinking}
onComplete={onComplete}
animate={animate}
renderType={RenderType.FULL}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
>
{children}
</MessageTextRenderer>
);
}
// Props interface for RendererComponent
interface RendererComponentProps {
packets: Packet[];
@@ -329,53 +255,10 @@ export const RendererComponent = memo(function RendererComponent({
stopReason,
children,
}: RendererComponentProps) {
const structuredViewEnabled = useGenUIViewStore(
(s) => s.structuredViewEnabled
);
// Detect mixed display groups
// Detect mixed display groups (both chat text and image generation)
const hasChatPackets = packets.some((p) => isChatPacket(p));
const hasImagePackets = packets.some((p) => isImageToolPacket(p));
const hasGenUIPackets = packets.some((p) => isGenUIPacket(p));
// Mixed chat + GenUI: show text by default with toggle to GenUI
if (hasChatPackets && hasGenUIPackets) {
const sharedTypes = new Set<string>([
PacketType.SECTION_END,
PacketType.ERROR,
]);
const chatOnly = packets.filter(
(p) =>
isChatPacket(p) ||
p.obj.type === PacketType.CITATION_INFO ||
sharedTypes.has(p.obj.type as string)
);
const genuiOnly = packets.filter(
(p) =>
isGenUIPacket(p) ||
p.obj.type === PacketType.GENUI_DELTA ||
sharedTypes.has(p.obj.type as string)
);
return (
<GenUIToggleHandler
chatPackets={chatOnly}
genuiPackets={genuiOnly}
showGenUI={structuredViewEnabled}
chatState={chatState}
messageNodeId={messageNodeId}
hasTimelineThinking={hasTimelineThinking}
onComplete={onComplete}
animate={animate}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
>
{children}
</GenUIToggleHandler>
);
}
// Mixed chat + image generation
if (hasChatPackets && hasImagePackets) {
const sharedTypes = new Set<string>([
PacketType.SECTION_END,

View File

@@ -1,123 +0,0 @@
"use client";
import React, { useEffect } from "react";
import {
PacketType,
GenUIPacket,
GenUIDelta,
} from "../../../services/streamingModels";
import { MessageRenderer, RenderType } from "../interfaces";
import { Renderer } from "@onyx/genui-react";
import { onyxLibrary } from "@onyx/genui-onyx";
/**
* Strip markdown code fences that some LLMs wrap around GenUI output.
* Handles ```genui, ```\n, etc.
*/
function stripCodeFences(raw: string): string {
let s = raw;
// Strip opening fence: ```genui\n or ```\n
s = s.replace(/^```[a-zA-Z]*\s*\n?/, "");
// Strip closing fence at end
s = s.replace(/\n?```\s*$/, "");
return s;
}
function extractGenUIContent(packets: GenUIPacket[]): {
content: string;
isComplete: boolean;
} {
const deltas = packets
.filter((p) => p.obj.type === PacketType.GENUI_DELTA)
.map((p) => p.obj as GenUIDelta);
const hasEnd = packets.some(
(p) =>
p.obj.type === PacketType.SECTION_END || p.obj.type === PacketType.ERROR
);
const raw = deltas.map((d) => d.content).join("");
const content = stripCodeFences(raw);
return { content, isComplete: hasEnd };
}
function GeneratingIndicator() {
return (
<div className="flex items-center gap-2 py-4 text-text-03">
<div className="flex gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-text-04 animate-bounce [animation-delay:0ms]" />
<span className="w-1.5 h-1.5 rounded-full bg-text-04 animate-bounce [animation-delay:150ms]" />
<span className="w-1.5 h-1.5 rounded-full bg-text-04 animate-bounce [animation-delay:300ms]" />
</div>
<span className="text-sm">Generating structured view...</span>
</div>
);
}
export const GenUIRenderer: MessageRenderer<GenUIPacket, {}> = ({
packets,
onComplete,
renderType,
stopPacketSeen,
children,
}) => {
const { content, isComplete } = extractGenUIContent(packets);
// GenUI responses may not receive an explicit SECTION_END from the backend
// (content streams end with the overall STOP packet). Treat stopPacketSeen
// as an alternative completion signal.
const effectiveComplete = isComplete || stopPacketSeen;
useEffect(() => {
if (effectiveComplete) {
onComplete();
}
}, [effectiveComplete, onComplete]);
const isStreaming = !effectiveComplete;
// During generation, show a loading indicator instead of streaming
// partial GenUI content (which can look broken mid-parse).
if (isStreaming) {
return children([
{
icon: null,
status: renderType === RenderType.FULL ? null : "Generating...",
content: <GeneratingIndicator />,
},
]);
}
if (renderType === RenderType.FULL) {
return children([
{
icon: null,
status: null,
content: (
<Renderer
response={content || null}
library={onyxLibrary}
isStreaming={false}
fallbackToMarkdown
/>
),
},
]);
}
return children([
{
icon: null,
status: "Generated UI",
content: (
<Renderer
response={content || null}
library={onyxLibrary}
isStreaming={false}
fallbackToMarkdown
/>
),
},
]);
};

View File

@@ -149,7 +149,6 @@ const CONTENT_PACKET_TYPES_SET = new Set<PacketType>([
PacketType.REASONING_START,
PacketType.DEEP_RESEARCH_PLAN_START,
PacketType.RESEARCH_AGENT_START,
PacketType.GENUI_START,
]);
function hasContentPackets(packets: Packet[]): boolean {
@@ -173,8 +172,6 @@ const FINAL_ANSWER_PACKET_TYPES_SET = new Set<PacketType>([
PacketType.MESSAGE_DELTA,
PacketType.IMAGE_GENERATION_TOOL_START,
PacketType.IMAGE_GENERATION_TOOL_DELTA,
PacketType.GENUI_START,
PacketType.GENUI_DELTA,
]);
// ============================================================================

View File

@@ -60,8 +60,7 @@ export function isActualToolCallPacket(packet: Packet): boolean {
export function isDisplayPacket(packet: Packet) {
return (
packet.obj.type === PacketType.MESSAGE_START ||
packet.obj.type === PacketType.IMAGE_GENERATION_TOOL_START ||
packet.obj.type === PacketType.GENUI_START
packet.obj.type === PacketType.IMAGE_GENERATION_TOOL_START
);
}
@@ -81,8 +80,7 @@ export function isFinalAnswerComing(packets: Packet[]) {
return packets.some(
(packet) =>
packet.obj.type === PacketType.MESSAGE_START ||
packet.obj.type === PacketType.IMAGE_GENERATION_TOOL_START ||
packet.obj.type === PacketType.GENUI_START
packet.obj.type === PacketType.IMAGE_GENERATION_TOOL_START
);
}

View File

@@ -61,10 +61,6 @@ export enum PacketType {
INTERMEDIATE_REPORT_START = "intermediate_report_start",
INTERMEDIATE_REPORT_DELTA = "intermediate_report_delta",
INTERMEDIATE_REPORT_CITED_DOCS = "intermediate_report_cited_docs",
// GenUI packets
GENUI_START = "genui_start",
GENUI_DELTA = "genui_delta",
}
export const CODE_INTERPRETER_TOOL_TYPES = {
@@ -303,16 +299,6 @@ export interface IntermediateReportCitedDocs extends BaseObj {
cited_docs: OnyxDocument[] | null;
}
// GenUI Packets
export interface GenUIStart extends BaseObj {
type: "genui_start";
}
export interface GenUIDelta extends BaseObj {
type: "genui_delta";
content: string;
}
export type ChatObj = MessageStart | MessageDelta | MessageEnd;
export type StopObj = Stop;
@@ -398,8 +384,6 @@ export type ResearchAgentObj =
| IntermediateReportCitedDocs
| SectionEnd;
export type GenUIObj = GenUIStart | GenUIDelta | SectionEnd | PacketError;
// Union type for all possible streaming objects
export type ObjTypes =
| ChatObj
@@ -411,7 +395,6 @@ export type ObjTypes =
| CitationObj
| DeepResearchPlanObj
| ResearchAgentObj
| GenUIObj
| PacketErrorObj
| CitationObj;
@@ -502,8 +485,3 @@ export interface ResearchAgentPacket {
placement: Placement;
obj: ResearchAgentObj;
}
export interface GenUIPacket {
placement: Placement;
obj: GenUIObj;
}

View File

@@ -1,17 +0,0 @@
import { create } from "zustand";
interface GenUIViewStore {
/** When true, GenUI messages render as structured components.
* When false, they fall through to the markdown/text fallback. */
structuredViewEnabled: boolean;
toggleStructuredView: () => void;
/** Reset to structured mode — called when a new message is sent. */
resetToStructuredView: () => void;
}
export const useGenUIViewStore = create<GenUIViewStore>()((set) => ({
structuredViewEnabled: true,
toggleStructuredView: () =>
set((state) => ({ structuredViewEnabled: !state.structuredViewEnabled })),
resetToStructuredView: () => set({ structuredViewEnabled: true }),
}));

View File

@@ -79,7 +79,7 @@ interface SelectedConnectorState {
}
/**
* Build Admin Settings - Connector configuration page
* Build Admin Panel - Connector configuration page
*
* Renders in the center panel area (replacing ChatPanel + OutputPanel).
* Uses SettingsLayouts like AgentEditorPage does.

View File

@@ -22,10 +22,10 @@ export default function NoAgentModal() {
<>
<Text as="p">
As an administrator, you can create a new agent by visiting the
admin settings.
admin panel.
</Text>
<Button width="full" href="/admin/agents">
Go to Admin Settings
Go to Admin Panel
</Button>
</>
) : (

View File

@@ -1,5 +1,7 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { IS_DEV } from "@/lib/constants";
// Target format for OpenAI Realtime API
const TARGET_SAMPLE_RATE = 24000;
const CHUNK_INTERVAL_MS = 250;
@@ -245,9 +247,8 @@ class VoiceRecorderSession {
const { token } = await tokenResponse.json();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const isDev = window.location.port === "3000";
const host = isDev ? "localhost:8080" : window.location.host;
const path = isDev
const host = IS_DEV ? "localhost:8080" : window.location.host;
const path = IS_DEV
? "/voice/transcribe/stream"
: "/api/voice/transcribe/stream";
return `${protocol}//${host}${path}?token=${encodeURIComponent(token)}`;

View File

@@ -56,11 +56,9 @@ import {
SvgSearchMenu,
SvgShare,
SvgSidebar,
SvgSparkle,
SvgTrash,
} from "@opal/icons";
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import { useGenUIViewStore } from "@/app/app/stores/useGenUIViewStore";
import { useSettingsContext } from "@/providers/SettingsProvider";
import type { AppMode } from "@/providers/QueryControllerProvider";
import useAppFocus from "@/hooks/useAppFocus";
@@ -68,23 +66,6 @@ import { useQueryController } from "@/providers/QueryControllerProvider";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import useBrowserInfo from "@/hooks/useBrowserInfo";
/** Chat-level toggle for structured GenUI view vs raw text. */
function GenUIToggleButton() {
const { structuredViewEnabled, toggleStructuredView } = useGenUIViewStore();
return (
<Button
icon={SvgSparkle}
prominence="tertiary"
interaction={structuredViewEnabled ? "hover" : "rest"}
responsiveHideText
onClick={toggleStructuredView}
aria-label="genui-view-toggle"
>
{structuredViewEnabled ? "Structured" : "Text"}
</Button>
);
}
/**
* App Header Component
*
@@ -410,7 +391,6 @@ function Header() {
<div className="flex flex-1 justify-end items-center h-[3.3rem]">
{appFocus.isChat() && currentChatSession && (
<FrostedDiv className="flex shrink flex-row items-center">
<GenUIToggleButton />
<Button
icon={SvgShare}
prominence="tertiary"

View File

@@ -1,3 +1,5 @@
export const IS_DEV = process.env.NODE_ENV === "development";
export enum AuthType {
BASIC = "basic",
GOOGLE_OAUTH = "google_oauth",

View File

@@ -3,6 +3,8 @@
* Plays audio chunks as they arrive for smooth, low-latency playback.
*/
import { IS_DEV } from "@/lib/constants";
/**
* HTTPStreamingTTSPlayer - Uses HTTP streaming with MediaSource Extensions
* for smooth, gapless audio playback. This is the recommended approach for
@@ -382,9 +384,8 @@ export class WebSocketStreamingTTSPlayer {
const { token } = await tokenResponse.json();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const isDev = window.location.port === "3000";
const host = isDev ? "localhost:8080" : window.location.host;
const path = isDev
const host = IS_DEV ? "localhost:8080" : window.location.host;
const path = IS_DEV
? "/voice/synthesize/stream"
: "/api/voice/synthesize/stream";
return `${protocol}//${host}${path}?token=${encodeURIComponent(token)}`;

View File

@@ -61,7 +61,6 @@ import OnboardingFlow from "@/sections/onboarding/OnboardingFlow";
import { OnboardingStep } from "@/interfaces/onboarding";
import { useShowOnboarding } from "@/hooks/useShowOnboarding";
import * as AppLayouts from "@/layouts/app-layouts";
import { useGenUIViewStore } from "@/app/app/stores/useGenUIViewStore";
import { SvgChevronDown, SvgFileText } from "@opal/icons";
import { Button } from "@opal/components";
import Spacer from "@/refresh-components/Spacer";
@@ -507,15 +506,8 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
[]
);
const resetToStructuredView = useGenUIViewStore(
(s) => s.resetToStructuredView
);
const handleAppInputBarSubmit = useCallback(
async (message: string) => {
// Reset GenUI view to structured mode for the new response.
resetToStructuredView();
// If we're in an existing chat session, always use chat mode
// (appMode only applies to new sessions)
if (currentChatSessionId) {
@@ -542,7 +534,6 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
submitQuery,
onChat,
resetInputBar,
resetToStructuredView,
onSubmit,
currentMessageFiles,
deepResearchEnabledForCurrentWorkflow,
@@ -775,7 +766,6 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
{(appFocus.isNewSession() || appFocus.isAgent()) &&
(state.phase === "idle" ||
state.phase === "classifying") &&
!isLoadingOnboarding &&
(showOnboarding || !user?.personalization?.name) &&
!onboardingDismissed && (
<OnboardingFlow

View File

@@ -977,7 +977,7 @@ function ChatPreferencesSettings() {
<Section gap={0.75}>
<Content
title="Voice Mode"
title="Voice"
sizePreset="main-content"
variant="section"
widthVariant="full"

View File

@@ -92,7 +92,7 @@ export default function ChatDocumentDisplay({
) : (
<SourceIcon sourceType={document.source_type} iconSize={18} />
)}
<Truncated className="line-clamp-2" side="left" disable>
<Truncated className="line-clamp-2" side="left">
{title}
</Truncated>
</div>

View File

@@ -3,15 +3,7 @@
import { MinimalOnyxDocument, OnyxDocument } from "@/lib/search/interfaces";
import ChatDocumentDisplay from "@/sections/document-sidebar/ChatDocumentDisplay";
import { removeDuplicateDocs } from "@/lib/documentUtils";
import {
Dispatch,
SetStateAction,
useMemo,
memo,
useRef,
useState,
useCallback,
} from "react";
import { Dispatch, SetStateAction, useMemo, memo } from "react";
import { getCitations } from "@/app/app/services/packetUtils";
import {
useCurrentMessageTree,
@@ -48,30 +40,25 @@ const buildOnyxDocumentFromFile = (
interface HeaderProps {
children: string;
onClose?: () => void;
isTop?: boolean;
onClose: () => void;
}
function Header({ children, onClose, isTop }: HeaderProps) {
function Header({ children, onClose }: HeaderProps) {
return (
<div className="sticky top-0 z-sticky bg-background-tint-01">
<div className="flex flex-row w-full items-center justify-between gap-2 py-3">
<div className="flex items-center gap-2 w-full px-3">
{isTop && (
<SvgSearchMenu className="w-[1.3rem] h-[1.3rem] stroke-text-03" />
)}
<SvgSearchMenu className="w-[1.3rem] h-[1.3rem] stroke-text-03" />
<Text as="p" headingH3 text03>
{children}
</Text>
</div>
{onClose && (
<Button
icon={SvgX}
prominence="tertiary"
onClick={onClose}
tooltip="Close Sidebar"
/>
)}
<Button
icon={SvgX}
prominence="tertiary"
onClick={onClose}
tooltip="Close Sidebar"
/>
</div>
<Separator noPadding />
</div>
@@ -135,26 +122,6 @@ const DocumentsSidebar = memo(
return { citedDocumentIds, citationOrder };
}, [idOfMessageToDisplay, selectedMessage?.packets.length]);
const observerRef = useRef<IntersectionObserver | null>(null);
const [isMoreStuck, setIsMoreStuck] = useState(false);
const moreSentinelRef = useCallback((node: HTMLDivElement | null) => {
observerRef.current?.disconnect();
observerRef.current = null;
if (!node) return;
const root = node.closest("#onyx-chat-sidebar");
if (!root) return;
const observer = new IntersectionObserver(
(entries) => setIsMoreStuck(!entries[0]?.isIntersecting),
{ root, threshold: 0 }
);
observer.observe(node);
observerRef.current = observer;
}, []);
// if these are missing for some reason, then nothing we can do. Just
// don't render.
// TODO: improve this display
@@ -200,9 +167,7 @@ const DocumentsSidebar = memo(
<div className="flex flex-col px-3 gap-6">
{hasCited && (
<div>
<Header isTop onClose={closeSidebar}>
Cited Sources
</Header>
<Header onClose={closeSidebar}>Cited Sources</Header>
<ChatDocumentDisplayWrapper>
{citedDocuments.map((document) => (
<ChatDocumentDisplay
@@ -221,11 +186,7 @@ const DocumentsSidebar = memo(
{hasOther && (
<div>
<div ref={moreSentinelRef} className="h-px" />
<Header
isTop={!hasCited || isMoreStuck}
onClose={!hasCited || isMoreStuck ? closeSidebar : undefined}
>
<Header onClose={closeSidebar}>
{citedDocuments.length > 0 ? "More" : "Found Sources"}
</Header>
<ChatDocumentDisplayWrapper>
@@ -246,12 +207,7 @@ const DocumentsSidebar = memo(
{humanFileDescriptors && humanFileDescriptors.length > 0 && (
<div>
<Header
isTop={!hasCited && !hasOther}
onClose={!hasCited && !hasOther ? closeSidebar : undefined}
>
User Files
</Header>
<Header onClose={closeSidebar}>User Files</Header>
<ChatDocumentDisplayWrapper>
{humanFileDescriptors.map((file) => (
<ChatDocumentDisplay

View File

@@ -649,9 +649,9 @@ const AppInputBar = React.memo(
<Disabled disabled>
<Button
icon={SvgMicrophone}
aria-label="Configure Voice Mode"
aria-label="Set up voice"
prominence="tertiary"
tooltip="Configure Voice Mode in Admin Settings."
tooltip="Voice not configured. Set up in admin settings."
/>
</Disabled>
))}

View File

@@ -143,7 +143,7 @@ const LLMStepInner = ({
rightIcon={SvgExternalLink}
href="/admin/configuration/llm"
>
View in Admin Settings
View in Admin Panel
</Button>
</Disabled>
}

View File

@@ -134,7 +134,7 @@ const collections = (
sidebarItem(ADMIN_PATHS.WEB_SEARCH),
sidebarItem(ADMIN_PATHS.IMAGE_GENERATION),
{
name: "Voice Mode",
name: "Voice",
icon: SvgAudio,
link: "/admin/configuration/voice",
},

View File

@@ -613,7 +613,7 @@ const MemoizedAppSidebarInner = memo(
icon={SvgSettings}
folded={folded}
>
{isAdmin ? "Admin Settings" : "Curator Panel"}
{isAdmin ? "Admin Panel" : "Curator Panel"}
</SidebarTab>
)}
<UserAvatarPopover

View File

@@ -25,9 +25,7 @@
"@tests/*": ["./tests/*"],
"@public/*": ["./public/*"],
"@opal/*": ["./lib/opal/src/*"],
"@opal/types/*": ["./lib/opal/src/types/*"],
"@onyx/genui": ["./lib/genui-core/src/index.ts"],
"@onyx/genui-react": ["./lib/genui-react/src/index.ts"]
"@opal/types/*": ["./lib/opal/src/types/*"]
}
},
"include": [

Some files were not shown because too many files have changed in this diff Show More