mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-24 17:12:44 +00:00
Compare commits
3 Commits
nikg/genui
...
jamison/vo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f46e1e084 | ||
|
|
f4d379ceed | ||
|
|
8f1076e69d |
32
.vscode/launch.json
vendored
32
.vscode/launch.json
vendored
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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"""
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
#####
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
1838
web/lib/genui-core/package-lock.json
generated
1838
web/lib/genui-core/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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")])])'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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("(])");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: [] };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { generatePrompt } from "./generator";
|
||||
export { zodToTypeString, schemaToSignature } from "./introspector";
|
||||
@@ -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")'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(", ")})`;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
@@ -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>,
|
||||
});
|
||||
@@ -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"} />
|
||||
),
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -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"}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -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")`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
2789
web/lib/genui-react/package-lock.json
generated
2789
web/lib/genui-react/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -1,7 +0,0 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
83
web/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
),
|
||||
},
|
||||
]);
|
||||
};
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const IS_DEV = process.env.NODE_ENV === "development";
|
||||
|
||||
export enum AuthType {
|
||||
BASIC = "basic",
|
||||
GOOGLE_OAUTH = "google_oauth",
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -977,7 +977,7 @@ function ChatPreferencesSettings() {
|
||||
|
||||
<Section gap={0.75}>
|
||||
<Content
|
||||
title="Voice Mode"
|
||||
title="Voice"
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
widthVariant="full"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -143,7 +143,7 @@ const LLMStepInner = ({
|
||||
rightIcon={SvgExternalLink}
|
||||
href="/admin/configuration/llm"
|
||||
>
|
||||
View in Admin Settings
|
||||
View in Admin Panel
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -613,7 +613,7 @@ const MemoizedAppSidebarInner = memo(
|
||||
icon={SvgSettings}
|
||||
folded={folded}
|
||||
>
|
||||
{isAdmin ? "Admin Settings" : "Curator Panel"}
|
||||
{isAdmin ? "Admin Panel" : "Curator Panel"}
|
||||
</SidebarTab>
|
||||
)}
|
||||
<UserAvatarPopover
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user