feat(frontend): Enhanced output rendering system for agent runs (#10819)

## Summary
Introduces a modular, extensible output renderer system supporting
multiple content types (text, code, images, videos, JSON, markdown) for
agent run outputs. The system includes smart clipboard operations,
concatenated downloads, and rich markdown rendering with LaTeX math and
video embedding support.

## Changes 🏗️

### Core Output Rendering System
- **Added extensible renderer architecture**
(`output-renderers/types.ts`)
  - Plugin-based system with priority ordering
  - Registry pattern for automatic renderer selection
  - Support for custom metadata and MIME types

### Output Renderers
- **TextRenderer**: Plain text with proper formatting and line breaks
- **CodeRenderer**: Syntax-highlighted code blocks with language
detection
- **JSONRenderer**: Collapsible, formatted JSON with syntax highlighting
- **ImageRenderer**: Image display with support for URLs, data URIs, and
file uploads
- **VideoRenderer**: Embedded video player for YouTube, Vimeo, and
direct video files
- **MarkdownRenderer**: Rich markdown with:
  - GitHub Flavored Markdown (tables, task lists, strikethrough)
- LaTeX math rendering via KaTeX (inline `$...$` and display `$$...$$`)
  - Syntax highlighting via highlight.js
  - Video embedding (YouTube/Vimeo URLs auto-convert to embeds)
  - Clickable heading anchors
  - Dark mode support

### User Interface Components
- **OutputItem**: Individual output display with renderer selection
- **OutputActions**: Hover-based action buttons for:
  - Copy to clipboard with smart MIME type detection
- Download with intelligent concatenation (text files merge, binaries
separate)
  - Share functionality (placeholder for future implementation)
- **AgentRunOutputView**: Main output view component with feature flag
integration

### Clipboard & Download Features
- Smart clipboard operations using native ClipboardItem API
- MIME type detection and browser capability checking
- Fallback strategies for unsupported content types
- Concatenated downloads for text-based outputs
- Individual downloads for binary content

### Feature Flag Integration
- Added `ENABLE_ENHANCED_OUTPUT_HANDLING` flag to LaunchDarkly
- Backwards compatible with existing output display
- Graceful fallback for disabled feature flag

### Styling & UX
- Max width constraints (950px card, 660px content)
- Hover-based action buttons for clean interface
- Dark mode support across all renderers
- Responsive design for various content types
- Loading states and error handling

## Test Plan 📋

### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:

### Test Scenarios:
- [x] **Basic Output Rendering**
  - [x] Execute agent with text output - verify proper formatting
  - [x] Execute agent with JSON output - verify collapsible tree view
  - [x] Execute agent with code output - verify syntax highlighting
  
- [x] **Rich Content**
  - [x] Test markdown rendering with headers, lists, tables
  - [x] Test LaTeX math expressions (inline and display)
  - [x] Test code blocks within markdown
  - [x] Test task lists and strikethrough
  
- [x] **Media Handling**
  - [x] Upload and display PNG/JPEG images
  - [x] Test video URL embedding (YouTube/Vimeo)
  - [x] Test direct video file playback
  
- [x] **Clipboard Operations**
  - [x] Copy plain text output
  - [x] Copy formatted code
  - [x] Copy JSON data
  - [x] Copy markdown content
  - [x] Verify fallback for unsupported MIME types
  
- [x] **Download Functionality**
  - [x] Download single text output
  - [x] Download multiple text outputs (verify concatenation)
  - [x] Download mixed content (verify separate files)
  - [x] Download images and binary content
  
- [x] **Feature Flag**
  - [x] Enable flag - verify enhanced rendering
  - [x] Disable flag - verify fallback to original view
  - [x] Check backwards compatibility
 
  
- [x] **Edge Cases**
  - [x] Large JSON objects (performance)
  - [x] Very long text outputs
  - [x] Mixed content types in single run
  - [x] Malformed markdown
  - [x] Invalid video URLs

## Dependencies Added
- `react-markdown` (9.0.3) - Already present
- `remark-gfm` (4.0.1) - GitHub Flavored Markdown
- `remark-math` (6.0.0) - LaTeX math support
- `rehype-katex` (7.0.1) - Math rendering
- `katex` (0.16.22) - Math typesetting
- `rehype-highlight` (7.0.2) - Syntax highlighting
- `highlight.js` (11.11.1) - Highlighting library
- `rehype-slug` (6.0.0) - Heading anchors
- `rehype-autolink-headings` (7.1.0) - Clickable headings

## Notes
- Mermaid diagram support was attempted but removed due to compatibility
issues
- Share functionality is stubbed out for future implementation
- PNG file upload rendering issue has logging in place for debugging
- All components follow existing UI patterns and use Tailwind CSS

## Screenshots
<img width="1656" height="1250" alt="image"
src="https://github.com/user-attachments/assets/af7542fe-db89-4521-aaf5-19e33d48a409"
/>
## Related Issues
- Implements SECRT-1209

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Nicholas Tindle <ntindle@users.noreply.github.com>
This commit is contained in:
Nicholas Tindle
2025-09-04 08:37:26 -05:00
committed by GitHub
parent bbd6709bd6
commit 901e9eba5d
17 changed files with 2325 additions and 37 deletions

View File

@@ -66,7 +66,9 @@
"embla-carousel-react": "8.6.0",
"framer-motion": "12.23.12",
"geist": "1.4.2",
"highlight.js": "11.11.1",
"jaro-winkler": "0.2.8",
"katex": "0.16.22",
"launchdarkly-react-client-sdk": "3.8.1",
"lodash": "4.17.21",
"lucide-react": "0.539.0",
@@ -86,6 +88,12 @@
"react-shepherd": "6.1.9",
"react-window": "1.8.11",
"recharts": "2.15.3",
"rehype-autolink-headings": "7.1.0",
"rehype-highlight": "7.0.2",
"rehype-katex": "7.0.1",
"rehype-slug": "6.0.0",
"remark-gfm": "4.0.1",
"remark-math": "6.0.0",
"shepherd.js": "14.5.1",
"sonner": "2.0.7",
"tailwind-merge": "2.6.0",

View File

@@ -131,9 +131,15 @@ importers:
geist:
specifier: 1.4.2
version: 1.4.2(next@15.4.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
highlight.js:
specifier: 11.11.1
version: 11.11.1
jaro-winkler:
specifier: 0.2.8
version: 0.2.8
katex:
specifier: 0.16.22
version: 0.16.22
launchdarkly-react-client-sdk:
specifier: 3.8.1
version: 3.8.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -191,6 +197,24 @@ importers:
recharts:
specifier: 2.15.3
version: 2.15.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rehype-autolink-headings:
specifier: 7.1.0
version: 7.1.0
rehype-highlight:
specifier: 7.0.2
version: 7.0.2
rehype-katex:
specifier: 7.0.1
version: 7.0.1
rehype-slug:
specifier: 6.0.0
version: 6.0.0
remark-gfm:
specifier: 4.0.1
version: 4.0.1
remark-math:
specifier: 6.0.0
version: 6.0.0
shepherd.js:
specifier: 14.5.1
version: 14.5.1
@@ -2900,6 +2924,9 @@ packages:
'@types/junit-report-builder@3.0.2':
resolution: {integrity: sha512-R5M+SYhMbwBeQcNXYWNCZkl09vkVfAtcPIaCGdzIkkbeaTrVbGQ7HVgi4s+EmM/M1K4ZuWQH0jGcvMvNePfxYA==}
'@types/katex@0.16.7':
resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
'@types/lodash@4.17.20':
resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==}
@@ -4119,6 +4146,10 @@ packages:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
@@ -4189,6 +4220,10 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
eslint-config-next@15.4.6:
resolution: {integrity: sha512-4uznvw5DlTTjrZgYZjMciSdDDMO2SWIuQgUNaFyC2O3Zw3Z91XeIejeVa439yRq2CnJb/KEvE4U2AeN/66FpUA==}
peerDependencies:
@@ -4550,6 +4585,9 @@ packages:
get-tsconfig@4.10.1:
resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==}
github-slugger@2.0.0:
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -4636,12 +4674,42 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hast-util-from-dom@5.0.1:
resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==}
hast-util-from-html-isomorphic@2.0.0:
resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==}
hast-util-from-html@2.0.3:
resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
hast-util-heading-rank@3.0.0:
resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==}
hast-util-is-element@3.0.0:
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
hast-util-to-string@3.0.1:
resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==}
hast-util-to-text@4.0.2:
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
@@ -4649,6 +4717,10 @@ packages:
headers-polyfill@4.0.3:
resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
hmac-drbg@1.0.1:
resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==}
@@ -5015,6 +5087,10 @@ packages:
resolution: {integrity: sha512-ZNOIIGMzqCGcHQEA2Q4rIQQ3Df6gSIfne+X9Rly9Bc2y55KxAZu8iGv+n2pP0bLf0XAOctJZgeloC54hWzCahQ==}
engines: {node: '>=16'}
katex@0.16.22:
resolution: {integrity: sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==}
hasBin: true
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -5129,6 +5205,9 @@ packages:
lower-case@2.0.2:
resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==}
lowlight@3.3.0:
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -5162,6 +5241,9 @@ packages:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -5169,9 +5251,33 @@ packages:
md5.js@1.3.5:
resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==}
mdast-util-find-and-replace@3.0.2:
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
mdast-util-from-markdown@2.0.2:
resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==}
mdast-util-gfm-autolink-literal@2.0.1:
resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
mdast-util-gfm-footnote@2.1.0:
resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
mdast-util-gfm-strikethrough@2.0.0:
resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==}
mdast-util-gfm-table@2.0.0:
resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
mdast-util-gfm-task-list-item@2.0.0:
resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
mdast-util-math@3.0.0:
resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==}
mdast-util-mdx-expression@2.0.1:
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
@@ -5213,6 +5319,30 @@ packages:
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
micromark-extension-gfm-autolink-literal@2.1.0:
resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
micromark-extension-gfm-footnote@2.1.0:
resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
micromark-extension-gfm-strikethrough@2.1.0:
resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
micromark-extension-gfm-table@2.1.1:
resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==}
micromark-extension-gfm-tagfilter@2.0.0:
resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
micromark-extension-gfm-task-list-item@2.1.0:
resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
micromark-extension-gfm@3.0.0:
resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
micromark-extension-math@3.1.0:
resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==}
micromark-factory-destination@2.0.1:
resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
@@ -5629,6 +5759,9 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
party-js@2.2.0:
resolution: {integrity: sha512-50hGuALCpvDTrQLPQ1fgUgxKIWAH28ShVkmeK/3zhO0YJyCqkhrZhQEkWPxDYLvbFJ7YAXyROmFEu35gKpZLtQ==}
@@ -6178,16 +6311,37 @@ packages:
resolution: {integrity: sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==}
hasBin: true
rehype-autolink-headings@7.1.0:
resolution: {integrity: sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==}
rehype-highlight@7.0.2:
resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==}
rehype-katex@7.0.1:
resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==}
rehype-slug@6.0.0:
resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==}
relateurl@0.2.7:
resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==}
engines: {node: '>= 0.10'}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
remark-math@6.0.0:
resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==}
remark-parse@11.0.0:
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
remark-rehype@11.1.2:
resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
renderkid@3.0.0:
resolution: {integrity: sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==}
@@ -6858,12 +7012,18 @@ packages:
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
unist-util-find-after@5.0.0:
resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
unist-util-is@6.0.0:
resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==}
unist-util-position@5.0.0:
resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
unist-util-remove-position@5.0.0:
resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==}
unist-util-stringify-position@4.0.0:
resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
@@ -6970,6 +7130,9 @@ packages:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -6989,6 +7152,9 @@ packages:
resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==}
engines: {node: '>=10.13.0'}
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -10194,6 +10360,8 @@ snapshots:
'@types/junit-report-builder@3.0.2': {}
'@types/katex@0.16.7': {}
'@types/lodash@4.17.20': {}
'@types/mdast@4.0.4':
@@ -11486,6 +11654,8 @@ snapshots:
entities@4.5.0: {}
entities@6.0.1: {}
env-paths@2.2.1: {}
error-ex@1.3.2:
@@ -11652,6 +11822,8 @@ snapshots:
escape-string-regexp@4.0.0: {}
escape-string-regexp@5.0.0: {}
eslint-config-next@15.4.6(eslint@8.57.1)(typescript@5.9.2):
dependencies:
'@next/eslint-plugin-next': 15.4.6
@@ -11660,8 +11832,8 @@ snapshots:
'@typescript-eslint/parser': 8.39.1(eslint@8.57.1)(typescript@5.9.2)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.39.1(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.39.1(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.39.1(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.39.1(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1)
@@ -11680,7 +11852,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.39.1(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1):
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.1
@@ -11691,22 +11863,22 @@ snapshots:
tinyglobby: 0.2.14
unrs-resolver: 1.11.1
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.39.1(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.39.1(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.39.1(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.39.1(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.39.1(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.39.1(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.39.1(eslint@8.57.1)(typescript@5.9.2)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.39.1(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.39.1(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.39.1(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.39.1(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -11717,7 +11889,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.39.1(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.39.1(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.39.1(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -12110,6 +12282,8 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
github-slugger@2.0.0: {}
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -12207,6 +12381,51 @@ snapshots:
dependencies:
function-bind: 1.1.2
hast-util-from-dom@5.0.1:
dependencies:
'@types/hast': 3.0.4
hastscript: 9.0.1
web-namespaces: 2.0.1
hast-util-from-html-isomorphic@2.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-from-dom: 5.0.1
hast-util-from-html: 2.0.3
unist-util-remove-position: 5.0.0
hast-util-from-html@2.0.3:
dependencies:
'@types/hast': 3.0.4
devlop: 1.1.0
hast-util-from-parse5: 8.0.3
parse5: 7.3.0
vfile: 6.0.3
vfile-message: 4.0.3
hast-util-from-parse5@8.0.3:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
devlop: 1.1.0
hastscript: 9.0.1
property-information: 7.1.0
vfile: 6.0.3
vfile-location: 5.0.3
web-namespaces: 2.0.1
hast-util-heading-rank@3.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-is-element@3.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-parse-selector@4.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.8
@@ -12227,14 +12446,35 @@ snapshots:
transitivePeerDependencies:
- supports-color
hast-util-to-string@3.0.1:
dependencies:
'@types/hast': 3.0.4
hast-util-to-text@4.0.2:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
hast-util-is-element: 3.0.0
unist-util-find-after: 5.0.0
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
hastscript@9.0.1:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
hast-util-parse-selector: 4.0.0
property-information: 7.1.0
space-separated-tokens: 2.0.2
he@1.2.0: {}
headers-polyfill@4.0.3: {}
highlight.js@11.11.1: {}
hmac-drbg@1.0.1:
dependencies:
hash.js: 1.1.7
@@ -12587,6 +12827,10 @@ snapshots:
make-dir: 3.1.0
xmlbuilder: 15.1.1
katex@0.16.22:
dependencies:
commander: 8.3.0
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -12689,6 +12933,12 @@ snapshots:
dependencies:
tslib: 2.8.1
lowlight@3.3.0:
dependencies:
'@types/hast': 3.0.4
devlop: 1.1.0
highlight.js: 11.11.1
lru-cache@10.4.3: {}
lru-cache@5.1.1:
@@ -12724,6 +12974,8 @@ snapshots:
punycode.js: 2.3.1
uc.micro: 2.1.0
markdown-table@3.0.4: {}
math-intrinsics@1.1.0: {}
md5.js@1.3.5:
@@ -12732,6 +12984,13 @@ snapshots:
inherits: 2.0.4
safe-buffer: 5.2.1
mdast-util-find-and-replace@3.0.2:
dependencies:
'@types/mdast': 4.0.4
escape-string-regexp: 5.0.0
unist-util-is: 6.0.0
unist-util-visit-parents: 6.0.1
mdast-util-from-markdown@2.0.2:
dependencies:
'@types/mdast': 4.0.4
@@ -12749,6 +13008,75 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-gfm-autolink-literal@2.0.1:
dependencies:
'@types/mdast': 4.0.4
ccount: 2.0.1
devlop: 1.1.0
mdast-util-find-and-replace: 3.0.2
micromark-util-character: 2.1.1
mdast-util-gfm-footnote@2.1.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
micromark-util-normalize-identifier: 2.0.1
transitivePeerDependencies:
- supports-color
mdast-util-gfm-strikethrough@2.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-gfm-table@2.0.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
markdown-table: 3.0.4
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-gfm-task-list-item@2.0.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-gfm@3.1.0:
dependencies:
mdast-util-from-markdown: 2.0.2
mdast-util-gfm-autolink-literal: 2.0.1
mdast-util-gfm-footnote: 2.1.0
mdast-util-gfm-strikethrough: 2.0.0
mdast-util-gfm-table: 2.0.0
mdast-util-gfm-task-list-item: 2.0.0
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-math@3.0.0:
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
devlop: 1.1.0
longest-streak: 3.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
unist-util-remove-position: 5.0.0
transitivePeerDependencies:
- supports-color
mdast-util-mdx-expression@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
@@ -12852,6 +13180,74 @@ snapshots:
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-autolink-literal@2.1.0:
dependencies:
micromark-util-character: 2.1.1
micromark-util-sanitize-uri: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-footnote@2.1.0:
dependencies:
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-normalize-identifier: 2.0.1
micromark-util-sanitize-uri: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-strikethrough@2.1.0:
dependencies:
devlop: 1.1.0
micromark-util-chunked: 2.0.1
micromark-util-classify-character: 2.0.1
micromark-util-resolve-all: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-table@2.1.1:
dependencies:
devlop: 1.1.0
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-tagfilter@2.0.0:
dependencies:
micromark-util-types: 2.0.2
micromark-extension-gfm-task-list-item@2.1.0:
dependencies:
devlop: 1.1.0
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm@3.0.0:
dependencies:
micromark-extension-gfm-autolink-literal: 2.1.0
micromark-extension-gfm-footnote: 2.1.0
micromark-extension-gfm-strikethrough: 2.1.0
micromark-extension-gfm-table: 2.1.1
micromark-extension-gfm-tagfilter: 2.0.0
micromark-extension-gfm-task-list-item: 2.1.0
micromark-util-combine-extensions: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-math@3.1.0:
dependencies:
'@types/katex': 0.16.7
devlop: 1.1.0
katex: 0.16.22
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-factory-destination@2.0.1:
dependencies:
micromark-util-character: 2.1.1
@@ -13404,6 +13800,10 @@ snapshots:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
parse5@7.3.0:
dependencies:
entities: 6.0.1
party-js@2.2.0: {}
pascal-case@3.1.2:
@@ -13916,8 +14316,63 @@ snapshots:
dependencies:
jsesc: 3.0.2
rehype-autolink-headings@7.1.0:
dependencies:
'@types/hast': 3.0.4
'@ungap/structured-clone': 1.3.0
hast-util-heading-rank: 3.0.0
hast-util-is-element: 3.0.0
unified: 11.0.5
unist-util-visit: 5.0.0
rehype-highlight@7.0.2:
dependencies:
'@types/hast': 3.0.4
hast-util-to-text: 4.0.2
lowlight: 3.3.0
unist-util-visit: 5.0.0
vfile: 6.0.3
rehype-katex@7.0.1:
dependencies:
'@types/hast': 3.0.4
'@types/katex': 0.16.7
hast-util-from-html-isomorphic: 2.0.0
hast-util-to-text: 4.0.2
katex: 0.16.22
unist-util-visit-parents: 6.0.1
vfile: 6.0.3
rehype-slug@6.0.0:
dependencies:
'@types/hast': 3.0.4
github-slugger: 2.0.0
hast-util-heading-rank: 3.0.0
hast-util-to-string: 3.0.1
unist-util-visit: 5.0.0
relateurl@0.2.7: {}
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
mdast-util-gfm: 3.1.0
micromark-extension-gfm: 3.0.0
remark-parse: 11.0.0
remark-stringify: 11.0.0
unified: 11.0.5
transitivePeerDependencies:
- supports-color
remark-math@6.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-math: 3.0.0
micromark-extension-math: 3.1.0
unified: 11.0.5
transitivePeerDependencies:
- supports-color
remark-parse@11.0.0:
dependencies:
'@types/mdast': 4.0.4
@@ -13935,6 +14390,12 @@ snapshots:
unified: 11.0.5
vfile: 6.0.3
remark-stringify@11.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
renderkid@3.0.0:
dependencies:
css-select: 4.3.0
@@ -14736,6 +15197,11 @@ snapshots:
trough: 2.2.0
vfile: 6.0.3
unist-util-find-after@5.0.0:
dependencies:
'@types/unist': 3.0.3
unist-util-is: 6.0.0
unist-util-is@6.0.0:
dependencies:
'@types/unist': 3.0.3
@@ -14744,6 +15210,11 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
unist-util-remove-position@5.0.0:
dependencies:
'@types/unist': 3.0.3
unist-util-visit: 5.0.0
unist-util-stringify-position@4.0.0:
dependencies:
'@types/unist': 3.0.3
@@ -14871,6 +15342,11 @@ snapshots:
- '@types/react'
- '@types/react-dom'
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3
vfile: 6.0.3
vfile-message@4.0.3:
dependencies:
'@types/unist': 3.0.3
@@ -14909,6 +15385,8 @@ snapshots:
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
web-namespaces@2.0.1: {}
webidl-conversions@3.0.1: {}
webpack-dev-middleware@6.1.3(webpack@5.101.1(esbuild@0.25.9)):

View File

@@ -31,6 +31,7 @@ import { useToastOnFail } from "@/components/molecules/Toast/use-toast";
import { AgentRunStatus, agentRunStatusMap } from "./agent-run-status-chip";
import useCredits from "@/hooks/useCredits";
import { AgentRunOutputView } from "./agent-run-output-view";
export function AgentRunDetailsView({
agent,
@@ -296,35 +297,7 @@ export function AgentRunDetailsView({
)}
{agentRunOutputs !== null && (
<Card className="agpt-box">
<CardHeader>
<CardTitle className="font-poppins text-lg">Output</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{agentRunOutputs !== undefined ? (
Object.entries(agentRunOutputs).map(
([key, { title, values }]) => (
<div key={key} className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{title || key}
</label>
{values.map((value, i) => (
<p
className="resize-none overflow-x-auto whitespace-pre-wrap break-words border-none text-sm text-neutral-700 disabled:cursor-not-allowed"
key={i}
>
{value}
</p>
))}
{/* TODO: pretty type-dependent rendering */}
</div>
),
)
) : (
<LoadingBox spinnerSize={12} className="h-24" />
)}
</CardContent>
</Card>
<AgentRunOutputView agentRunOutputs={agentRunOutputs} />
)}
<Card className="agpt-box">

View File

@@ -0,0 +1,169 @@
"use client";
import React, { useMemo } from "react";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import LoadingBox from "@/components/ui/loading";
import { globalRegistry, OutputItem, OutputActions } from "./output-renderers";
import type { OutputMetadata } from "./output-renderers";
export function AgentRunOutputView({
agentRunOutputs,
}: {
agentRunOutputs:
| Record<
string,
{
title?: string;
/* type: BlockIOSubType; */
values: Array<React.ReactNode>;
}
>
| undefined;
}) {
const enableEnhancedOutputHandling = useGetFlag(
Flag.ENABLE_ENHANCED_OUTPUT_HANDLING,
);
// Prepare items for the renderer system
const outputItems = useMemo(() => {
if (!agentRunOutputs) return [];
const items: Array<{
key: string;
label: string;
value: unknown;
metadata?: OutputMetadata;
renderer: any;
}> = [];
Object.entries(agentRunOutputs).forEach(([key, { title, values }]) => {
values.forEach((value, index) => {
// Enhanced metadata extraction
const metadata: OutputMetadata = {};
// Type guard to safely access properties
if (
typeof value === "object" &&
value !== null &&
!React.isValidElement(value)
) {
const objValue = value as any;
if (objValue.type) metadata.type = objValue.type;
if (objValue.mimeType) metadata.mimeType = objValue.mimeType;
if (objValue.filename) metadata.filename = objValue.filename;
}
const renderer = globalRegistry.getRenderer(value, metadata);
if (renderer) {
items.push({
key: `${key}-${index}`,
label: index === 0 ? title || key : "",
value,
metadata,
renderer,
});
} else {
const textRenderer = globalRegistry
.getAllRenderers()
.find((r) => r.name === "TextRenderer");
if (textRenderer) {
items.push({
key: `${key}-${index}`,
label: index === 0 ? title || key : "",
value: JSON.stringify(value, null, 2),
metadata,
renderer: textRenderer,
});
}
}
});
});
return items;
}, [agentRunOutputs]);
return (
<>
{enableEnhancedOutputHandling ? (
<Card className="agpt-box" style={{ maxWidth: "950px" }}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="font-poppins text-lg">Output</CardTitle>
{outputItems.length > 0 && (
<OutputActions
items={outputItems.map((item) => ({
value: item.value,
metadata: item.metadata,
renderer: item.renderer,
}))}
/>
)}
</div>
</CardHeader>
<CardContent
className="flex flex-col gap-4"
style={{ maxWidth: "660px" }}
>
{agentRunOutputs !== undefined ? (
outputItems.length > 0 ? (
outputItems.map((item) => (
<OutputItem
key={item.key}
value={item.value}
metadata={item.metadata}
renderer={item.renderer}
label={item.label}
/>
))
) : (
<p className="text-sm text-muted-foreground">
No outputs to display
</p>
)
) : (
<LoadingBox spinnerSize={12} className="h-24" />
)}
</CardContent>
</Card>
) : (
<Card className="agpt-box" style={{ maxWidth: "950px" }}>
<CardHeader>
<CardTitle className="font-poppins text-lg">Output</CardTitle>
</CardHeader>
<CardContent
className="flex flex-col gap-4"
style={{ maxWidth: "660px" }}
>
{agentRunOutputs !== undefined ? (
Object.entries(agentRunOutputs).map(
([key, { title, values }]) => (
<div key={key} className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{title || key}
</label>
{values.map((value, i) => (
<p
className="resize-none overflow-x-auto whitespace-pre-wrap break-words border-none text-sm text-neutral-700 disabled:cursor-not-allowed"
key={i}
>
{value}
</p>
))}
{/* TODO: pretty type-dependent rendering */}
</div>
),
)
) : (
<LoadingBox spinnerSize={12} className="h-24" />
)}
</CardContent>
</Card>
)}
</>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import React, { useState } from "react";
import { CheckIcon, CopyIcon, DownloadIcon } from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import { OutputRenderer, OutputMetadata } from "../types";
import { downloadOutputs } from "../utils/download";
interface OutputActionsProps {
items: Array<{
value: unknown;
metadata?: OutputMetadata;
renderer: OutputRenderer;
}>;
}
export function OutputActions({ items }: OutputActionsProps) {
const [copied, setCopied] = useState(false);
const handleCopyAll = async () => {
const textContents: string[] = [];
for (const item of items) {
const copyContent = item.renderer.getCopyContent(
item.value,
item.metadata,
);
if (
copyContent &&
item.renderer.isConcatenable(item.value, item.metadata)
) {
// For concatenable items, extract the text
let text: string;
if (typeof copyContent.data === "string") {
text = copyContent.data;
} else if (copyContent.fallbackText) {
text = copyContent.fallbackText;
} else {
continue;
}
textContents.push(text);
}
}
if (textContents.length > 0) {
const combinedText = textContents.join("\n\n");
try {
await navigator.clipboard.writeText(combinedText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error("Failed to copy:", error);
}
}
};
const handleDownloadAll = () => {
downloadOutputs(items);
};
return (
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={handleCopyAll}
aria-label="Copy all text outputs"
>
{copied ? (
<CheckIcon className="size-4 text-green-600" />
) : (
<CopyIcon className="size-4 text-neutral-500" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleDownloadAll}
aria-label="Download outputs"
>
<DownloadIcon className="size-4 text-neutral-500" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import React, { useState } from "react";
import { CopyIcon, CheckIcon } from "lucide-react";
import { OutputRenderer, OutputMetadata } from "../types";
import { copyToClipboard } from "../utils/copy";
interface OutputItemProps {
value: any;
metadata?: OutputMetadata;
renderer: OutputRenderer;
label?: string;
}
export function OutputItem({
value,
metadata,
renderer,
label,
}: OutputItemProps) {
const [showCopyButton, setShowCopyButton] = useState(false);
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
const copyContent = renderer.getCopyContent(value, metadata);
if (copyContent) {
try {
await copyToClipboard(copyContent);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error("Failed to copy:", error);
}
}
};
const canCopy = renderer.getCopyContent(value, metadata) !== null;
return (
<div
className="relative"
onMouseEnter={() => setShowCopyButton(true)}
onMouseLeave={() => setShowCopyButton(false)}
>
{label && (
<label className="mb-1.5 block text-sm font-medium">{label}</label>
)}
<div className="relative">
{renderer.render(value, metadata)}
{canCopy && showCopyButton && (
<button
onClick={handleCopy}
className="absolute right-2 top-2 rounded-md border border-gray-200 bg-background/80 p-1.5 backdrop-blur-sm transition-all duration-200 hover:bg-gray-100"
aria-label="Copy content"
>
{copied ? (
<CheckIcon className="h-4 w-4 text-green-600" />
) : (
<CopyIcon className="h-4 w-4 text-gray-600" />
)}
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { globalRegistry } from "./types";
import { textRenderer } from "./renderers/TextRenderer";
import { codeRenderer } from "./renderers/CodeRenderer";
import { imageRenderer } from "./renderers/ImageRenderer";
import { videoRenderer } from "./renderers/VideoRenderer";
import { jsonRenderer } from "./renderers/JSONRenderer";
import { markdownRenderer } from "./renderers/MarkdownRenderer";
// Register all renderers in priority order
globalRegistry.register(videoRenderer);
globalRegistry.register(imageRenderer);
globalRegistry.register(codeRenderer);
globalRegistry.register(markdownRenderer);
globalRegistry.register(jsonRenderer);
globalRegistry.register(textRenderer);
export { globalRegistry };
export type { OutputRenderer, OutputMetadata, DownloadContent } from "./types";
export { OutputItem } from "./components/OutputItem";
export { OutputActions } from "./components/OutputActions";

View File

@@ -0,0 +1,135 @@
import React from "react";
import {
OutputRenderer,
OutputMetadata,
DownloadContent,
CopyContent,
} from "../types";
function getFileExtension(language: string): string {
const extensionMap: Record<string, string> = {
javascript: "js",
typescript: "ts",
python: "py",
java: "java",
csharp: "cs",
cpp: "cpp",
c: "c",
html: "html",
css: "css",
json: "json",
xml: "xml",
yaml: "yaml",
markdown: "md",
sql: "sql",
bash: "sh",
shell: "sh",
plaintext: "txt",
};
return extensionMap[language.toLowerCase()] || "txt";
}
function canRenderCode(value: unknown, metadata?: OutputMetadata): boolean {
if (metadata?.type === "code" || metadata?.language) {
return typeof value === "string";
}
if (typeof value !== "string") return false;
const markdownIndicators = [
/^#{1,6}\s+/m,
/\*\*[^*]+\*\*/,
/\[([^\]]+)\]\(([^)]+)\)/,
/^>\s+/m,
/^\s*[-*+]\s+\w+/m,
/!\[([^\]]*)\]\(([^)]+)\)/,
];
let markdownMatches = 0;
for (const pattern of markdownIndicators) {
if (pattern.test(value)) {
markdownMatches++;
if (markdownMatches >= 2) {
return false;
}
}
}
const codeIndicators = [
/^(function|const|let|var|class|import|export|if|for|while)\s/m,
/^def\s+\w+\s*\(/m,
/^import\s+/m,
/^from\s+\w+\s+import/m,
/^\s*<[^>]+>/,
/[{}[\]();]/,
];
return codeIndicators.some((pattern) => pattern.test(value));
}
function renderCode(
value: unknown,
metadata?: OutputMetadata,
): React.ReactNode {
const codeValue = String(value);
const language = metadata?.language || "plaintext";
return (
<div className="group relative">
{metadata?.language && (
<div className="absolute right-2 top-2 rounded bg-background/80 px-2 py-1 text-xs text-muted-foreground">
{language}
</div>
)}
<pre className="overflow-x-auto rounded-md bg-muted p-3">
<code className="font-mono text-sm">{codeValue}</code>
</pre>
</div>
);
}
function getCopyContentCode(
value: unknown,
_metadata?: OutputMetadata,
): CopyContent | null {
const codeValue = String(value);
return {
mimeType: "text/plain",
data: codeValue,
fallbackText: codeValue,
};
}
function getDownloadContentCode(
value: unknown,
metadata?: OutputMetadata,
): DownloadContent | null {
const codeValue = String(value);
const language = metadata?.language || "txt";
const extension = getFileExtension(language);
const blob = new Blob([codeValue], { type: "text/plain" });
return {
data: blob,
filename: metadata?.filename || `code.${extension}`,
mimeType: "text/plain",
};
}
function isConcatenableCode(
_value: unknown,
_metadata?: OutputMetadata,
): boolean {
return true;
}
export const codeRenderer: OutputRenderer = {
name: "CodeRenderer",
priority: 30,
canRender: canRenderCode,
render: renderCode,
getCopyContent: getCopyContentCode,
getDownloadContent: getDownloadContentCode,
isConcatenable: isConcatenableCode,
};

View File

@@ -0,0 +1,208 @@
import React from "react";
import {
OutputRenderer,
OutputMetadata,
DownloadContent,
CopyContent,
} from "../types";
const imageExtensions = [
".jpg",
".jpeg",
".png",
".gif",
".bmp",
".svg",
".webp",
".ico",
];
const imageMimeTypes = [
"image/jpeg",
"image/png",
"image/gif",
"image/bmp",
"image/svg+xml",
"image/webp",
"image/x-icon",
];
function guessMimeType(url: string): string | null {
const extension = url.split(".").pop()?.toLowerCase();
const mimeMap: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
bmp: "image/bmp",
svg: "image/svg+xml",
webp: "image/webp",
ico: "image/x-icon",
};
return extension ? mimeMap[extension] || null : null;
}
function canRenderImage(value: unknown, metadata?: OutputMetadata): boolean {
if (
metadata?.type === "image" ||
(metadata?.mimeType && imageMimeTypes.includes(metadata.mimeType))
) {
return true;
}
if (typeof value === "object" && value !== null) {
const obj = value as any;
if (obj.url || obj.data || obj.path) {
const urlOrData = obj.url || obj.data || obj.path;
if (typeof urlOrData === "string") {
if (urlOrData.startsWith("data:image/")) {
return true;
}
if (
urlOrData.startsWith("http://") ||
urlOrData.startsWith("https://")
) {
const hasImageExt = imageExtensions.some((ext) =>
urlOrData.toLowerCase().includes(ext),
);
return hasImageExt;
}
}
}
if (obj.filename) {
const hasImageExt = imageExtensions.some((ext) =>
obj.filename.toLowerCase().endsWith(ext),
);
return hasImageExt;
}
}
if (typeof value === "string") {
if (value.startsWith("data:image/")) {
return true;
}
if (value.startsWith("http://") || value.startsWith("https://")) {
const hasImageExt = imageExtensions.some((ext) =>
value.toLowerCase().includes(ext),
);
return hasImageExt;
}
if (metadata?.filename) {
const hasImageExt = imageExtensions.some((ext) =>
metadata.filename!.toLowerCase().endsWith(ext),
);
return hasImageExt;
}
}
return false;
}
function renderImage(
value: unknown,
metadata?: OutputMetadata,
): React.ReactNode {
const imageUrl = String(value);
const altText = metadata?.filename || "Output image";
return (
<div className="group relative">
<img
src={imageUrl}
alt={altText}
className="h-auto max-w-full rounded-md border border-gray-200"
loading="lazy"
/>
</div>
);
}
function getCopyContentImage(
value: unknown,
metadata?: OutputMetadata,
): CopyContent | null {
const imageUrl = String(value);
if (imageUrl.startsWith("data:")) {
const mimeMatch = imageUrl.match(/data:([^;]+)/);
const mimeType = mimeMatch?.[1] || "image/png";
return {
mimeType: mimeType,
data: async () => {
const response = await fetch(imageUrl);
return await response.blob();
},
alternativeMimeTypes: ["image/png", "text/plain"],
fallbackText: imageUrl,
};
}
const mimeType = metadata?.mimeType || guessMimeType(imageUrl) || "image/png";
return {
mimeType: mimeType,
data: async () => {
const response = await fetch(imageUrl);
return await response.blob();
},
alternativeMimeTypes: ["image/png", "text/plain"],
fallbackText: imageUrl,
};
}
function getDownloadContentImage(
value: unknown,
metadata?: OutputMetadata,
): DownloadContent | null {
const imageUrl = String(value);
if (imageUrl.startsWith("data:")) {
const [mimeInfo, base64Data] = imageUrl.split(",");
const mimeType = mimeInfo.match(/data:([^;]+)/)?.[1] || "image/png";
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: mimeType });
const extension = mimeType.split("/")[1] || "png";
return {
data: blob,
filename: metadata?.filename || `image.${extension}`,
mimeType,
};
}
return {
data: imageUrl,
filename: metadata?.filename || "image.png",
mimeType: metadata?.mimeType || "image/png",
};
}
function isConcatenableImage(
_value: unknown,
_metadata?: OutputMetadata,
): boolean {
return false;
}
export const imageRenderer: OutputRenderer = {
name: "ImageRenderer",
priority: 40,
canRender: canRenderImage,
render: renderImage,
getCopyContent: getCopyContentImage,
getDownloadContent: getDownloadContentImage,
isConcatenable: isConcatenableImage,
};

View File

@@ -0,0 +1,204 @@
"use client";
import React, { useState } from "react";
import { CaretDown, CaretRight } from "@phosphor-icons/react";
import {
OutputRenderer,
OutputMetadata,
DownloadContent,
CopyContent,
} from "../types";
function canRenderJSON(value: unknown, _metadata?: OutputMetadata): boolean {
if (_metadata?.type === "json") {
return true;
}
if (typeof value === "object" && value !== null) {
return true;
}
if (typeof value === "string") {
try {
JSON.parse(value);
return true;
} catch {
return false;
}
}
return false;
}
function renderJSON(
value: unknown,
_metadata?: OutputMetadata,
): React.ReactNode {
let jsonData = value;
if (typeof value === "string") {
try {
jsonData = JSON.parse(value);
} catch {
return null;
}
}
return <JSONViewer data={jsonData} />;
}
function getCopyContentJSON(
value: unknown,
_metadata?: OutputMetadata,
): CopyContent | null {
const jsonString =
typeof value === "string" ? value : JSON.stringify(value, null, 2);
return {
mimeType: "application/json",
data: jsonString,
alternativeMimeTypes: ["text/plain"],
fallbackText: jsonString,
};
}
function getDownloadContentJSON(
value: unknown,
_metadata?: OutputMetadata,
): DownloadContent | null {
const jsonString =
typeof value === "string" ? value : JSON.stringify(value, null, 2);
const blob = new Blob([jsonString], { type: "application/json" });
return {
data: blob,
filename: _metadata?.filename || "output.json",
mimeType: "application/json",
};
}
function isConcatenableJSON(
_value: unknown,
_metadata?: OutputMetadata,
): boolean {
return true;
}
export const jsonRenderer: OutputRenderer = {
name: "JSONRenderer",
priority: 20,
canRender: canRenderJSON,
render: renderJSON,
getCopyContent: getCopyContentJSON,
getDownloadContent: getDownloadContentJSON,
isConcatenable: isConcatenableJSON,
};
function JSONViewer({ data }: { data: any }) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const toggleCollapse = (key: string) => {
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
};
const renderValue = (value: any, key: string = ""): React.ReactNode => {
if (value === null)
return <span className="text-muted-foreground">null</span>;
if (value === undefined)
return <span className="text-muted-foreground">undefined</span>;
if (typeof value === "boolean") {
return <span className="text-blue-600">{value.toString()}</span>;
}
if (typeof value === "number") {
return <span className="text-green-600">{value}</span>;
}
if (typeof value === "string") {
return <span className="text-orange-600">&quot;{value}&quot;</span>;
}
if (Array.isArray(value)) {
const isCollapsed = collapsed[key];
const itemCount = value.length;
if (itemCount === 0) {
return <span className="text-muted-foreground">[]</span>;
}
return (
<div className="inline-block">
<button
onClick={() => toggleCollapse(key)}
className="inline-flex items-center rounded px-1 hover:bg-muted"
>
{isCollapsed ? (
<CaretRight className="size-3" />
) : (
<CaretDown className="size-3" />
)}
<span className="ml-1 text-muted-foreground">
Array({itemCount})
</span>
</button>
{!isCollapsed && (
<div className="ml-4 mt-1">
{value.map((item, index) => (
<div key={index} className="flex">
<span className="mr-2 text-muted-foreground">{index}:</span>
{renderValue(item, `${key}[${index}]`)}
</div>
))}
</div>
)}
</div>
);
}
if (typeof value === "object") {
const isCollapsed = collapsed[key];
const keys = Object.keys(value);
if (keys.length === 0) {
return <span className="text-muted-foreground">{"{}"}</span>;
}
return (
<div className="inline-block">
<button
onClick={() => toggleCollapse(key)}
className="inline-flex items-center rounded px-1 hover:bg-muted"
>
{isCollapsed ? (
<CaretRight className="size-3" />
) : (
<CaretDown className="size-3" />
)}
<span className="ml-1 text-muted-foreground">Object</span>
</button>
{!isCollapsed && (
<div className="ml-4 mt-1">
{keys.map((objKey) => (
<div key={objKey} className="flex">
<span className="mr-2 text-purple-600">
&quot;{objKey}&quot;:
</span>
{renderValue(value[objKey], `${key}.${objKey}`)}
</div>
))}
</div>
)}
</div>
);
}
return <span className="text-muted-foreground">{String(value)}</span>;
};
return (
<div className="overflow-x-auto rounded-md bg-muted p-3 font-mono text-sm">
{renderValue(data, "root")}
</div>
);
}

View File

@@ -0,0 +1,447 @@
"use client";
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import rehypeHighlight from "rehype-highlight";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import {
OutputRenderer,
OutputMetadata,
DownloadContent,
CopyContent,
} from "../types";
import "highlight.js/styles/github-dark.css";
import "katex/dist/katex.min.css";
const markdownPatterns = [
/```[\s\S]*?```/u, // Fenced code blocks (check first)
/^#{1,6}\s+\S+/gmu, // ATX headers (require content)
/\*\*[^*\n]+?\*\*/u, // **bold**
/__(?!_)[^_\n]+?__(?!_)/u, // __bold__ (avoid ___/snake_case_)
/(?<!\*)\*(?!\*)(?:[^*\n]|(?<=\\)\*)+?(?<!\\)\*(?!\*)/u, // *italic* (try to avoid **)
/(?<!_)_(?!_)(?:[^_\n]|(?<=\\)_)+?(?<!\\)_(?!_)/u, // _italic_ with guards
/\[([^\]\n]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/u, // Links with optional title (simple)
/!\[([^\]\n]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/u, // Images with optional title (simple)
/`[^`\n]+`/u, // Inline code
/^(?:\s*[-*+]\s+\S.*)(?:\n\s*[-*+]\s+\S.*)+$/gmu, // UL list (2 items)
/^(?:\s*\d+\.\s+\S.*)(?:\n\s*\d+\.\s+\S.*)+$/gmu, // OL list (2 items)
/^>\s+\S.*/gm, // Blockquotes
/^\|[^|\n]+(\|[^|\n]+)+\|\s*$/gm, // Table row (at least two cells)
/^\s*\|(?:\s*:?[-=]{3,}\s*\|)+\s*$/gm, // Table separator row
/\$\$[\s\S]+?\$\$/u, // Display math
/(?<!\\)(?<!\w)\$[^$\n]+?\$(?!\w)/u, // Inline math: avoid prices/ids
];
const videoExtensions = [".mp4", ".webm", ".ogg", ".mov", ".avi"];
function isVideoUrl(url: string): boolean {
if (url.includes("youtube.com/watch") || url.includes("youtu.be/")) {
return true;
}
if (url.includes("vimeo.com/")) {
return true;
}
return videoExtensions.some((ext) => url.toLowerCase().includes(ext));
}
function getVideoEmbedUrl(url: string): string | null {
const youtubeMatch = url.match(
/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&\s]+)/,
);
if (youtubeMatch) {
return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
}
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/);
if (vimeoMatch) {
return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
}
if (videoExtensions.some((ext) => url.toLowerCase().includes(ext))) {
return url;
}
return null;
}
function renderVideoEmbed(url: string): React.ReactNode {
const embedUrl = getVideoEmbedUrl(url);
if (!embedUrl) {
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline"
>
{url}
</a>
);
}
if (videoExtensions.some((ext) => embedUrl.toLowerCase().includes(ext))) {
return (
<div className="my-4">
<video
controls
className="w-full max-w-2xl rounded-lg shadow-md"
preload="metadata"
>
<source src={embedUrl} />
Your browser does not support the video tag.
</video>
</div>
);
}
return (
<div className="my-4">
<div className="relative aspect-video">
<iframe
src={embedUrl}
title="Embedded video player"
className="absolute left-0 top-0 h-full w-full rounded-lg shadow-md"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
);
}
function canRenderMarkdown(value: unknown, metadata?: OutputMetadata): boolean {
if (
metadata?.type === "markdown" ||
metadata?.mimeType === "text/markdown" ||
metadata?.mimeType === "text/x-markdown"
) {
return true;
}
if (typeof value !== "string") {
return false;
}
if (metadata?.filename?.toLowerCase().endsWith(".md")) {
return true;
}
let matchCount = 0;
const requiredMatches = 2;
for (const pattern of markdownPatterns) {
if (pattern.test(value)) {
matchCount++;
if (matchCount >= requiredMatches) {
return true;
}
}
}
return false;
}
function renderMarkdown(
value: unknown,
_metadata?: OutputMetadata,
): React.ReactNode {
const markdownContent = String(value);
return (
<div className="markdown-output">
<ReactMarkdown
className="prose prose-sm dark:prose-invert max-w-none"
remarkPlugins={[
remarkGfm, // GitHub Flavored Markdown (tables, task lists, strikethrough)
remarkMath, // Math support for LaTeX
]}
rehypePlugins={[
rehypeKatex, // Render math with KaTeX
rehypeHighlight, // Syntax highlighting for code blocks
rehypeSlug, // Add IDs to headings
[rehypeAutolinkHeadings, { behavior: "wrap" }], // Make headings clickable
]}
components={{
// Custom components for better rendering
pre: ({ children, ...props }) => (
<pre
className="my-4 overflow-x-auto rounded-md bg-gray-900 p-4 dark:bg-gray-950"
{...props}
>
{children}
</pre>
),
code: ({ children, ...props }: any) => {
// Check if it's inline code by looking at the parent
const isInline = !props.className?.includes("language-");
if (isInline) {
return (
<code
className="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-200"
{...props}
>
{children}
</code>
);
}
// Block code is handled by rehype-highlight
return (
<code className="font-mono text-sm text-gray-100" {...props}>
{children}
</code>
);
},
a: ({ children, href, ...props }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-black underline decoration-1 underline-offset-2 transition-colors"
{...props}
>
{children}
</a>
),
blockquote: ({ children, ...props }) => (
<blockquote
className="my-4 border-l-4 border-blue-500 pl-4 italic text-gray-700 dark:border-blue-400 dark:text-gray-300"
{...props}
>
{children}
</blockquote>
),
table: ({ children, ...props }) => (
<div className="my-4 overflow-x-auto">
<table
className="min-w-full divide-y divide-gray-200 rounded-lg border border-gray-200 dark:divide-gray-700 dark:border-gray-700"
{...props}
>
{children}
</table>
</div>
),
th: ({ children, ...props }) => (
<th
className="bg-gray-50 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-700 dark:bg-gray-800 dark:text-gray-300"
{...props}
>
{children}
</th>
),
td: ({ children, ...props }) => (
<td
className="border-t border-gray-200 px-4 py-3 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
{...props}
>
{children}
</td>
),
// GitHub Flavored Markdown task lists
input: ({ ...props }: any) => {
if (props.type === "checkbox") {
return (
<input
type="checkbox"
className="mr-2 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-70"
disabled
{...props}
/>
);
}
return <input {...props} />;
},
// Better list styling
ul: ({ children, ...props }: any) => (
<ul
className={`my-4 list-disc space-y-2 pl-6 ${
props.className?.includes("contains-task-list")
? "list-none pl-0"
: ""
}`}
{...props}
>
{children}
</ul>
),
ol: ({ children, ...props }) => (
<ol className="my-4 list-decimal space-y-2 pl-6" {...props}>
{children}
</ol>
),
li: ({ children, ...props }: any) => (
<li
className={`text-gray-700 dark:text-gray-300 ${
props.className?.includes("task-list-item")
? "flex items-start"
: ""
}`}
{...props}
>
{children}
</li>
),
// Better heading styles
h1: ({ children, ...props }) => (
<h1
className="my-6 text-3xl font-bold text-gray-900 dark:text-gray-100"
{...props}
>
{children}
</h1>
),
h2: ({ children, ...props }) => (
<h2
className="my-5 text-2xl font-semibold text-gray-800 dark:text-gray-200"
{...props}
>
{children}
</h2>
),
h3: ({ children, ...props }) => (
<h3
className="my-4 text-xl font-semibold text-gray-800 dark:text-gray-200"
{...props}
>
{children}
</h3>
),
h4: ({ children, ...props }) => (
<h4
className="my-3 text-lg font-medium text-gray-700 dark:text-gray-300"
{...props}
>
{children}
</h4>
),
h5: ({ children, ...props }) => (
<h5
className="my-2 text-base font-medium text-gray-700 dark:text-gray-300"
{...props}
>
{children}
</h5>
),
h6: ({ children, ...props }) => (
<h6
className="my-2 text-sm font-medium text-gray-600 dark:text-gray-400"
{...props}
>
{children}
</h6>
),
// Horizontal rule
hr: ({ ...props }) => (
<hr
className="my-6 border-gray-300 dark:border-gray-700"
{...props}
/>
),
// Strikethrough (GFM)
del: ({ children, ...props }) => (
<del
className="text-gray-500 line-through dark:text-gray-500"
{...props}
>
{children}
</del>
),
// Image handling
img: ({ src, alt, ...props }) => {
// Check if it's a video URL pattern
if (src && isVideoUrl(src)) {
return renderVideoEmbed(src);
}
return (
<img
src={src}
alt={alt}
className="my-4 h-auto max-w-full rounded-lg shadow-md"
loading="lazy"
{...props}
/>
);
},
// Custom paragraph to handle standalone video URLs
p: ({ children, ...props }) => {
// Check if paragraph contains just a video URL
if (typeof children === "string" && isVideoUrl(children.trim())) {
return renderVideoEmbed(children.trim());
}
// Check for video URLs in link children
if (React.Children.count(children) === 1) {
const child = React.Children.toArray(children)[0];
if (React.isValidElement(child) && child.type === "a") {
const href = child.props.href;
if (href && isVideoUrl(href)) {
return renderVideoEmbed(href);
}
}
}
return (
<p
className="my-3 leading-relaxed text-gray-700 dark:text-gray-300"
{...props}
>
{children}
</p>
);
},
}}
>
{markdownContent}
</ReactMarkdown>
</div>
);
}
function getCopyContentMarkdown(
value: unknown,
_metadata?: OutputMetadata,
): CopyContent | null {
const markdownText = String(value);
return {
mimeType: "text/markdown",
data: markdownText,
alternativeMimeTypes: ["text/plain"],
fallbackText: markdownText,
};
}
function getDownloadContentMarkdown(
value: unknown,
metadata?: OutputMetadata,
): DownloadContent | null {
const markdownText = String(value);
const blob = new Blob([markdownText], { type: "text/markdown" });
return {
data: blob,
filename: metadata?.filename || "output.md",
mimeType: "text/markdown",
};
}
function isConcatenableMarkdown(
_value: unknown,
_metadata?: OutputMetadata,
): boolean {
return true;
}
export const markdownRenderer: OutputRenderer = {
name: "MarkdownRenderer",
priority: 35,
canRender: canRenderMarkdown,
render: renderMarkdown,
getCopyContent: getCopyContentMarkdown,
getDownloadContent: getDownloadContentMarkdown,
isConcatenable: isConcatenableMarkdown,
};

View File

@@ -0,0 +1,71 @@
import React from "react";
import {
OutputRenderer,
OutputMetadata,
DownloadContent,
CopyContent,
} from "../types";
function canRenderText(value: unknown, _metadata?: OutputMetadata): boolean {
return (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
);
}
function renderText(
value: unknown,
_metadata?: OutputMetadata,
): React.ReactNode {
const textValue = String(value);
return (
<p className="resize-none overflow-x-auto whitespace-pre-wrap break-words border-none text-sm text-neutral-700">
{textValue}
</p>
);
}
function getCopyContentText(
value: unknown,
_metadata?: OutputMetadata,
): CopyContent | null {
const textValue = String(value);
return {
mimeType: "text/plain",
data: textValue,
fallbackText: textValue,
};
}
function getDownloadContentText(
value: unknown,
_metadata?: OutputMetadata,
): DownloadContent | null {
const textValue = String(value);
const blob = new Blob([textValue], { type: "text/plain" });
return {
data: blob,
filename: _metadata?.filename || "output.txt",
mimeType: "text/plain",
};
}
function isConcatenableText(
_value: unknown,
_metadata?: OutputMetadata,
): boolean {
return true;
}
export const textRenderer: OutputRenderer = {
name: "TextRenderer",
priority: 0,
canRender: canRenderText,
render: renderText,
getCopyContent: getCopyContentText,
getDownloadContent: getDownloadContentText,
isConcatenable: isConcatenableText,
};

View File

@@ -0,0 +1,169 @@
import React from "react";
import {
OutputRenderer,
OutputMetadata,
DownloadContent,
CopyContent,
} from "../types";
const videoExtensions = [
".mp4",
".webm",
".ogg",
".mov",
".avi",
".mkv",
".m4v",
];
const videoMimeTypes = [
"video/mp4",
"video/webm",
"video/ogg",
"video/quicktime",
"video/x-msvideo",
"video/x-matroska",
];
function guessMimeType(url: string): string | null {
const extension = url.split(".").pop()?.toLowerCase();
const mimeMap: Record<string, string> = {
mp4: "video/mp4",
webm: "video/webm",
ogg: "video/ogg",
mov: "video/quicktime",
avi: "video/x-msvideo",
mkv: "video/x-matroska",
m4v: "video/mp4",
};
return extension ? mimeMap[extension] || null : null;
}
function canRenderVideo(value: unknown, metadata?: OutputMetadata): boolean {
if (
metadata?.type === "video" ||
(metadata?.mimeType && videoMimeTypes.includes(metadata.mimeType))
) {
return true;
}
if (typeof value === "string") {
if (value.startsWith("data:video/")) {
return true;
}
if (value.startsWith("http://") || value.startsWith("https://")) {
return videoExtensions.some((ext) => value.toLowerCase().includes(ext));
}
if (metadata?.filename) {
return videoExtensions.some((ext) =>
metadata.filename!.toLowerCase().endsWith(ext),
);
}
}
return false;
}
function renderVideo(
value: unknown,
metadata?: OutputMetadata,
): React.ReactNode {
const videoUrl = String(value);
return (
<div className="group relative">
<video
controls
className="h-auto max-w-full rounded-md border border-gray-200"
preload="metadata"
>
<source src={videoUrl} type={metadata?.mimeType || "video/mp4"} />
Your browser does not support the video tag.
</video>
</div>
);
}
function getCopyContentVideo(
value: unknown,
metadata?: OutputMetadata,
): CopyContent | null {
const videoUrl = String(value);
if (videoUrl.startsWith("data:")) {
const mimeMatch = videoUrl.match(/data:([^;]+)/);
const mimeType = mimeMatch?.[1] || "video/mp4";
return {
mimeType: mimeType,
data: videoUrl,
alternativeMimeTypes: ["text/plain"],
fallbackText: videoUrl,
};
}
const mimeType = metadata?.mimeType || guessMimeType(videoUrl) || "video/mp4";
return {
mimeType: mimeType,
data: async () => {
const response = await fetch(videoUrl);
return await response.blob();
},
alternativeMimeTypes: ["text/plain"],
fallbackText: videoUrl,
};
}
function getDownloadContentVideo(
value: unknown,
metadata?: OutputMetadata,
): DownloadContent | null {
const videoUrl = String(value);
if (videoUrl.startsWith("data:")) {
const [mimeInfo, base64Data] = videoUrl.split(",");
const mimeType = mimeInfo.match(/data:([^;]+)/)?.[1] || "video/mp4";
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: mimeType });
const extension = mimeType.split("/")[1] || "mp4";
return {
data: blob,
filename: metadata?.filename || `video.${extension}`,
mimeType,
};
}
return {
data: videoUrl,
filename: metadata?.filename || "video.mp4",
mimeType: metadata?.mimeType || "video/mp4",
};
}
function isConcatenableVideo(
_value: unknown,
_metadata?: OutputMetadata,
): boolean {
return false;
}
export const videoRenderer: OutputRenderer = {
name: "VideoRenderer",
priority: 45,
canRender: canRenderVideo,
render: renderVideo,
getCopyContent: getCopyContentVideo,
getDownloadContent: getDownloadContentVideo,
isConcatenable: isConcatenableVideo,
};

View File

@@ -0,0 +1,60 @@
import { ReactNode } from "react";
export interface OutputMetadata {
type?: string;
language?: string;
mimeType?: string;
filename?: string;
[key: string]: any;
}
export interface DownloadContent {
data: Blob | string;
filename: string;
mimeType: string;
}
export interface CopyContent {
mimeType: string; // Primary MIME type to try
data: Blob | string | (() => Promise<Blob | string>); // Data or async function to get data
fallbackText?: string; // Optional fallback text if rich copy fails
alternativeMimeTypes?: string[]; // Alternative MIME types to try if primary isn't supported
}
export interface OutputRenderer {
name: string;
priority: number;
canRender(value: any, metadata?: OutputMetadata): boolean;
render(value: any, metadata?: OutputMetadata): ReactNode;
getCopyContent(value: any, metadata?: OutputMetadata): CopyContent | null;
getDownloadContent(
value: any,
metadata?: OutputMetadata,
): DownloadContent | null;
isConcatenable(value: any, metadata?: OutputMetadata): boolean;
}
export class OutputRendererRegistry {
private renderers: OutputRenderer[] = [];
register(renderer: OutputRenderer): void {
const index = this.renderers.findIndex(
(r) => r.priority < renderer.priority,
);
if (index === -1) {
this.renderers.push(renderer);
} else {
this.renderers.splice(index, 0, renderer);
}
}
getRenderer(value: any, metadata?: OutputMetadata): OutputRenderer | null {
return this.renderers.find((r) => r.canRender(value, metadata)) || null;
}
getAllRenderers(): OutputRenderer[] {
return [...this.renderers];
}
}
export const globalRegistry = new OutputRendererRegistry();

View File

@@ -0,0 +1,115 @@
import { CopyContent } from "../types";
export function isClipboardTypeSupported(mimeType: string): boolean {
// ClipboardItem.supports() is the proper way to check
if ("ClipboardItem" in window && "supports" in ClipboardItem) {
return ClipboardItem.supports(mimeType);
}
// Fallback for browsers that don't support the supports() method
// These are generally supported
const fallbackSupported = ["text/plain", "text/html", "image/png"];
return fallbackSupported.includes(mimeType);
}
export function getSupportedClipboardType(
preferredTypes: string[],
): string | null {
for (const type of preferredTypes) {
if (isClipboardTypeSupported(type)) {
return type;
}
}
return null;
}
export async function copyToClipboard(copyContent: CopyContent): Promise<void> {
try {
// Determine the best supported MIME type
const supportedTypes = [
copyContent.mimeType,
...(copyContent.alternativeMimeTypes || []),
];
const bestType = getSupportedClipboardType(supportedTypes);
if (!bestType) {
// No supported type found, use fallback text if available
if (copyContent.fallbackText) {
await navigator.clipboard.writeText(copyContent.fallbackText);
return;
}
throw new Error(
`None of the MIME types are supported: ${supportedTypes.join(", ")}`,
);
}
// Get the data (resolve if it's a function)
let data = copyContent.data;
if (typeof data === "function") {
data = await data();
}
// If data is already a Blob, use it directly
if (data instanceof Blob) {
// If we need a different MIME type than the blob has, recreate it
if (bestType !== data.type && bestType !== copyContent.mimeType) {
data = new Blob([data], { type: bestType });
}
await navigator.clipboard.write([
new ClipboardItem({
[bestType]: data,
}),
]);
return;
}
// If data is a string
if (typeof data === "string") {
// For plain text, use the simpler writeText API
if (bestType === "text/plain") {
await navigator.clipboard.writeText(data);
return;
}
// For other text formats (HTML, JSON, etc.), create a blob
const blob = new Blob([data], { type: bestType });
await navigator.clipboard.write([
new ClipboardItem({
[bestType]: blob,
}),
]);
}
} catch (_error) {
// If rich copy fails and we have fallback text, try that
if (copyContent.fallbackText) {
try {
await navigator.clipboard.writeText(copyContent.fallbackText);
return;
} catch {
// Even fallback failed
}
}
throw _error;
}
}
export async function fetchAndCopyImage(imageUrl: string): Promise<void> {
try {
const response = await fetch(imageUrl);
if (!response.ok) throw new Error("Failed to fetch image");
const blob = await response.blob();
const mimeType = blob.type || "image/png";
await navigator.clipboard.write([
new ClipboardItem({
[mimeType]: blob,
}),
]);
} catch (_error) {
// If fetching fails (e.g., CORS), fall back to copying the URL
await navigator.clipboard.writeText(imageUrl);
}
}

View File

@@ -0,0 +1,74 @@
import { OutputRenderer, OutputMetadata } from "../types";
export interface DownloadItem {
value: any;
metadata?: OutputMetadata;
renderer: OutputRenderer;
}
export async function downloadOutputs(items: DownloadItem[]) {
const concatenableTexts: string[] = [];
const nonConcatenableDownloads: Array<{ blob: Blob; filename: string }> = [];
for (const item of items) {
if (item.renderer.isConcatenable(item.value, item.metadata)) {
const copyContent = item.renderer.getCopyContent(
item.value,
item.metadata,
);
if (copyContent) {
// Extract text from CopyContent
let text: string;
if (typeof copyContent.data === "string") {
text = copyContent.data;
} else if (copyContent.fallbackText) {
text = copyContent.fallbackText;
} else {
continue;
}
concatenableTexts.push(text);
}
} else {
const downloadContent = item.renderer.getDownloadContent(
item.value,
item.metadata,
);
if (downloadContent) {
if (typeof downloadContent.data === "string") {
if (downloadContent.data.startsWith("http")) {
const link = document.createElement("a");
link.href = downloadContent.data;
link.download = downloadContent.filename;
link.click();
}
} else {
nonConcatenableDownloads.push({
blob: downloadContent.data as Blob,
filename: downloadContent.filename,
});
}
}
}
}
if (concatenableTexts.length > 0) {
const combinedText = concatenableTexts.join("\n\n---\n\n");
const blob = new Blob([combinedText], { type: "text/plain" });
downloadBlob(blob, "combined_output.txt");
}
for (const download of nonConcatenableDownloads) {
downloadBlob(download.blob, download.filename);
}
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}

View File

@@ -9,6 +9,7 @@ export enum Flag {
NEW_BLOCK_MENU = "new-block-menu",
NEW_AGENT_RUNS = "new-agent-runs",
GRAPH_SEARCH = "graph-search",
ENABLE_ENHANCED_OUTPUT_HANDLING = "enable-enhanced-output-handling",
}
export type FlagValues = {
@@ -17,6 +18,7 @@ export type FlagValues = {
[Flag.NEW_BLOCK_MENU]: boolean;
[Flag.NEW_AGENT_RUNS]: boolean;
[Flag.GRAPH_SEARCH]: boolean;
[Flag.ENABLE_ENHANCED_OUTPUT_HANDLING]: boolean;
};
const isPwMockEnabled = process.env.NEXT_PUBLIC_PW_TEST === "true";
@@ -27,6 +29,7 @@ const mockFlags = {
[Flag.NEW_BLOCK_MENU]: false,
[Flag.NEW_AGENT_RUNS]: false,
[Flag.GRAPH_SEARCH]: true,
[Flag.ENABLE_ENHANCED_OUTPUT_HANDLING]: false,
};
export function useGetFlag<T extends Flag>(flag: T): FlagValues[T] | null {