feat(desktop): embed BoosAPI Desktop client as a subproject

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" sizes="64x64" href="/favicon.png" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<meta
name="description"
content="markstream-react React 18 playground for streaming Markdown, Mermaid, KaTeX, and large-document rendering."
/>
<title>markstream-react Playground | React 18 demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,12 @@
[build]
publish = "dist"
command = "npx pnpm i && pnpm --filter stream-markdown-parser build && pnpm --filter markstream-react build && npm run build"
[build.environment]
NPM_FLAGS = "--version"
NODE_VERSION = "22"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
@@ -0,0 +1,29 @@
{
"name": "playground-react18",
"type": "module",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc -b --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@iconify/react": "^4.1.1",
"markstream-react": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.11.0",
"autoprefixer": "^10.4.27",
"monaco-editor": "^0.52.2",
"postcss": "^8.5.8",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-monaco-editor-esm": "^2.0.2"
}
}
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: { config: './tailwind.config.js' },
autoprefixer: {},
},
}
@@ -0,0 +1,5 @@
# Cloudflare Pages 重定向规则
# SPA 路由: 将所有请求重定向到 index.html
# https://developers.cloudflare.com/pages/configuration/redirects/
/* /index.html 200
Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 B

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<style>
path { fill: #222; }
@media (prefers-color-scheme: dark) {
path { fill: #ffffff; }
}
</style>
<path d="M27.562 26L17.17 8.928l2.366-3.888L17.828 4L16 7.005L14.17 4l-1.708 1.04l2.366 3.888L4.438 26H2v2h28v-2zM16 10.85L25.22 26H17v-8h-2v8H6.78z" />
</svg>

After

Width:  |  Height:  |  Size: 347 B

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Vue Markdown Renderer">
<title>Vue Markdown Renderer</title>
<defs>
<linearGradient id="g-vue" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#41b883" />
<stop offset="1" stop-color="#2c9f67" />
</linearGradient>
<linearGradient id="g-accent" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#3aa8ff" />
<stop offset="1" stop-color="#5cc6ff" />
</linearGradient>
</defs>
<!-- background card -->
<rect x="6" y="6" width="116" height="116" rx="16" fill="#0b1220" />
<rect x="12" y="12" width="104" height="104" rx="12" fill="#ffffff" />
<!-- folded corner -->
<path d="M92 12 H108 V28" fill="#f6f7f8" opacity="0.9" />
<path d="M92 12 L108 28" stroke="#e6e6e6" stroke-width="1.5" fill="none" />
<!-- document area -->
<rect x="28" y="26" width="72" height="76" rx="6" fill="#ffffff" />
<!-- Vue chevron (simplified) -->
<g transform="translate(34,30)">
<path d="M10 0 L24 24 L0 24 Z" fill="url(#g-vue)" />
<path d="M10 6 L24 30 L0 30 Z" fill="#063" opacity="0.95" />
</g>
<!-- Markdown hash (#) drawn with strokes (reliable across renderers) -->
<g transform="translate(64,46)" stroke="#0b1220" stroke-width="4" stroke-linecap="round">
<line x1="-12" y1="-4" x2="12" y2="-4" />
<line x1="-12" y1="8" x2="12" y2="8" />
<line x1="-6" y1="-12" x2="-6" y2="20" />
<line x1="6" y1="-12" x2="6" y2="20" />
</g>
<!-- small code brackets to indicate renderer -->
<g transform="translate(36,84)" stroke="#0b1220" stroke-width="3" fill="none" stroke-linecap="round">
<path d="M0 0 L8 8 L0 16" />
<path d="M56 0 L48 8 L56 16" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,759 @@
import type { StreamPresetId } from './shared/streamPresets'
import type { StreamSliceMode, StreamTransportMode } from './shared/useStreamSimulator'
import { Icon } from '@iconify/react'
import { NodeRenderer, setCustomComponents, setKaTeXWorker, setMermaidWorker } from 'markstream-react'
import KatexWorker from 'markstream-react/workers/katexRenderer.worker?worker&inline'
import MermaidWorker from 'markstream-react/workers/mermaidParser.worker?worker&inline'
import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ThinkingNode } from './components/ThinkingNode'
import { streamContent } from './markdown'
import { PLAYGROUND_CUSTOM_HTML_TAGS, PLAYGROUND_CUSTOM_ID } from './shared/markstreamPlayground'
import { MigrationDemoPage } from './shared/MigrationDemoPage'
import { CUSTOM_STREAM_PRESET_ID, findMatchingStreamPreset, getStreamPreset, STREAM_PRESETS } from './shared/streamPresets'
import { TestLab } from './shared/TestLab'
import { useChatAutoScroll } from './shared/useChatAutoScroll'
import { clampStreamControl, normalizeStreamRange, useStreamSimulator } from './shared/useStreamSimulator'
setKaTeXWorker(new KatexWorker())
setMermaidWorker(new MermaidWorker())
setCustomComponents(PLAYGROUND_CUSTOM_ID, {
thinking: ThinkingNode,
})
const MemoizedNodeRenderer = memo(NodeRenderer)
const THEMES = [
'andromeeda',
'aurora-x',
'ayu-dark',
'catppuccin-frappe',
'catppuccin-latte',
'catppuccin-macchiato',
'catppuccin-mocha',
'dark-plus',
'dracula',
'dracula-soft',
'everforest-dark',
'everforest-light',
'github-dark',
'github-dark-default',
'github-dark-dimmed',
'github-dark-high-contrast',
'github-light',
'github-light-default',
'github-light-high-contrast',
'gruvbox-dark-hard',
'gruvbox-dark-medium',
'gruvbox-dark-soft',
'gruvbox-light-hard',
'gruvbox-light-medium',
'gruvbox-light-soft',
'houston',
'kanagawa-dragon',
'kanagawa-lotus',
'kanagawa-wave',
'laserwave',
'light-plus',
'material-theme',
'material-theme-darker',
'material-theme-lighter',
'material-theme-ocean',
'material-theme-palenight',
'min-dark',
'min-light',
'monokai',
'night-owl',
'nord',
'one-dark-pro',
'one-light',
'plastic',
'poimandres',
'red',
'rose-pine',
'rose-pine-dawn',
'rose-pine-moon',
'slack-dark',
'slack-ochin',
'snazzy-light',
'solarized-dark',
'solarized-light',
'synthwave-84',
'tokyo-night',
'vesper',
'vitesse-black',
'vitesse-dark',
'vitesse-light',
] as const
const STREAM_DELAY_MIN_KEY = 'vmr-settings-stream-delay-min'
const STREAM_DELAY_MAX_KEY = 'vmr-settings-stream-delay-max'
const STREAM_CHUNK_MIN_KEY = 'vmr-settings-stream-chunk-size-min'
const STREAM_CHUNK_MAX_KEY = 'vmr-settings-stream-chunk-size-max'
const STREAM_BURSTINESS_KEY = 'vmr-settings-stream-burstiness'
const STREAM_TRANSPORT_MODE_KEY = 'vmr-settings-stream-transport-mode'
const STREAM_SLICE_MODE_KEY = 'vmr-settings-stream-slice-mode'
const THEME_KEYS = ['vmr-settings-selected-theme', 'vmv-settings-selected-theme'] as const
const DARK_MODE_KEY = 'vueuse-color-scheme'
function formatThemeName(theme: string) {
return theme
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
function readNumber(key: string, fallback: number) {
if (typeof window === 'undefined')
return fallback
const raw = window.localStorage.getItem(key)
if (!raw)
return fallback
const parsed = Number(raw)
return Number.isFinite(parsed) ? parsed : fallback
}
function readThemeFromStorage(fallback: string) {
if (typeof window === 'undefined')
return fallback
for (const key of THEME_KEYS) {
const raw = window.localStorage.getItem(key)
if (raw && raw.trim())
return raw
}
return fallback
}
function readString(key: string, fallback: string) {
if (typeof window === 'undefined')
return fallback
const raw = window.localStorage.getItem(key)
if (!raw || !raw.trim())
return fallback
return raw
}
function normalizePath(pathname: string) {
const normalized = pathname.replace(/\/+$/, '')
return normalized || '/'
}
function useMediaQuery(query: string) {
const [matches, setMatches] = useState(() => {
if (typeof window === 'undefined')
return false
return window.matchMedia(query).matches
})
useEffect(() => {
if (typeof window === 'undefined')
return
const mediaQuery = window.matchMedia(query)
const update = () => setMatches(mediaQuery.matches)
update()
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', update)
return () => mediaQuery.removeEventListener('change', update)
}
mediaQuery.addListener(update)
return () => mediaQuery.removeListener(update)
}, [query])
return matches
}
export default function App() {
const [currentPath, setCurrentPath] = useState(() => {
if (typeof window === 'undefined')
return '/'
return normalizePath(window.location.pathname)
})
const [showSettings, setShowSettings] = useState(false)
const [selectedTheme, setSelectedTheme] = useState(() => readThemeFromStorage('vitesse-dark'))
const [streamChunkDelayMin, setStreamChunkDelayMin] = useState(() => readNumber(STREAM_DELAY_MIN_KEY, 14))
const [streamChunkDelayMax, setStreamChunkDelayMax] = useState(() => readNumber(STREAM_DELAY_MAX_KEY, 34))
const [streamChunkSizeMin, setStreamChunkSizeMin] = useState(() => readNumber(STREAM_CHUNK_MIN_KEY, 2))
const [streamChunkSizeMax, setStreamChunkSizeMax] = useState(() => readNumber(STREAM_CHUNK_MAX_KEY, 7))
const [streamBurstiness, setStreamBurstiness] = useState(() => readNumber(STREAM_BURSTINESS_KEY, 35))
const [streamTransportMode, setStreamTransportMode] = useState<StreamTransportMode>(() => readString(STREAM_TRANSPORT_MODE_KEY, 'readable-stream') as StreamTransportMode)
const [streamSliceMode, setStreamSliceMode] = useState<StreamSliceMode>(() => readString(STREAM_SLICE_MODE_KEY, 'pure-random') as StreamSliceMode)
const [isDark, setIsDark] = useState(() => {
if (typeof window === 'undefined')
return false
const stored = window.localStorage.getItem(DARK_MODE_KEY)
if (stored)
return stored === 'dark'
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false
})
const normalizedChunkDelayRange = useMemo(() => normalizeStreamRange(
streamChunkDelayMin,
streamChunkDelayMax,
8,
240,
14,
34,
), [streamChunkDelayMax, streamChunkDelayMin])
const normalizedChunkSizeRange = useMemo(() => normalizeStreamRange(
streamChunkSizeMin,
streamChunkSizeMax,
1,
24,
2,
7,
), [streamChunkSizeMax, streamChunkSizeMin])
const normalizedBurstiness = useMemo(
() => Math.round(clampStreamControl(streamBurstiness, 0, 100, 35)),
[streamBurstiness],
)
const activeStreamPreset = useMemo(() => findMatchingStreamPreset({
chunkDelayMin: normalizedChunkDelayRange.min,
chunkDelayMax: normalizedChunkDelayRange.max,
chunkSizeMin: normalizedChunkSizeRange.min,
chunkSizeMax: normalizedChunkSizeRange.max,
burstiness: normalizedBurstiness,
}), [normalizedBurstiness, normalizedChunkDelayRange.max, normalizedChunkDelayRange.min, normalizedChunkSizeRange.max, normalizedChunkSizeRange.min])
const streamPresetDescription = activeStreamPreset?.description ?? 'Custom min/max window with your own burst profile.'
const selectedStreamPresetId = activeStreamPreset?.id ?? CUSTOM_STREAM_PRESET_ID
const streamChunkRangeLabel = `${normalizedChunkSizeRange.min}-${normalizedChunkSizeRange.max}`
const streamDelayRangeLabel = `${normalizedChunkDelayRange.min}-${normalizedChunkDelayRange.max}ms`
const {
content,
start: startStreamSimulation,
stop: stopStreamSimulation,
} = useStreamSimulator({
source: streamContent,
chunkSizeMin: normalizedChunkSizeRange.min,
chunkSizeMax: normalizedChunkSizeRange.max,
chunkDelayMin: normalizedChunkDelayRange.min,
chunkDelayMax: normalizedChunkDelayRange.max,
burstiness: normalizedBurstiness / 100,
sliceMode: streamSliceMode,
transportMode: streamTransportMode,
})
const themeOptions = useMemo(() => Array.from(THEMES), [])
const messagesRef = useRef<HTMLDivElement | null>(null)
const settingsRootRef = useRef<HTMLDivElement | null>(null)
const isCompactSettings = useMediaQuery('(max-width: 1023px)')
const shouldShowSettingsPanel = !isCompactSettings || showSettings
const isTestPage = currentPath === '/test'
const isMigrationDemoPage = currentPath === '/migration-demo'
useChatAutoScroll(messagesRef, content)
const navigate = useCallback((pathname: string) => {
if (typeof window === 'undefined')
return
const nextPath = normalizePath(pathname)
if (nextPath !== normalizePath(window.location.pathname))
window.history.pushState({}, '', nextPath)
startTransition(() => {
setCurrentPath(nextPath)
})
}, [])
useEffect(() => {
if (typeof window === 'undefined')
return
const handlePopState = () => {
startTransition(() => {
setCurrentPath(normalizePath(window.location.pathname))
})
}
window.addEventListener('popstate', handlePopState)
return () => {
window.removeEventListener('popstate', handlePopState)
}
}, [])
useEffect(() => {
if (typeof document === 'undefined')
return
document.documentElement.classList.toggle('dark', isDark)
if (typeof window !== 'undefined')
window.localStorage.setItem(DARK_MODE_KEY, isDark ? 'dark' : 'light')
}, [isDark])
useEffect(() => {
if (streamChunkDelayMin !== normalizedChunkDelayRange.min) {
setStreamChunkDelayMin(normalizedChunkDelayRange.min)
return
}
if (typeof window !== 'undefined')
window.localStorage.setItem(STREAM_DELAY_MIN_KEY, String(normalizedChunkDelayRange.min))
}, [normalizedChunkDelayRange.min, streamChunkDelayMin])
useEffect(() => {
if (streamChunkDelayMax !== normalizedChunkDelayRange.max) {
setStreamChunkDelayMax(normalizedChunkDelayRange.max)
return
}
if (typeof window !== 'undefined')
window.localStorage.setItem(STREAM_DELAY_MAX_KEY, String(normalizedChunkDelayRange.max))
}, [normalizedChunkDelayRange.max, streamChunkDelayMax])
useEffect(() => {
if (streamChunkSizeMin !== normalizedChunkSizeRange.min) {
setStreamChunkSizeMin(normalizedChunkSizeRange.min)
return
}
if (typeof window !== 'undefined')
window.localStorage.setItem(STREAM_CHUNK_MIN_KEY, String(normalizedChunkSizeRange.min))
}, [normalizedChunkSizeRange.min, streamChunkSizeMin])
useEffect(() => {
if (streamChunkSizeMax !== normalizedChunkSizeRange.max) {
setStreamChunkSizeMax(normalizedChunkSizeRange.max)
return
}
if (typeof window !== 'undefined')
window.localStorage.setItem(STREAM_CHUNK_MAX_KEY, String(normalizedChunkSizeRange.max))
}, [normalizedChunkSizeRange.max, streamChunkSizeMax])
useEffect(() => {
if (streamBurstiness !== normalizedBurstiness) {
setStreamBurstiness(normalizedBurstiness)
return
}
if (typeof window !== 'undefined')
window.localStorage.setItem(STREAM_BURSTINESS_KEY, String(normalizedBurstiness))
}, [normalizedBurstiness, streamBurstiness])
useEffect(() => {
if (typeof window === 'undefined')
return
window.localStorage.setItem(STREAM_TRANSPORT_MODE_KEY, streamTransportMode)
}, [streamTransportMode])
useEffect(() => {
if (typeof window === 'undefined')
return
window.localStorage.setItem(STREAM_SLICE_MODE_KEY, streamSliceMode)
}, [streamSliceMode])
useEffect(() => {
if (typeof window === 'undefined')
return
for (const key of THEME_KEYS)
window.localStorage.setItem(key, selectedTheme)
}, [selectedTheme])
useEffect(() => {
if (!showSettings || !isCompactSettings)
return
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
const root = settingsRootRef.current
if (!root)
return
if (root.contains(event.target as Node))
return
setShowSettings(false)
}
document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('touchstart', handlePointerDown)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('touchstart', handlePointerDown)
}
}, [isCompactSettings, showSettings])
useEffect(() => {
if (!isCompactSettings)
setShowSettings(false)
}, [isCompactSettings])
useEffect(() => {
if (isTestPage || isMigrationDemoPage)
return
startStreamSimulation()
return () => {
stopStreamSimulation()
}
}, [isMigrationDemoPage, isTestPage, startStreamSimulation, stopStreamSimulation])
const goToTest = () => {
navigate('/test')
}
const goToMigrationDemo = () => {
navigate('/migration-demo')
}
const handleStreamPresetChange = (presetId: StreamPresetId) => {
if (presetId === CUSTOM_STREAM_PRESET_ID)
return
const preset = getStreamPreset(presetId)
if (!preset)
return
setStreamChunkDelayMin(preset.chunkDelayMin)
setStreamChunkDelayMax(preset.chunkDelayMax)
setStreamChunkSizeMin(preset.chunkSizeMin)
setStreamChunkSizeMax(preset.chunkSizeMax)
setStreamBurstiness(preset.burstiness)
}
if (isTestPage)
return <TestLab frameworkLabel="React 18" onGoHome={() => navigate('/')} />
if (isMigrationDemoPage) {
return (
<MigrationDemoPage
isDark={isDark}
onGoHome={() => navigate('/')}
onGoTest={() => navigate('/test')}
/>
)
}
return (
<div className="markstream-vue h-full">
<div className="flex items-center justify-center p-4 lg:pr-[304px] app-container h-full bg-gray-50 dark:bg-gray-900">
<div ref={settingsRootRef} className="fixed top-4 right-4 z-10 pointer-events-none flex flex-col items-end gap-2">
{isCompactSettings && (
<button
type="button"
className={`pointer-events-auto settings-toggle w-10 h-10 rounded-full bg-white/95 dark:bg-gray-800/95 backdrop-blur-md border border-gray-200/50 dark:border-gray-700/50 hover:bg-gray-50 dark:hover:bg-gray-700/50 shadow-lg dark:shadow-gray-900/20 transition-all duration-200 flex items-center justify-center focus:outline-none focus:ring-2 focus:ring-blue-500/50 ${showSettings ? 'ring-2 ring-blue-500/50' : ''}`}
onClick={() => setShowSettings(value => !value)}
>
<Icon
icon="carbon:settings"
className={`w-5 h-5 text-gray-600 dark:text-gray-400 transition-transform duration-200 ${showSettings ? 'rotate-90' : ''}`}
/>
</button>
)}
{shouldShowSettingsPanel && (
<div
className={`pointer-events-auto settings-panel bg-white/95 dark:bg-gray-800/95 backdrop-blur-md border border-gray-200/50 dark:border-gray-700/50 rounded-xl shadow-xl dark:shadow-gray-900/30 p-4 space-y-4 min-w-[220px] w-[280px] overflow-y-auto ${isCompactSettings ? 'absolute top-12 right-0 mt-2 max-h-[calc(100vh-5rem)] origin-top-right transition-all duration-200 ease-out' : 'max-h-[calc(100vh-2rem)]'}`}
>
{!isCompactSettings && (
<div className="flex items-center gap-2 border-b border-gray-200/70 pb-2 dark:border-gray-700/70">
<Icon icon="carbon:settings" className="w-4 h-4 text-gray-500 dark:text-gray-400" />
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-gray-500 dark:text-gray-400">
Settings
</span>
</div>
)}
<div className="space-y-2">
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
Code Theme
</label>
<div className="relative theme-selector">
<select
value={selectedTheme}
onChange={event => setSelectedTheme(event.target.value)}
className="w-full appearance-none px-3 py-2 pr-8 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-all duration-200 cursor-pointer"
>
{THEMES.map(theme => (
<option key={theme} value={theme}>
{formatThemeName(theme)}
</option>
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<Icon icon="carbon:chevron-down" className="w-4 h-4 text-gray-400 dark:text-gray-500" />
</div>
</div>
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
Stream Profile
</label>
<div className="relative">
<select
value={selectedStreamPresetId}
onChange={event => handleStreamPresetChange(event.target.value as StreamPresetId)}
className="w-full appearance-none px-3 py-2 pr-8 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-all duration-200 cursor-pointer"
>
{STREAM_PRESETS.map(preset => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
<option value={CUSTOM_STREAM_PRESET_ID}>Custom</option>
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<Icon icon="carbon:chevron-down" className="w-4 h-4 text-gray-400 dark:text-gray-500" />
</div>
</div>
<p className="text-[11px] leading-5 text-gray-500 dark:text-gray-400">
{streamPresetDescription}
</p>
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
Transport
</label>
<div className="relative">
<select
value={streamTransportMode}
onChange={event => setStreamTransportMode(event.target.value as StreamTransportMode)}
className="w-full appearance-none px-3 py-2 pr-8 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-all duration-200 cursor-pointer"
>
<option value="readable-stream">ReadableStream</option>
<option value="scheduler">Scheduler</option>
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<Icon icon="carbon:chevron-down" className="w-4 h-4 text-gray-400 dark:text-gray-500" />
</div>
</div>
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
Slice Mode
</label>
<div className="relative">
<select
value={streamSliceMode}
onChange={event => setStreamSliceMode(event.target.value as StreamSliceMode)}
className="w-full appearance-none px-3 py-2 pr-8 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-all duration-200 cursor-pointer"
>
<option value="pure-random">Pure Random</option>
<option value="boundary-aware">Boundary Aware</option>
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<Icon icon="carbon:chevron-down" className="w-4 h-4 text-gray-400 dark:text-gray-500" />
</div>
</div>
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
chunkDelayMin
</label>
<div className="flex items-center gap-3">
<input
type="range"
min={8}
max={240}
step={4}
value={streamChunkDelayMin}
onChange={event => setStreamChunkDelayMin(Number(event.target.value))}
className="flex-1 cursor-pointer"
/>
<span className="text-xs font-medium text-gray-600 dark:text-gray-400 w-14 text-right">
{normalizedChunkDelayRange.min}
ms
</span>
</div>
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
chunkDelayMax
</label>
<div className="flex items-center gap-3">
<input
type="range"
min={8}
max={240}
step={4}
value={streamChunkDelayMax}
onChange={event => setStreamChunkDelayMax(Number(event.target.value))}
className="flex-1 cursor-pointer"
/>
<span className="text-xs font-medium text-gray-600 dark:text-gray-400 w-14 text-right">
{normalizedChunkDelayRange.max}
ms
</span>
</div>
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
chunkSizeMin
</label>
<div className="flex items-center gap-3">
<input
type="range"
min={1}
max={24}
step={1}
value={streamChunkSizeMin}
onChange={event => setStreamChunkSizeMin(Number(event.target.value))}
className="flex-1 cursor-pointer"
/>
<span className="text-xs font-medium text-gray-600 dark:text-gray-400 w-14 text-right">
{normalizedChunkSizeRange.min}
</span>
</div>
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
chunkSizeMax
</label>
<div className="flex items-center gap-3">
<input
type="range"
min={1}
max={24}
step={1}
value={streamChunkSizeMax}
onChange={event => setStreamChunkSizeMax(Number(event.target.value))}
className="flex-1 cursor-pointer"
/>
<span className="text-xs font-medium text-gray-600 dark:text-gray-400 w-14 text-right">
{normalizedChunkSizeRange.max}
</span>
</div>
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
Burstiness
</label>
<div className="flex items-center gap-3">
<input
type="range"
min={0}
max={100}
step={1}
value={streamBurstiness}
onChange={event => setStreamBurstiness(Number(event.target.value))}
className="flex-1 cursor-pointer"
/>
<span className="text-xs font-medium text-gray-600 dark:text-gray-400 w-12 text-right">
{normalizedBurstiness}
%
</span>
</div>
</div>
<p className="text-[11px] leading-5 text-gray-500 dark:text-gray-400">
Active window:
{' '}
{streamChunkRangeLabel}
{' '}
chars and
{' '}
{streamDelayRangeLabel}
. When min=max, the cadence becomes fixed.
</p>
<p className="text-[11px] leading-5 text-gray-500 dark:text-gray-400">
<code>Pure Random</code>
{' '}
uses raw random
<code>slice</code>
;
<code>Boundary Aware</code>
{' '}
snaps toward word and punctuation boundaries.
<code>ReadableStream</code>
{' '}
is closest to the real reader path.
</p>
<div className="border-t border-gray-200 dark:border-gray-700" />
<div className="flex items-center justify-between">
<label className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
Dark Mode
</label>
<button
type="button"
className="relative w-12 h-6 rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500/50 hover:shadow-lg active:scale-95 transition-all duration-200 ease-out"
style={{
backgroundColor: isDark ? '#3b82f6' : '#e5e7eb',
transition: 'background-color 0.35s ease-out, box-shadow 0.2s ease, transform 0.1s ease',
}}
onClick={() => setIsDark(value => !value)}
>
<div
className="absolute top-0.5 w-5 h-5 bg-white rounded-full flex items-center justify-center shadow-md"
style={{
left: isDark ? '26px' : '2px',
transform: `scale(${isDark ? 1.02 : 1})`,
transition: 'left 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94), transform 0.2s ease-out, box-shadow 0.2s ease',
}}
>
{isDark
? <Icon icon="carbon:moon" className="w-3 h-3 text-blue-600 drop-shadow-sm" />
: <Icon icon="carbon:sun" className="w-3 h-3 text-yellow-500 drop-shadow-sm" />}
</div>
</button>
</div>
</div>
)}
</div>
<div className="chatbot-container max-w-5xl w-full bg-white dark:bg-gray-800 rounded-2xl shadow-2xl dark:shadow-gray-900/50 flex flex-col overflow-hidden border border-gray-200 dark:border-gray-700">
<div className="chatbot-header px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-gray-800 dark:to-gray-800">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center shadow-lg">
<Icon icon="carbon:chat" className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-lg font-semibold text-gray-800 dark:text-gray-100">
markstream-react18
</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">
Streaming markdown demo
</p>
</div>
</div>
<div className="flex">
<a
href="https://github.com/Simon-He95/markstream-vue"
target="_blank"
rel="noreferrer"
className="github-star-btn flex items-center gap-2 px-3 py-1.5 bg-gray-800 dark:bg-gray-700 hover:bg-gray-700 dark:hover:bg-gray-600 text-white text-sm font-medium rounded-lg transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50"
>
<Icon icon="carbon:star" className="w-4 h-4" />
<span>Star</span>
</a>
<button
type="button"
className="ml-2 test-page-btn flex items-center gap-2 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition-all duration-200 shadow-md focus:outline-none focus:ring-2 focus:ring-blue-500/50"
title="Go to Test page"
onClick={goToTest}
>
<Icon icon="carbon:rocket" className="w-4 h-4" />
<span>Test</span>
</button>
<button
type="button"
className="ml-2 migration-demo-btn flex items-center gap-2 px-3 py-1.5 bg-white text-slate-700 hover:bg-slate-100 text-sm font-medium rounded-lg transition-all duration-200 shadow-md border border-slate-200 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
title="Open the react-markdown migration demo"
onClick={goToMigrationDemo}
>
<Icon icon="carbon:data-structured" className="w-4 h-4" />
<span>Migration</span>
</button>
</div>
</div>
</div>
<main ref={messagesRef} className="chatbot-messages flex-1 overflow-y-auto mr-[1px] mb-4 flex flex-col">
<div className="chatbot-renderer-shell">
<MemoizedNodeRenderer
content={content}
codeBlockDarkTheme={selectedTheme}
codeBlockLightTheme={selectedTheme}
themes={themeOptions}
isDark={isDark}
customId={PLAYGROUND_CUSTOM_ID}
customHtmlTags={PLAYGROUND_CUSTOM_HTML_TAGS}
deferNodesUntilVisible={false}
maxLiveNodes={2000}
liveNodeBuffer={200}
viewportPriority={false}
/>
</div>
</main>
</div>
</div>
</div>
)
}
@@ -0,0 +1,100 @@
import type { NodeComponentProps } from 'markstream-react'
import type { ReactNode } from 'react'
import { NodeRenderer } from 'markstream-react'
import { PLAYGROUND_CUSTOM_HTML_TAGS } from '../shared/markstreamPlayground'
interface ThinkingNodeData {
node: {
type: 'thinking'
content: string
loading?: boolean
attrs?: Array<{ name: string, value: string | boolean }>
}
}
type ThinkingNodeProps = Partial<NodeComponentProps<ThinkingNodeData['node']>> & {
children?: ReactNode
}
function getResolvedNode(props: ThinkingNodeProps): ThinkingNodeData['node'] {
if (props.node)
return props.node
return {
type: 'thinking',
content: '',
loading: false,
}
}
export function ThinkingNode(props: ThinkingNodeProps) {
const node = getResolvedNode(props)
const dotsClass = node.loading ? 'thinking-dots visible' : 'thinking-dots hidden'
const ctx = props.ctx
const inheritedCustomId = props.customId ?? ctx?.customId
const inheritedIsDark = props.isDark ?? ctx?.isDark
const inheritedTypewriter = props.typewriter ?? ctx?.typewriter ?? true
const hasStructuredNode = Boolean(props.node)
return (
<div className="thinking-node p-4 my-4 bg-blue-50 dark:bg-blue-900/40 rounded-md border-l-4 border-blue-400 flex items-start gap-3">
<div className="flex-shrink-0 mt-1">
<div className="w-9 h-9 rounded-full bg-blue-200 dark:bg-blue-700 flex items-center justify-center text-blue-700 dark:text-blue-100">
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 3C7.03 3 3 6.58 3 11c0 1.86.66 3.57 1.77 4.98L4 21l5.2-1.9C10.06 19.35 11 19.5 12 19.5c4.97 0 9-3.58 9-8.5S16.97 3 12 3z"
stroke="currentColor"
strokeWidth="0.8"
fill="currentColor"
opacity="0.9"
/>
</svg>
</div>
</div>
<div className="flex-1">
<div className="flex items-baseline gap-3">
<strong className="text-sm">Thinking</strong>
<span className="text-xs text-slate-500 dark:text-slate-300">(assistant)</span>
<span className="ml-2" aria-hidden="true">
<span className={dotsClass} aria-hidden="true">
<span className="dot dot-1" />
<span className="dot dot-2" />
<span className="dot dot-3" />
</span>
</span>
</div>
<div className="mt-1 text-sm leading-relaxed text-slate-800 dark:text-slate-100">
{node.loading && <span className="sr-only" aria-live="polite">Thinking…</span>}
<div className="content-area">
{hasStructuredNode
? (
<NodeRenderer
content={String(node.content ?? '')}
customId={inheritedCustomId}
customHtmlTags={PLAYGROUND_CUSTOM_HTML_TAGS}
isDark={inheritedIsDark}
themes={ctx?.codeBlockThemes?.themes}
codeBlockDarkTheme={ctx?.codeBlockThemes?.darkTheme}
codeBlockLightTheme={ctx?.codeBlockThemes?.lightTheme}
codeBlockMonacoOptions={ctx?.codeBlockThemes?.monacoOptions}
codeBlockMinWidth={ctx?.codeBlockThemes?.minWidth}
codeBlockMaxWidth={ctx?.codeBlockThemes?.maxWidth}
codeBlockProps={ctx?.codeBlockProps}
codeBlockStream={ctx?.codeBlockStream}
renderCodeBlocksAsPre={ctx?.renderCodeBlocksAsPre}
typewriter={inheritedTypewriter}
viewportPriority={false}
deferNodesUntilVisible={false}
batchRendering={false}
maxLiveNodes={0}
/>
)
: props.children}
</div>
</div>
</div>
</div>
)
}
export default ThinkingNode
@@ -0,0 +1,246 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#root {
height: 100%;
margin: 0;
padding: 0;
}
:root {
--tw-gradient-from-position: 0%;
--tw-gradient-to-position: 100%;
}
html {
color: #334155;
}
html.dark {
background: #121212;
color: #e0e0e0;
}
.app-container {
transition: background-color 0.3s ease;
overflow: hidden;
}
.chatbot-container {
transition: all 0.3s ease;
overscroll-behavior: contain;
height: calc(var(--app-viewport-vh, 1vh) * 100 - 2rem);
max-height: calc(var(--app-viewport-vh, 1vh) * 100 - 2rem);
}
.github-star-btn:active {
transform: scale(0.95);
}
.chatbot-messages {
overscroll-behavior: contain;
}
.chatbot-messages > .chatbot-renderer-shell {
min-height: 100%;
display: flex;
flex: 1 0 auto;
flex-direction: column;
justify-content: flex-end;
box-sizing: border-box;
padding: 1.5rem;
width: 100%;
}
.chatbot-messages::-webkit-scrollbar {
width: 8px;
}
.chatbot-messages::-webkit-scrollbar-track {
background: transparent;
}
.chatbot-messages::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.dark .chatbot-messages::-webkit-scrollbar-thumb {
background: #475569;
}
.chatbot-messages::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.dark .chatbot-messages::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
.settings-toggle {
backdrop-filter: blur(8px);
}
.settings-toggle:active {
transform: scale(0.95);
}
.theme-selector select:focus {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.theme-selector select option {
padding: 8px 12px;
background-color: white;
color: #374151;
}
.dark .theme-selector select option {
background-color: #1f2937;
color: #f3f4f6;
}
.settings-panel {
transform-origin: top right;
}
.markdown-renderer .code-block-container.is-rendering {
position: relative;
animation: renderingGlow 2s ease-in-out infinite;
}
@keyframes renderingGlow {
0% {
box-shadow:
0 0 10px rgba(59, 130, 246, 0.4),
0 0 20px rgba(59, 130, 246, 0.2);
}
25% {
box-shadow:
0 0 15px rgba(139, 92, 246, 0.5),
0 0 30px rgba(139, 92, 246, 0.3);
}
50% {
box-shadow:
0 0 20px rgba(236, 72, 153, 0.5),
0 0 40px rgba(236, 72, 153, 0.3);
}
75% {
box-shadow:
0 0 15px rgba(16, 185, 129, 0.5),
0 0 30px rgba(16, 185, 129, 0.3);
}
100% {
box-shadow:
0 0 10px rgba(59, 130, 246, 0.4),
0 0 20px rgba(59, 130, 246, 0.2);
}
}
.markdown-renderer .is-rendering {
position: relative;
animation: renderingGlow 2s ease-in-out infinite;
}
.thinking-node {
color: #0f172a;
}
.dark .thinking-node {
color: #e6f0ff;
}
.thinking-dots {
display: inline-flex;
align-items: center;
gap: 6px;
width: 36px;
justify-content: flex-start;
height: 12px;
transition: opacity 160ms linear, transform 160ms linear;
opacity: 0;
}
.thinking-dots .dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: #1e3a8a;
opacity: 0.25;
transform: translateY(0);
}
.thinking-dots.visible {
opacity: 1;
}
.thinking-dots.hidden {
opacity: 0;
transform: translateY(0);
}
.thinking-dots.visible .dot-1 {
animation: think-bounce 1s infinite ease-in-out;
animation-delay: 0s;
}
.thinking-dots.visible .dot-2 {
animation: think-bounce 1s infinite ease-in-out;
animation-delay: 0.12s;
}
.thinking-dots.visible .dot-3 {
animation: think-bounce 1s infinite ease-in-out;
animation-delay: 0.24s;
}
.dark .thinking-dots .dot {
background: #bfdbfe;
opacity: 0.28;
}
@keyframes think-bounce {
0%,
80%,
100% {
transform: translateY(0);
opacity: 0.25;
}
40% {
transform: translateY(-6px);
opacity: 1;
}
}
.content-area {
min-height: 1.25rem;
}
.partial-content,
.full-content {
transition: opacity 140ms ease;
}
.partial-content {
opacity: 0.9;
}
.full-content {
opacity: 1;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@@ -0,0 +1,16 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import 'katex/dist/katex.min.css'
import 'monaco-editor/min/vs/editor/editor.main.css'
import 'markstream-react/index.css'
import './shared/test-lab.css'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<div className="markstream-vue">
<App />
</div>
</React.StrictMode>,
)
@@ -0,0 +1,443 @@
export const streamContent = `
<thinking>
- markstream-vue playground
- A Vue 3 component that renders Markdown string content as HTML, supporting custom components and advanced markdown features.
</thinking>
>>>I'll create a simple Electron + Vue chat application demo. Here's the structure:
[Star on GitHub](https://github.com/Simon-He95/markstream-vue)
<a href="https://simonhe.me/">我是 a 元素标签</a>
https://github.com/Simon-He95/markstream-vue
[【Author: Simon】](https://simonhe.me/)
- **[Link (Test 1)](https://simonhe.me/)**
**[Link (Test 2)](https://simonhe.me/)**
**Markdown链接**:
1. [GitHub官网](https://github.com)
2. [知乎 - 有问题就会有答案](https://www.zhihu.com)
3. **加粗链接**:[Google](https://www.google.com)
4. 嵌套格式的链接:[*斜体链接*](https://example.com)
**普通链接**:
1. https://www.wikipedia.org
2. http://example.com/path?query=test
3. 纯文本URL:https://markdown-guide.readthedocs.io
![Vue Markdown Icon](/vue-markdown-icon.svg "Vue Markdown Icon")
*Figure: Vue Markdown Icon (served from /vue-markdown-icon.svg)*
这是 ~~已删除的文本~~,这是一个表情 :smile:。
- [ ] Star this repo
- [x] Fork this repo
- [ ] Create issues
- [x] Submit PRs
## 表格
| 姓名 | 年龄 | 职业 |
|------|------|------|
| 张三 | 25 | 工程师 |
| 李四 | 30 | 设计师 |
| 王五 | 28 | 产品经理 |
### 对齐表格
| 左对齐 | 居中对齐 | 右对齐 |
|:-------|:--------:|-------:|
| 内容1 | 内容2 | 内容3 |
| 内容4 | 内容5 | 内容6 |
我将为您输出泰勒公式的一般形式及其常见展开式。
---
## 0. 薛定谔方程(量子力学)
$$i\\hbar \\frac{\\partial}{\\partial t} \\Psi(\\mathbf{r},t) = \\left[ -\\frac{\\hbar^2}{2m} \\nabla^2 + V(\\mathbf{r},t) \\right] \\Psi(\\mathbf{r},t)$$
## 1. 泰勒公式(Taylor's Formula)
### 一般形式(在点 \\(x = a\\) 处展开):
\[
f(x) = f(a) + f'(a)(x-a) + \frac{f''(a)}{2!}(x-a)^2 + \frac{f'''(a)}{3!}(x-a)^3 + \cdots + \frac{f^{(n)}(a)}{n!}(x-a)^n + R_n(x)
\\]
其中:
- \\(f^{(k)}(a)\\) 是 \\(f(x)\\) 在 \\(x=a\\\) 处的 \\(k\\) 阶导数
- \\(R_n(x)\\) 是余项,常见形式有拉格朗日余项:
\[
R_n(x) = \frac{f^{(n+1)}(xi)}{(n+1)!}(x-a)^{n+1}, \quad xi \text{ 在 } a \text{ 和 } x \text{ 之间}
\\]
---
## 2. 麦克劳林公式(Maclaurin's Formula,即 \\(a=0\\) 时的泰勒公式):
\[
f(x) = f(0) + f'(0)x + \frac{f''(0)}{2!}x^2 + \frac{f'''(0)}{3!}x^3 + \cdots + \frac{f^{(n)}(0)}{n!}x^n + R_n(x)
\\]
---
## 3. 常见函数的麦克劳林展开(前几项)
- **指数函数**:
\\[
e^x = 1 + x + \frac{x^2}{2!} + \frac{x^3}{3!} + \cdots + \frac{x^n}{n!} + \cdots, \quad x \in \mathbb{R}
\\]
- **正弦函数**:
\\[
\sin x = x - \frac{x^3}{3!} + \frac{x^5}{5!} - \frac{x^7}{7!} + \cdots + (-1)^n \frac{x^{2n+1}}{(2n+1)!} + \cdots
\\]
- **余弦函数**:
\\[
\cos x = 1 - \frac{x^2}{2!} + \frac{x^4}{4!} - \frac{x^6}{6!} + \cdots + (-1)^n \frac{x^{2n}}{(2n)!} + \cdots
\\]
- **自然对数**(在 \\(x=0\\) 附近):
\\[
\ln(1+x) = x - \frac{x^2}{2} + \frac{x^3}{3} - \frac{x^4}{4} + \cdots + (-1)^{n-1} \frac{x^n}{n} + \cdots, \quad -1 < x \le 1
\\]
- **二项式展开**(\\( (1+x)^m \\),\\(m\\) 为实数):
\[
(1+x)^m = 1 + mx + \frac{m(m-1)}{2!}x^2 + \frac{m(m-1)(m-2)}{3!}x^3 + \cdots, \quad |x| < 1
\\]
- **矩阵**:
\\[
\begin{bmatrix}
2x_2 - 8x_3 = 8 \\
5x_1 - 5x_3 = 10
\end{bmatrix}
\\]
- **公式**
- **代入数据**
\\[
\frac{363}{15,\!135} \times 100\% = 2.398\%
\\]
- **计算工具验证**
通过数学计算工具确认结果:
\`363 ÷ 15,135 × 100 = 2.39841427...\`
- **差异说明**
$$E=mc^2$$
---
如果您需要某个特定函数在特定点的泰勒展开,请告诉我,我可以为您详细写出。
::: warning
这是一个警告块。
:::
::: tip 提示标题
这是带标题的提示。
:::
::: error 错误块
这是一个错误块。
:::
مرحبا بكم في عالم اللغة العربية!
1. First, let's set up the project:
\`\`\`shellscript
# Create Vue project
npm create vue@latest electron-vue-chat
# Navigate to project
cd electron-vue-chat
# Install dependencies
npm install
npm install electron electron-builder vue-router
# Install dev dependencies
npm install -D electron-dev-server concurrently wait-on
\`\`\`
2. Create the main Electron file:
\`\`\`javascript:electron/main.js
const { app, BrowserWindow } = require('electron');
const path = require('path');
const isDev = process.env.NODE_ENV === 'development';
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 900,
height: 680,
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
});
const url = isDev
? 'http://localhost:5173'
: \`file://\${path.join(__dirname, '../dist/index.html')}\`;
mainWindow.loadURL(url);
if (isDev) {
mainWindow.webContents.openDevTools();
}
mainWindow.on('closed', () => {
mainWindow = null;
});
}
app.on('ready', createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (mainWindow === null) {
createWindow();
}
});
\`\`\`
3. Update package.json:
\`\`\`diff json:package.json
{
"name": "markstream-vue",
"type": "module",
- "version": "0.0.49",
+ "version": "0.0.54-beta.1",
"packageManager": "pnpm@10.16.1",
"description": "A Vue 3 component that renders Markdown string content as HTML, supporting custom components and advanced markdown features.",
"author": "Simon He",
"license": "MIT",
"repository": {
"type": "git",
"url": "git + git@github.com:Simon-He95/markstream-vue.git"
},
"bugs": {
"url": "https://github.com/Simon-He95/markstream-vue/issues"
},
"keywords": [
"vue",
"vue3",
"markdown",
"markdown-to-html",
"markdown-renderer",
"vue-markdown",
"vue-component",
"html",
"renderer",
"custom-component"
],
"exports": {
".": {
"types": "./dist/types/exports.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./index.css": "./dist/index.css",
"./index.tailwind.css": "./dist/index.tailwind.css",
"./tailwind": "./dist/tailwind.ts"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/types/exports.d.ts",
"files": [
"dist"
],
}
\`\`\`
4. Create chat components \\(diversified languages\\):
\`\`\`python:src/server/app.py
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Message(BaseModel):
sender: str
text: str
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/echo")
def echo(msg: Message):
return {"reply": f"Echo: {msg.text}"}
\`\`\`
5. Create a native module example \(C++\):
\`\`\`cpp:src/native/compute.cpp
#include <bits/stdc++.h>
using namespace std;
int fibonacci(int n){
if(n<=1) return n;
int a=0,b=1;
for(int i=2;i<=n;++i){ int c=a+b; a=b; b=c; }
return b;
}
int main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout << "fib(10)=" << fibonacci(10) << "\n";
return 0;
}
\`\`\`
6. Update the main App.vue:
\`\`\`vue:src/App.vue
<template>
<router-view />
<\/template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
}
</style>
\`\`\`
7. Set up the router:
\`\`\`javascript:src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import ChatView from '../views/ChatView.vue';
const routes = [
{
path: '/',
name: 'chat',
component: ChatView
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
export default router;
\`\`\`
8. Update main.js:
\`\`\`javascript:src/main.js
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
createApp(App).use(router).mount('#app');
\`\`\`
9. Mermaid graphic:
\`\`\`mermaid
graph TD
Kira_Yamato[基拉·大和]
Lacus_Clyne[拉克丝·克莱因]
Athrun_Zala[阿斯兰·萨拉]
Cagalli_Yula_Athha[卡嘉莉·尤拉·阿斯哈]
Shinn_Asuka[真·飞鸟]
Lunamaria_Hawke[露娜玛丽亚·霍克]
COMPASS[世界和平监视组织COMPASS]
Foundation[芬德申王国]
Orphee_Lam_Tao[奥尔菲·拉姆·陶]
%% 节点定义结束,开始定义边
Kira_Yamato ---|恋人| Lacus_Clyne
Kira_Yamato ---|挚友| Athrun_Zala
Kira_Yamato -->|隶属| COMPASS
Kira_Yamato -->|前辈| Shinn_Asuka
Lacus_Clyne -->|初代总裁| COMPASS
Athrun_Zala ---|恋人| Cagalli_Yula_Athha
Athrun_Zala -.->|协力| COMPASS
Shinn_Asuka ---|恋人| Lunamaria_Hawke
Shinn_Asuka -->|隶属| COMPASS
Lunamaria_Hawke -->|隶属| COMPASS
COMPASS -->|对立| Foundation
Orphee_Lam_Tao -->|隶属| Foundation
Orphee_Lam_Tao -.->|追求| Lacus_Clyne
\`\`\`
\`\`\`mermaid
xychart
title "销售收入"
x-axis ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"]
y-axis "收入" 4000 --> 11000
line [5000, 6000, 7500, 8200, 9500, 10500, 11000, 10200, 9200, 8500, 7000, 6000]
\`\`\`
\`\`\`infographic
infographic list-row-simple-horizontal-arrow
data
items
- label 步骤 1
desc 开始
- label 步骤 2
desc 进行中
- label 步骤 3
desc 完成
\`\`\`
---
# 复杂数学公式
### 1. **理解 \\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 的含义**
- \\(\boldsymbol{\alpha}\\) 和 \\(\boldsymbol{\beta}\\) 是三维列向量,因此 \\(\boldsymbol{\alpha}^T \boldsymbol{\beta}\\) 表示它们的点积(内积)。
- \\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 意味着向量 \\(\boldsymbol{\alpha}\\) 和 \\(\boldsymbol{\beta}\\) 正交(即垂直),因为点积为零表示它们之间的夹角为 90 度。
### 2. **正交补空间的概念**
- 在线性代数中,对于一个子空间 \\(W\\),它的正交补空间(记为 \\(W^\perp\\))定义为所有与 \\(W\\) 中每个向量正交的向量的集合。即:
\[
W^\perp = \{ \mathbf{v} \in \mathbb{R}^3 \mid \mathbf{v} \cdot \mathbf{w} = 0 \text{ 对于所有 } \mathbf{w} \in W \}
\]
- 例如,如果 \\(W\\) 是由一个向量 \\(\boldsymbol{\alpha}\\) 张成的一维子空间(即 \\(W = \operatorname{span}\{\boldsymbol{\alpha}\}\\)),那么 \\(W^\perp\\) 就是所有与 \\(\boldsymbol{\alpha}\\) 正交的向量构成的二维平面。
### 3. **\\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 与正交补空间的联系**
- 当 \\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 时,这意味着:
- \\(\boldsymbol{\beta}\\) 属于 \\(\operatorname{span}\{\boldsymbol{\alpha}\}\\) 的正交补空间,即 \\(\boldsymbol{\beta} \in (\operatorname{span}\{\boldsymbol{\alpha}\})^\perp\\)。
- 同样,\\(\boldsymbol{\alpha}\\) 也属于 \\(\operatorname{span}\{\boldsymbol{\beta}\}\\) 的正交补空间,即 \\(\boldsymbol{\alpha} \in (\operatorname{span}\{\boldsymbol{\beta}\})^\perp\\)。
- 换句话说,\\(\boldsymbol{\beta}\\) 与 \\(\boldsymbol{\alpha}\\) 张成的直线正交,因此 \\(\boldsymbol{\beta}\\) 位于该直线的垂直平面(即正交补空间)上。反之亦然。
### 4. **在三维空间中的几何意义**
- 在三维空间中,如果 \\(\boldsymbol{\alpha}\\) 是一个非零向量,那么 \\(\operatorname{span}\{\boldsymbol{\alpha}\}\\) 是一条通过原点的直线,而它的正交补空间 \\((\operatorname{span}\{\boldsymbol{\alpha}\})^\perp\\) 是一个通过原点且与该直线垂直的平面。
- \\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 表示 \\(\boldsymbol{\beta}\\) 位于这个垂直平面上。同样,如果 \\(\boldsymbol{\beta}\\) 非零,那么 \\(\boldsymbol{\alpha}\\) 也位于与 \\(\boldsymbol{\beta}\\) 垂直的平面上。
### 5. **推广到更一般的情况**
- 如果考虑多个向量,正交补空间的概念可以扩展。例如,如果有一组向量 \\(\{\boldsymbol{\alpha}_1, \boldsymbol{\alpha}_2, \ldots, \boldsymbol{\alpha}_k\}\\),那么它们的张成子空间 \\(W = \operatorname{span}\{\boldsymbol{\alpha}_1, \ldots, \boldsymbol{\alpha}_k\}\\) 的正交补空间 \\(W^\perp\\) 包含所有与这些向量正交的向量。
- 在这种情况下,\\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 可以看作 \\(\boldsymbol{\beta}\\) 与 \\(W\\) 正交的一个特例(当 \\(W\\) 只由 \\(\boldsymbol{\alpha}\\) 张成时)。
总之,\\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 直接体现了正交补空间的关系:它表明一个向量属于另一个向量张成子空间的正交补空间。如果你有更多向量或子空间,这种联系可以进一步深化。
**示例:** emm\`1-(5)\`、\`3-(3)\`、\`3-(4)\` complex test \`1-(4)\`“heiheihei”中,hello world。
`
@@ -0,0 +1,162 @@
import { Icon } from '@iconify/react'
import { NodeRenderer } from 'markstream-react'
import afterAdvancedSource from '../../../test/fixtures/react-markdown-migration-demo/after-advanced.tsx?raw'
import afterBasicSource from '../../../test/fixtures/react-markdown-migration-demo/after-basic.tsx?raw'
import beforeAdvancedSource from '../../../test/fixtures/react-markdown-migration-demo/before-advanced.tsx?raw'
import beforeBasicSource from '../../../test/fixtures/react-markdown-migration-demo/before-basic.tsx?raw'
import skillOutputSource from '../../../test/fixtures/react-markdown-migration-demo/skill-output.md?raw'
import { PLAYGROUND_CUSTOM_HTML_TAGS, PLAYGROUND_CUSTOM_ID } from './markstreamPlayground'
interface MigrationDemoPageProps {
isDark: boolean
onGoHome: () => void
onGoTest: () => void
}
interface CodePanelProps {
badge: string
title: string
description: string
code: string
}
function CodePanel({ badge, title, description, code }: CodePanelProps) {
return (
<section className="rounded-2xl border border-slate-200 bg-white shadow-lg shadow-slate-200/40 dark:border-slate-700 dark:bg-slate-900 dark:shadow-black/20 overflow-hidden">
<div className="border-b border-slate-200 bg-slate-50 px-5 py-4 dark:border-slate-700 dark:bg-slate-800/80">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-500 dark:text-slate-400">
{badge}
</div>
<h2 className="mt-1 text-lg font-semibold text-slate-900 dark:text-slate-100">
{title}
</h2>
</div>
<div className="rounded-full bg-slate-200 px-3 py-1 text-xs font-medium text-slate-600 dark:bg-slate-700 dark:text-slate-300">
TSX
</div>
</div>
<p className="mt-3 text-sm leading-6 text-slate-600 dark:text-slate-300">
{description}
</p>
</div>
<pre className="overflow-x-auto bg-slate-950 px-5 py-4 text-[13px] leading-6 text-slate-100">
<code>{code}</code>
</pre>
</section>
)
}
export function MigrationDemoPage({ isDark, onGoHome, onGoTest }: MigrationDemoPageProps) {
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(14,165,233,0.18),_transparent_38%),linear-gradient(180deg,_#f8fafc_0%,_#eef2ff_48%,_#ffffff_100%)] dark:bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.16),_transparent_32%),linear-gradient(180deg,_#0f172a_0%,_#111827_48%,_#020617_100%)]">
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="rounded-[28px] border border-white/70 bg-white/90 p-6 shadow-2xl shadow-slate-300/40 backdrop-blur dark:border-slate-700/70 dark:bg-slate-900/88 dark:shadow-black/30">
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-slate-200/80 pb-6 dark:border-slate-700/80">
<div className="max-w-3xl">
<div className="inline-flex items-center gap-2 rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-sky-700 dark:bg-sky-500/15 dark:text-sky-300">
<Icon icon="carbon:skill-level-basic" className="h-4 w-4" />
Skill Demo
</div>
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 dark:text-slate-50 sm:text-4xl">
react-markdown to markstream-react
</h1>
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-600 dark:text-slate-300 sm:text-base">
This demo is intentionally isolated from the main repository implementation. The repo itself is not a
real
{' '}
<code>react-markdown</code>
{' '}
consumer, so this page shows how the migration skill audits a synthetic before/after example without
polluting the main playground.
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-slate-400 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-500 dark:hover:bg-slate-700"
onClick={onGoHome}
>
<Icon icon="carbon:home" className="h-4 w-4" />
Home
</button>
<button
type="button"
className="inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-slate-400 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-500 dark:hover:bg-slate-700"
onClick={onGoTest}
>
<Icon icon="carbon:rocket" className="h-4 w-4" />
Test Lab
</button>
</div>
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.95fr)]">
<section className="space-y-6">
<div className="grid gap-6 xl:grid-cols-2">
<CodePanel
badge="Before"
title="Basic Call Site"
description="A plain react-markdown render with no plugin chain. The skill should classify this as a direct migration."
code={beforeBasicSource}
/>
<CodePanel
badge="After"
title="Basic Migration"
description="This is the low-risk replacement path: swap in markstream-react, import CSS, and pass the markdown through content."
code={afterBasicSource}
/>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<CodePanel
badge="Before"
title="Advanced Call Site"
description="This example intentionally mixes custom components, remark-gfm, rehype-raw, allowlisting, and URL rewriting so the skill has real migration decisions to make."
code={beforeAdvancedSource}
/>
<CodePanel
badge="After"
title="Advanced Migration"
description="The safe 80 percent moves to setCustomComponents and custom link handling. The element allowlist remains a manual review item."
code={afterAdvancedSource}
/>
</div>
</section>
<aside className="rounded-2xl border border-slate-200 bg-white shadow-lg shadow-slate-200/40 dark:border-slate-700 dark:bg-slate-900 dark:shadow-black/20 overflow-hidden">
<div className="border-b border-slate-200 bg-gradient-to-r from-amber-50 to-cyan-50 px-5 py-4 dark:border-slate-700 dark:from-amber-500/10 dark:to-cyan-500/10">
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-500 dark:text-slate-400">
Audit Output
</div>
<h2 className="mt-1 text-lg font-semibold text-slate-900 dark:text-slate-100">
Suggested Skill Report
</h2>
<p className="mt-3 text-sm leading-6 text-slate-600 dark:text-slate-300">
The panel below renders the markdown audit report that the migration skill would produce for this
fixture.
</p>
</div>
<div className="max-h-[1120px] overflow-y-auto px-5 py-5">
<NodeRenderer
content={skillOutputSource}
isDark={isDark}
customId={PLAYGROUND_CUSTOM_ID}
customHtmlTags={PLAYGROUND_CUSTOM_HTML_TAGS}
renderCodeBlocksAsPre
viewportPriority={false}
deferNodesUntilVisible={false}
maxLiveNodes={0}
/>
</div>
</aside>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,705 @@
import type { ClipboardEvent as ReactClipboardEvent } from 'react'
import type { TestLabFrameworkId, TestLabSampleId } from '../../../playground-shared/testLabFixtures'
import type { TestPageViewMode } from '../../../playground-shared/testPageState'
import type { StreamPresetId } from './streamPresets'
import type { StreamSliceMode, StreamTransportMode } from './useStreamSimulator'
import { NodeRenderer } from 'markstream-react'
import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { resolveMarkdownTextareaPaste } from '../../../playground-shared/markdownPaste'
import { TEST_LAB_FRAMEWORKS, TEST_LAB_SAMPLES } from '../../../playground-shared/testLabFixtures'
import { buildTestPageHref, decodeMarkdownHash, resolveFrameworkTestHref, resolveTestPageViewMode } from '../../../playground-shared/testPageState'
import { PLAYGROUND_CUSTOM_HTML_TAGS, PLAYGROUND_CUSTOM_ID } from './markstreamPlayground'
import { CUSTOM_STREAM_PRESET_ID, findMatchingStreamPreset, getStreamPreset, STREAM_PRESETS } from './streamPresets'
import { clampStreamControl, normalizeStreamRange, useStreamSimulator } from './useStreamSimulator'
type SampleId = TestLabSampleId
type FrameworkId = TestLabFrameworkId
const CURRENT_FRAMEWORK: FrameworkId = 'react'
const DARK_MODE_KEY = 'vmr-test-dark'
const frameworkCards = TEST_LAB_FRAMEWORKS
const sampleCards = TEST_LAB_SAMPLES
interface TestLabProps {
frameworkLabel: string
onGoHome: () => void
}
export function TestLab({ frameworkLabel, onGoHome }: TestLabProps) {
const streamSettingsDialogRef = useRef<HTMLDialogElement>(null)
const previewCardRef = useRef<HTMLElement>(null)
const previewShareTimerRef = useRef<number | null>(null)
const [selectedSampleId, setSelectedSampleId] = useState<SampleId>('baseline')
const [input, setInput] = useState(sampleCards[0].content)
const [viewMode, setViewMode] = useState<TestPageViewMode>(() => (
typeof window === 'undefined' ? 'lab' : resolveTestPageViewMode(window.location.search)
))
const [isDark, setIsDark] = useState(() => (
typeof window !== 'undefined' && window.localStorage.getItem(DARK_MODE_KEY) === 'dark'
))
const [isPreviewFullscreen, setIsPreviewFullscreen] = useState(false)
const [isPreviewShareCopied, setIsPreviewShareCopied] = useState(false)
const [streamChunkSizeMin, setStreamChunkSizeMin] = useState(2)
const [streamChunkSizeMax, setStreamChunkSizeMax] = useState(7)
const [streamChunkDelayMin, setStreamChunkDelayMin] = useState(14)
const [streamChunkDelayMax, setStreamChunkDelayMax] = useState(34)
const [streamBurstiness, setStreamBurstiness] = useState(35)
const [streamTransportMode, setStreamTransportMode] = useState<StreamTransportMode>('readable-stream')
const [streamSliceMode, setStreamSliceMode] = useState<StreamSliceMode>('pure-random')
const activeSample = sampleCards.find(sample => sample.id === selectedSampleId) ?? sampleCards[0]
const normalizedChunkSizeRange = useMemo(() => normalizeStreamRange(
streamChunkSizeMin,
streamChunkSizeMax,
1,
80,
2,
7,
), [streamChunkSizeMax, streamChunkSizeMin])
const normalizedChunkDelayRange = useMemo(() => normalizeStreamRange(
streamChunkDelayMin,
streamChunkDelayMax,
8,
600,
14,
34,
), [streamChunkDelayMax, streamChunkDelayMin])
const normalizedBurstiness = useMemo(
() => Math.round(clampStreamControl(streamBurstiness, 0, 100, 35)),
[streamBurstiness],
)
const {
content: streamContent,
isPaused,
isStreaming,
lastChunkSize,
lastDelayMs,
reset: resetStreamState,
start: startStreaming,
stop: stopStreaming,
togglePause: toggleStreamingPause,
} = useStreamSimulator({
source: input,
chunkSizeMin: normalizedChunkSizeRange.min,
chunkSizeMax: normalizedChunkSizeRange.max,
chunkDelayMin: normalizedChunkDelayRange.min,
chunkDelayMax: normalizedChunkDelayRange.max,
burstiness: normalizedBurstiness / 100,
sliceMode: streamSliceMode,
transportMode: streamTransportMode,
})
const previewContent = isStreaming ? streamContent : input
const deferredPreview = useDeferredValue(previewContent)
const progress = input.length ? Math.min(100, Math.round((previewContent.length / input.length) * 100)) : 0
const charCount = input.length
const lineCount = input ? input.split('\n').length : 0
const activeStreamPreset = useMemo(() => findMatchingStreamPreset({
chunkDelayMin: normalizedChunkDelayRange.min,
chunkDelayMax: normalizedChunkDelayRange.max,
chunkSizeMin: normalizedChunkSizeRange.min,
chunkSizeMax: normalizedChunkSizeRange.max,
burstiness: normalizedBurstiness,
}), [normalizedBurstiness, normalizedChunkDelayRange.max, normalizedChunkDelayRange.min, normalizedChunkSizeRange.max, normalizedChunkSizeRange.min])
const selectedStreamPresetId = activeStreamPreset?.id ?? CUSTOM_STREAM_PRESET_ID
const streamPresetDescription = activeStreamPreset?.description ?? 'Current values are outside the built-in presets.'
const streamChunkRangeLabel = `${normalizedChunkSizeRange.min}-${normalizedChunkSizeRange.max} chars`
const streamDelayRangeLabel = `${normalizedChunkDelayRange.min}-${normalizedChunkDelayRange.max}ms`
const streamStatusLabel = isStreaming ? (isPaused ? 'Paused' : 'Streaming') : 'Ready'
const activeStreamPresetLabel = activeStreamPreset?.label ?? 'Custom window'
const isSharePreviewMode = viewMode === 'preview'
const showImmersivePreviewControls = isSharePreviewMode || isPreviewFullscreen
const immersiveBackLabel = isSharePreviewMode ? '打开 Test Page' : '返回编辑'
const themeToggleLabel = isDark ? '切换浅色' : '切换暗色'
useEffect(() => {
if (typeof window === 'undefined')
return
const restored = decodeMarkdownHash(window.location.hash || '')
if (restored)
setInput(restored)
setViewMode(resolveTestPageViewMode(window.location.search))
}, [])
useEffect(() => {
if (typeof window === 'undefined')
return
window.localStorage.setItem(DARK_MODE_KEY, isDark ? 'dark' : 'light')
}, [isDark])
useEffect(() => {
if (typeof document === 'undefined')
return
function syncPreviewFullscreenState() {
setIsPreviewFullscreen(document.fullscreenElement === previewCardRef.current)
}
document.addEventListener('fullscreenchange', syncPreviewFullscreenState)
return () => document.removeEventListener('fullscreenchange', syncPreviewFullscreenState)
}, [])
useEffect(() => () => {
if (previewShareTimerRef.current != null && typeof window !== 'undefined')
window.clearTimeout(previewShareTimerRef.current)
}, [])
useEffect(() => {
if (streamChunkSizeMin !== normalizedChunkSizeRange.min)
setStreamChunkSizeMin(normalizedChunkSizeRange.min)
if (streamChunkSizeMax !== normalizedChunkSizeRange.max)
setStreamChunkSizeMax(normalizedChunkSizeRange.max)
}, [normalizedChunkSizeRange.max, normalizedChunkSizeRange.min, streamChunkSizeMax, streamChunkSizeMin])
useEffect(() => {
if (streamChunkDelayMin !== normalizedChunkDelayRange.min)
setStreamChunkDelayMin(normalizedChunkDelayRange.min)
if (streamChunkDelayMax !== normalizedChunkDelayRange.max)
setStreamChunkDelayMax(normalizedChunkDelayRange.max)
}, [normalizedChunkDelayRange.max, normalizedChunkDelayRange.min, streamChunkDelayMax, streamChunkDelayMin])
useEffect(() => {
if (streamBurstiness !== normalizedBurstiness)
setStreamBurstiness(normalizedBurstiness)
}, [normalizedBurstiness, streamBurstiness])
function applySample(id: SampleId) {
const sample = sampleCards.find(item => item.id === id)
if (!sample)
return
resetStreamState()
setSelectedSampleId(sample.id)
setInput(sample.content)
}
function toggleStream() {
if (isStreaming) {
stopStreaming()
return
}
startStreaming()
}
function resetEditor() {
applySample(selectedSampleId)
}
function clearEditor() {
resetStreamState()
setInput('')
setIsPreviewShareCopied(false)
}
function handleEditorPaste(event: ReactClipboardEvent<HTMLTextAreaElement>) {
const textarea = event.currentTarget
const pasted = event.clipboardData.getData('text/plain')
const next = resolveMarkdownTextareaPaste(textarea, pasted)
if (!next)
return
event.preventDefault()
textarea.value = next.nextValue
textarea.selectionStart = next.selectionStart
textarea.selectionEnd = next.selectionEnd
setInput(next.nextValue)
}
function handleStreamPresetChange(presetId: StreamPresetId) {
if (presetId === CUSTOM_STREAM_PRESET_ID)
return
const preset = getStreamPreset(presetId)
if (!preset)
return
setStreamChunkDelayMin(preset.chunkDelayMin)
setStreamChunkDelayMax(preset.chunkDelayMax)
setStreamChunkSizeMin(preset.chunkSizeMin)
setStreamChunkSizeMax(preset.chunkSizeMax)
setStreamBurstiness(preset.burstiness)
}
function frameworkHref(id: FrameworkId) {
const framework = frameworkCards.find(item => item.id === id)
if (!framework)
return '/test'
return resolveFrameworkTestHref(
framework,
CURRENT_FRAMEWORK,
input,
typeof window !== 'undefined'
? { hostname: window.location.hostname, protocol: window.location.protocol }
: undefined,
)
}
function currentBasePageUrl() {
const url = new URL(window.location.href)
url.hash = ''
url.search = ''
return url.toString()
}
function toggleAppearance() {
setIsDark(previous => !previous)
}
async function copyPreviewShareLink() {
if (typeof window === 'undefined')
return
const target = buildTestPageHref(currentBasePageUrl(), input, 'preview')
await navigator.clipboard.writeText(target)
setIsPreviewShareCopied(true)
if (previewShareTimerRef.current != null)
window.clearTimeout(previewShareTimerRef.current)
previewShareTimerRef.current = window.setTimeout(() => {
setIsPreviewShareCopied(false)
}, 1800)
}
async function togglePreviewFullscreen() {
if (typeof document === 'undefined')
return
const previewCard = previewCardRef.current
if (!previewCard)
return
if (document.fullscreenElement === previewCard) {
if (document.exitFullscreen)
await document.exitFullscreen()
return
}
if (previewCard.requestFullscreen)
await previewCard.requestFullscreen()
}
function returnToEditableTestPage() {
if (isSharePreviewMode) {
window.location.href = buildTestPageHref(currentBasePageUrl(), input, 'lab')
return
}
if (typeof document !== 'undefined' && document.fullscreenElement === previewCardRef.current && document.exitFullscreen)
void document.exitFullscreen()
}
function openStreamSettingsDialog() {
if (!streamSettingsDialogRef.current)
return
if (!streamSettingsDialogRef.current.open)
streamSettingsDialogRef.current.showModal()
}
function closeStreamSettingsDialog() {
if (streamSettingsDialogRef.current?.open)
streamSettingsDialogRef.current.close()
}
return (
<div className={`test-lab ${isDark ? 'test-lab--dark dark' : ''} ${isSharePreviewMode ? 'test-lab--share-preview' : ''}`}>
{!isSharePreviewMode && <div className="test-lab__glow test-lab__glow--cyan" />}
{!isSharePreviewMode && <div className="test-lab__glow test-lab__glow--amber" />}
<div className={`test-lab__shell ${isSharePreviewMode ? 'test-lab__shell--share-preview' : ''}`}>
{!isSharePreviewMode && (
<section className="hero-panel">
<div className="hero-panel__copy">
<span className="eyebrow">
{frameworkLabel}
{' '}
Regression Lab
</span>
<h1>markstream-react /test</h1>
<p>专门用来和 Vue 3、Vue 2、Angular 的 test page 做对照,快速定位框架层差异。</p>
</div>
<div className="hero-panel__actions">
<div className="hero-panel__status-row">
<span className="mini-pill">{frameworkLabel}</span>
<span className={`mini-pill ${isStreaming ? 'mini-pill--active' : ''}`}>{streamStatusLabel}</span>
<span className="mini-pill">{activeStreamPresetLabel}</span>
</div>
<div className="hero-panel__metrics">
<div className="metric-card">
<span>当前框架</span>
<strong>{frameworkLabel}</strong>
</div>
<div className="metric-card">
<span>字符数</span>
<strong>{charCount}</strong>
</div>
<div className="metric-card">
<span>行数</span>
<strong>{lineCount}</strong>
</div>
<div className="metric-card">
<span>进度</span>
<strong>
{progress}
%
</strong>
</div>
</div>
</div>
<div className="framework-switcher">
{frameworkCards.map(framework => (
<a
key={framework.id}
className={`framework-chip ${framework.id === CURRENT_FRAMEWORK ? 'framework-chip--current' : ''}`}
href={frameworkHref(framework.id)}
>
<span className="framework-chip__label">{framework.label}</span>
<span className="framework-chip__note">{framework.note}</span>
</a>
))}
</div>
</section>
)}
<div className={`lab-layout ${isSharePreviewMode ? 'lab-layout--share-preview' : ''}`}>
{!isSharePreviewMode && (
<section className="panel-card panel-card--samples">
<div className="panel-card__head">
<div>
<h2>样例</h2>
<p>同一段输入,切到别的框架继续比。</p>
</div>
<span className="mini-pill">{activeSample.title}</span>
</div>
<div className="sample-list">
{sampleCards.map(sample => (
<button
key={sample.id}
type="button"
className={`sample-card ${sample.id === selectedSampleId ? 'sample-card--active' : ''}`}
onClick={() => applySample(sample.id)}
>
<strong>{sample.title}</strong>
<span>{sample.summary}</span>
</button>
))}
</div>
</section>
)}
{!isSharePreviewMode && (
<section className="panel-card panel-card--stream">
<div className="panel-card__head">
<div>
<h2>流式控制</h2>
<p>主卡片只保留节奏摘要,详细参数放到更多设置里。</p>
</div>
<button type="button" className="ghost-button" onClick={openStreamSettingsDialog}>
更多设置
</button>
</div>
<div className="stream-summary">
<div className="stream-summary__row">
<span className="mini-pill mini-pill--active">{activeStreamPresetLabel}</span>
<span className="mini-pill">{streamTransportMode === 'readable-stream' ? 'ReadableStream' : 'Scheduler'}</span>
<span className="mini-pill">{streamSliceMode === 'boundary-aware' ? 'Boundary Aware' : 'Pure Random'}</span>
<span className={`mini-pill ${isStreaming ? 'mini-pill--active' : ''}`}>{streamStatusLabel}</span>
</div>
<div className="stream-summary__row stream-summary__row--dense">
<span className="stream-summary__item">
Chunk
{' '}
{streamChunkRangeLabel}
</span>
<span className="stream-summary__item">
Delay
{' '}
{streamDelayRangeLabel}
</span>
<span className="stream-summary__item">
Burst
{' '}
{normalizedBurstiness}
%
</span>
<span className={`stream-summary__item ${isPaused ? 'stream-summary__item--active' : ''}`}>
{isPaused ? '已暂停' : '连续输出'}
</span>
</div>
</div>
<div className="button-grid">
<button type="button" className="testlab-btn testlab-btn--primary" onClick={toggleStream}>
{isStreaming ? '停止流式渲染' : '开始流式渲染'}
</button>
<button type="button" className="testlab-btn" disabled={!isStreaming} onClick={toggleStreamingPause}>
{isPaused ? '继续流式渲染' : '暂停流式渲染'}
</button>
<button type="button" className="testlab-btn" onClick={resetEditor}>
重置样例
</button>
<button type="button" className="testlab-btn" onClick={clearEditor}>
清空输入
</button>
<button type="button" className="testlab-btn" onClick={onGoHome}>
返回主 demo
</button>
</div>
<div className="progress-block">
<div className="progress-track">
<div className="progress-fill" style={{ width: `${progress}%` }} />
</div>
<div className="progress-meta">
<span>
{previewContent.length}
{' '}
/
{' '}
{input.length || 0}
</span>
<span>
{isStreaming ? `${lastChunkSize} chars / ${lastDelayMs}ms` : 'Static preview'}
</span>
</div>
</div>
</section>
)}
<section className={`workspace-grid ${isSharePreviewMode ? 'workspace-grid--share-preview' : ''}`}>
{!isSharePreviewMode && (
<article className="workspace-card workspace-card--pane workspace-card--editor">
<header className="workspace-card__head">
<div>
<h2>Markdown 输入</h2>
<p>把 markdown 粘进来,右侧立即对照 React 渲染结果。</p>
</div>
<span className="mini-pill">Live editor</span>
</header>
<textarea
value={input}
onChange={event => setInput(event.target.value)}
onPaste={handleEditorPaste}
className="editor-textarea"
spellCheck={false}
placeholder="Paste markdown here..."
/>
<footer className="workspace-card__foot">
<span>可直接粘贴 issue 复现内容</span>
<span>
{charCount}
{' '}
chars
</span>
</footer>
</article>
)}
<article
ref={previewCardRef}
className={`workspace-card workspace-card--pane workspace-card--preview ${isSharePreviewMode ? 'workspace-card--share-preview' : ''}`}
>
{showImmersivePreviewControls && (
<div className="preview-immersive-shell">
<div className="preview-immersive-toolbar">
<button type="button" className="ghost-button preview-immersive-toolbar__button" onClick={returnToEditableTestPage}>
{immersiveBackLabel}
</button>
<button type="button" className="ghost-button preview-immersive-toolbar__button" onClick={toggleAppearance}>
{themeToggleLabel}
</button>
{!isSharePreviewMode && (
<button type="button" className="ghost-button preview-immersive-toolbar__button" onClick={() => void togglePreviewFullscreen()}>
{isPreviewFullscreen ? '退出全屏' : '全屏预览'}
</button>
)}
</div>
</div>
)}
{!isSharePreviewMode && (
<header className="workspace-card__head">
<div>
<h2>实时预览</h2>
<p>{`${isStreaming ? (isPaused ? '流式已暂停' : 'Streaming 中') : '已显示完整输入'}${isPreviewFullscreen ? ' · 按 Esc 退出全屏' : ''}`}</p>
</div>
<div className="workspace-card__head-actions">
<button type="button" className="ghost-button" onClick={toggleAppearance}>
{themeToggleLabel}
</button>
<button type="button" className="ghost-button" onClick={() => void copyPreviewShareLink()}>
{isPreviewShareCopied ? '已复制预览链接' : '复制预览链接'}
</button>
<button type="button" className="ghost-button" onClick={() => void togglePreviewFullscreen()}>
{isPreviewFullscreen ? '退出全屏' : '全屏预览'}
</button>
<span className={`mini-pill ${isStreaming ? 'mini-pill--active' : ''}`}>{streamStatusLabel}</span>
</div>
</header>
)}
<div className="preview-surface">
<NodeRenderer
content={deferredPreview}
typewriter={false}
codeBlockStream
isDark={isDark}
customId={PLAYGROUND_CUSTOM_ID}
customHtmlTags={PLAYGROUND_CUSTOM_HTML_TAGS}
codeBlockDarkTheme="vitesse-dark"
codeBlockLightTheme="vitesse-light"
/>
</div>
{!isSharePreviewMode && (
<footer className="workspace-card__foot">
<span>
{deferredPreview.length}
{' '}
/
{' '}
{input.length || 0}
</span>
<span>
{isStreaming ? `${streamTransportMode} · ${lastChunkSize} chars / ${lastDelayMs}ms` : 'React renderer'}
</span>
</footer>
)}
</article>
</section>
</div>
{!isSharePreviewMode && (
<dialog ref={streamSettingsDialogRef} className="settings-dialog">
<div className="settings-dialog__panel">
<header className="settings-dialog__head">
<div>
<h2>流式详细设置</h2>
<p>这里调整 transport、分片策略和 chunk 窗口。</p>
</div>
<button type="button" className="ghost-button" onClick={closeStreamSettingsDialog}>
关闭
</button>
</header>
<div className="control-grid control-grid--stream">
<label className="input-card">
<span>Preset</span>
<select
value={selectedStreamPresetId}
onChange={event => handleStreamPresetChange(event.target.value as StreamPresetId)}
>
{STREAM_PRESETS.map(preset => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
<option value={CUSTOM_STREAM_PRESET_ID}>Custom</option>
</select>
</label>
<label className="input-card">
<span>Transport</span>
<select
value={streamTransportMode}
onChange={event => setStreamTransportMode(event.target.value as StreamTransportMode)}
>
<option value="readable-stream">ReadableStream</option>
<option value="scheduler">Scheduler</option>
</select>
</label>
<label className="input-card">
<span>Slice Mode</span>
<select
value={streamSliceMode}
onChange={event => setStreamSliceMode(event.target.value as StreamSliceMode)}
>
<option value="pure-random">Pure Random</option>
<option value="boundary-aware">Boundary Aware</option>
</select>
</label>
<label className="input-card">
<span>chunkSizeMin</span>
<input
type="number"
min={1}
max={80}
value={streamChunkSizeMin}
onChange={event => setStreamChunkSizeMin(Number(event.target.value))}
/>
</label>
<label className="input-card">
<span>chunkSizeMax</span>
<input
type="number"
min={1}
max={80}
value={streamChunkSizeMax}
onChange={event => setStreamChunkSizeMax(Number(event.target.value))}
/>
</label>
<label className="input-card">
<span>chunkDelayMin</span>
<input
type="number"
min={8}
max={600}
value={streamChunkDelayMin}
onChange={event => setStreamChunkDelayMin(Number(event.target.value))}
/>
</label>
<label className="input-card">
<span>chunkDelayMax</span>
<input
type="number"
min={8}
max={600}
value={streamChunkDelayMax}
onChange={event => setStreamChunkDelayMax(Number(event.target.value))}
/>
</label>
<label className="input-card">
<span>Burstiness (%)</span>
<input
type="number"
min={0}
max={100}
value={streamBurstiness}
onChange={event => setStreamBurstiness(Number(event.target.value))}
/>
</label>
</div>
<p className="control-note">{streamPresetDescription}</p>
<p className="control-note">
Active window:
{' '}
{streamChunkRangeLabel}
,
{' '}
{streamDelayRangeLabel}
. When min=max, the cadence becomes fixed.
</p>
<p className="control-note">
<code>Pure Random</code>
{' '}
uses raw random
<code>slice</code>
;
<code>Boundary Aware</code>
{' '}
snaps toward word and punctuation boundaries.
</p>
</div>
</dialog>
)}
</div>
</div>
)
}
@@ -0,0 +1,2 @@
export const PLAYGROUND_CUSTOM_ID = 'playground-demo'
export const PLAYGROUND_CUSTOM_HTML_TAGS = ['thinking'] as const
@@ -0,0 +1,90 @@
export const CUSTOM_STREAM_PRESET_ID = 'custom' as const
export type StreamPresetId
= 'balanced'
| 'sse'
| 'websocket'
| 'proxy-buffered'
| 'weak-mobile'
| typeof CUSTOM_STREAM_PRESET_ID
export interface StreamPresetValues {
chunkDelayMin: number
chunkDelayMax: number
chunkSizeMin: number
chunkSizeMax: number
burstiness: number
}
export interface StreamPreset extends StreamPresetValues {
id: Exclude<StreamPresetId, typeof CUSTOM_STREAM_PRESET_ID>
label: string
description: string
}
export const STREAM_PRESETS: StreamPreset[] = [
{
id: 'balanced',
label: 'Balanced',
description: 'Moderate jitter with short stalls and occasional bursts.',
chunkDelayMin: 14,
chunkDelayMax: 34,
chunkSizeMin: 2,
chunkSizeMax: 7,
burstiness: 35,
},
{
id: 'sse',
label: 'SSE',
description: 'Smaller chunks, frequent flushes, and visible network wobble.',
chunkDelayMin: 18,
chunkDelayMax: 44,
chunkSizeMin: 1,
chunkSizeMax: 4,
burstiness: 18,
},
{
id: 'websocket',
label: 'WebSocket',
description: 'Smoother cadence with larger chunks and lower transport jitter.',
chunkDelayMin: 8,
chunkDelayMax: 20,
chunkSizeMin: 4,
chunkSizeMax: 8,
burstiness: 22,
},
{
id: 'proxy-buffered',
label: 'Proxy Buffered',
description: 'Silent gaps followed by buffered batches from an intermediate proxy.',
chunkDelayMin: 36,
chunkDelayMax: 160,
chunkSizeMin: 6,
chunkSizeMax: 16,
burstiness: 88,
},
{
id: 'weak-mobile',
label: 'Weak Mobile',
description: 'High latency, high jitter, and resume-after-stall behavior.',
chunkDelayMin: 42,
chunkDelayMax: 180,
chunkSizeMin: 1,
chunkSizeMax: 6,
burstiness: 68,
},
]
export function getStreamPreset(id: StreamPresetId | string | null | undefined) {
return STREAM_PRESETS.find(preset => preset.id === id) ?? null
}
export function findMatchingStreamPreset(values: StreamPresetValues) {
return STREAM_PRESETS.find(preset =>
preset.chunkDelayMin === values.chunkDelayMin
&& preset.chunkDelayMax === values.chunkDelayMax
&& preset.chunkSizeMin === values.chunkSizeMin
&& preset.chunkSizeMax === values.chunkSizeMax
&& preset.burstiness === values.burstiness,
) ?? null
}
@@ -0,0 +1,899 @@
.test-lab {
--lab-bg: #f4f7fb;
--lab-surface: rgba(255, 255, 255, 0.82);
--lab-surface-strong: rgba(255, 255, 255, 0.94);
--lab-border: rgba(15, 23, 42, 0.09);
--lab-shadow: 0 28px 80px rgba(15, 23, 42, 0.12);
--lab-text: #10203a;
--lab-muted: #59708f;
--lab-accent: #1d4ed8;
--workspace-pane-height: clamp(520px, 74vh, 860px);
position: relative;
min-height: 100vh;
padding: 28px 18px 42px;
background:
radial-gradient(circle at top left, rgba(56, 189, 248, 0.18), transparent 30%),
radial-gradient(circle at 85% 12%, rgba(251, 191, 36, 0.16), transparent 28%),
linear-gradient(180deg, #f8fbff 0%, var(--lab-bg) 100%);
color: var(--lab-text);
overflow: hidden;
}
.test-lab--dark {
--lab-bg: #08111f;
--lab-surface: rgba(9, 18, 32, 0.82);
--lab-surface-strong: rgba(15, 23, 42, 0.94);
--lab-border: rgba(148, 163, 184, 0.14);
--lab-shadow: 0 28px 80px rgba(2, 6, 23, 0.45);
--lab-text: #e2e8f0;
--lab-muted: #94a3b8;
--lab-accent: #60a5fa;
color-scheme: dark;
background:
radial-gradient(circle at top left, rgba(14, 165, 233, 0.16), transparent 30%),
radial-gradient(circle at 85% 12%, rgba(245, 158, 11, 0.12), transparent 28%),
linear-gradient(180deg, #0b1220 0%, var(--lab-bg) 100%);
}
.test-lab--share-preview {
padding: 0;
}
.test-lab__shell {
position: relative;
z-index: 1;
max-width: 1480px;
margin: 0 auto;
display: grid;
gap: 22px;
}
.test-lab__shell--share-preview {
max-width: none;
min-height: 100vh;
gap: 0;
}
.test-lab__glow {
position: absolute;
border-radius: 999px;
filter: blur(80px);
opacity: 0.45;
pointer-events: none;
}
.test-lab__glow--cyan {
top: 40px;
left: -80px;
width: 280px;
height: 280px;
background: rgba(34, 211, 238, 0.34);
}
.test-lab__glow--amber {
right: -60px;
bottom: 120px;
width: 260px;
height: 260px;
background: rgba(251, 191, 36, 0.28);
}
.hero-panel,
.panel-card,
.workspace-card {
border: 1px solid var(--lab-border);
border-radius: 28px;
background: var(--lab-surface);
box-shadow: var(--lab-shadow);
backdrop-filter: blur(18px);
}
.hero-panel {
position: relative;
overflow: hidden;
padding: 28px;
display: grid;
gap: 22px;
}
.hero-panel::after {
content: '';
position: absolute;
inset: auto -12% -55% auto;
width: 360px;
height: 360px;
background: radial-gradient(circle, rgba(29, 78, 216, 0.12), transparent 68%);
pointer-events: none;
}
.hero-panel__copy,
.hero-panel__actions {
position: relative;
z-index: 1;
display: grid;
gap: 16px;
}
.hero-panel__status-row,
.stream-summary__row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hero-panel__metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.eyebrow {
display: inline-flex;
width: fit-content;
padding: 7px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(29, 78, 216, 0.14);
color: var(--lab-accent);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.test-lab--dark .eyebrow {
background: rgba(15, 23, 42, 0.84);
border-color: rgba(96, 165, 250, 0.22);
color: #93c5fd;
}
.hero-panel h1 {
margin: 12px 0 10px;
font-size: clamp(2.1rem, 4vw, 3.4rem);
line-height: 0.94;
}
.hero-panel p,
.panel-card__head p,
.workspace-card__head p,
.workspace-card__foot,
.sample-card span,
.control-note,
.progress-meta {
margin: 0;
color: var(--lab-muted);
font-size: 0.92rem;
line-height: 1.6;
}
.metric-card,
.sample-card,
.framework-chip,
.input-card {
border-radius: 22px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: rgba(255, 255, 255, 0.78);
}
.test-lab--dark .metric-card,
.test-lab--dark .sample-card,
.test-lab--dark .framework-chip,
.test-lab--dark .input-card {
background: rgba(15, 23, 42, 0.78);
border-color: rgba(148, 163, 184, 0.12);
}
.metric-card {
padding: 16px 18px;
}
.metric-card span {
display: block;
color: var(--lab-muted);
font-size: 0.82rem;
margin-bottom: 8px;
}
.metric-card strong {
font-size: 1.4rem;
line-break: anywhere;
}
.framework-switcher {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.framework-chip {
display: flex;
flex-direction: column;
gap: 4px;
padding: 15px 18px;
color: inherit;
text-decoration: none;
transition:
transform 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease;
}
.framework-chip:hover {
transform: translateY(-2px);
border-color: rgba(29, 78, 216, 0.28);
box-shadow: 0 16px 32px rgba(29, 78, 216, 0.1);
}
.framework-chip--current {
border-color: rgba(29, 78, 216, 0.28);
background: linear-gradient(135deg, rgba(29, 78, 216, 0.12), rgba(34, 197, 94, 0.08));
}
.test-lab--dark .framework-chip--current,
.test-lab--dark .sample-card--active {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.22), rgba(15, 23, 42, 0.92));
border-color: rgba(96, 165, 250, 0.3);
color: #bfdbfe;
}
.framework-chip__label {
font-weight: 700;
font-size: 1rem;
}
.framework-chip__note {
color: var(--lab-muted);
font-size: 0.88rem;
}
.lab-layout {
display: grid;
gap: 20px;
}
.lab-layout--share-preview {
gap: 0;
}
.panel-card {
display: grid;
gap: 14px;
padding: 20px;
}
.panel-card__head,
.workspace-card__head,
.workspace-card__foot,
.progress-meta {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.workspace-card__head {
flex-wrap: wrap;
}
.panel-card__head h2,
.workspace-card__head h2 {
margin: 0;
font-size: 1.08rem;
}
.mini-pill {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
min-height: 32px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.05);
color: var(--lab-muted);
font-size: 0.78rem;
font-weight: 700;
white-space: nowrap;
}
.mini-pill--active {
background: rgba(29, 78, 216, 0.14);
color: var(--lab-accent);
}
.test-lab--dark .mini-pill {
background: rgba(30, 41, 59, 0.9);
color: #cbd5e1;
}
.test-lab--dark .mini-pill--active {
background: rgba(37, 99, 235, 0.2);
color: #bfdbfe;
}
.sample-list,
.workspace-grid {
display: grid;
gap: 12px;
}
.sample-list {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.sample-card {
width: 100%;
padding: 14px 16px;
text-align: left;
cursor: pointer;
transition:
transform 0.18s ease,
border-color 0.18s ease,
background 0.18s ease;
}
.sample-card:hover {
transform: translateY(-2px);
}
.sample-card strong {
display: block;
margin-bottom: 6px;
font-size: 0.98rem;
}
.sample-card--active {
border-color: rgba(29, 78, 216, 0.28);
background: linear-gradient(135deg, rgba(29, 78, 216, 0.12), rgba(255, 255, 255, 0.92));
}
.stream-summary {
display: grid;
gap: 12px;
padding: 16px 18px;
border-radius: 22px;
background: rgba(248, 250, 252, 0.88);
border: 1px solid rgba(15, 23, 42, 0.06);
}
.test-lab--dark .stream-summary {
background: rgba(15, 23, 42, 0.76);
border-color: rgba(148, 163, 184, 0.12);
}
.stream-summary__row--dense {
gap: 10px;
}
.stream-summary__item {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.05);
border: 1px solid rgba(15, 23, 42, 0.06);
color: var(--lab-muted);
font-size: 0.79rem;
font-weight: 600;
white-space: nowrap;
}
.stream-summary__item--active {
background: rgba(29, 78, 216, 0.12);
border-color: rgba(29, 78, 216, 0.2);
color: var(--lab-accent);
}
.test-lab--dark .stream-summary__item {
background: rgba(30, 41, 59, 0.9);
border-color: rgba(148, 163, 184, 0.12);
}
.test-lab--dark .stream-summary__item--active {
background: rgba(37, 99, 235, 0.2);
border-color: rgba(96, 165, 250, 0.24);
color: #bfdbfe;
}
.ghost-button {
border: 0;
border-radius: 999px;
padding: 8px 12px;
background: rgba(15, 23, 42, 0.05);
color: var(--lab-muted);
font: inherit;
cursor: pointer;
}
.test-lab--dark .ghost-button,
.test-lab--dark .testlab-btn:not(.testlab-btn--primary) {
background: rgba(30, 41, 59, 0.84);
color: #cbd5e1;
}
.control-grid {
display: grid;
gap: 12px;
}
.control-grid--stream {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.input-card {
display: grid;
gap: 8px;
padding: 12px 14px;
}
.input-card span {
color: var(--lab-muted);
font-size: 0.86rem;
}
.input-card input,
.input-card select {
border: 0;
border-radius: 12px;
padding: 10px 12px;
background: rgba(15, 23, 42, 0.05);
color: var(--lab-text);
font: inherit;
}
.test-lab--dark .input-card input,
.test-lab--dark .input-card select {
background: rgba(30, 41, 59, 0.9);
color: #e2e8f0;
}
.control-note code {
padding: 0 4px;
border-radius: 6px;
background: rgba(15, 23, 42, 0.05);
}
.control-note {
margin: 0;
color: var(--lab-muted);
font-size: 0.92rem;
line-height: 1.6;
}
.button-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.testlab-btn {
border: 0;
border-radius: 16px;
padding: 12px 14px;
background: rgba(15, 23, 42, 0.06);
color: var(--lab-text);
font: inherit;
cursor: pointer;
transition:
transform 0.18s ease,
background 0.18s ease;
}
.testlab-btn:hover:not(:disabled) {
transform: translateY(-1px);
}
.testlab-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.testlab-btn--primary {
background: linear-gradient(135deg, #1d4ed8, #2563eb);
color: #fff;
}
.progress-block {
display: grid;
gap: 10px;
}
.progress-track {
width: 100%;
height: 8px;
overflow: hidden;
border-radius: 999px;
background: rgba(15, 23, 42, 0.08);
}
.test-lab--dark .progress-track {
background: rgba(51, 65, 85, 0.7);
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #6366f1);
}
.progress-meta {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin: 0;
color: var(--lab-muted);
font-size: 0.92rem;
line-height: 1.6;
}
.settings-dialog {
width: min(980px, calc(100vw - 32px));
max-width: 100%;
max-height: calc(100vh - 32px);
margin: auto;
padding: 0;
border: 0;
background: transparent;
overflow: visible;
}
.settings-dialog::backdrop {
background: rgba(15, 23, 42, 0.48);
backdrop-filter: blur(6px);
}
.settings-dialog__panel {
display: grid;
gap: 18px;
padding: 22px;
border-radius: 28px;
background: var(--lab-surface);
border: 1px solid var(--lab-border);
box-shadow: var(--lab-shadow);
backdrop-filter: blur(18px);
}
.settings-dialog__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.settings-dialog__head h2 {
margin: 0;
font-size: 1.08rem;
}
.settings-dialog__head p {
margin: 6px 0 0;
color: var(--lab-muted);
font-size: 0.92rem;
line-height: 1.5;
}
.workspace-card__head-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.workspace-grid--share-preview {
grid-template-columns: 1fr;
gap: 0;
}
.workspace-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.workspace-card {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
min-height: 760px;
overflow: hidden;
}
.workspace-card--pane {
height: var(--workspace-pane-height);
min-height: var(--workspace-pane-height);
max-height: var(--workspace-pane-height);
}
.workspace-card--preview {
position: relative;
}
.workspace-card--share-preview {
grid-template-rows: minmax(0, 1fr);
min-height: 100vh;
height: 100vh;
max-height: none;
border-radius: 0;
border: 0;
box-shadow: none;
overflow: hidden;
}
.preview-immersive-shell {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 8;
display: flex;
justify-content: center;
align-items: flex-end;
min-height: calc(96px + env(safe-area-inset-bottom, 0px));
padding: 0 16px calc(12px + env(safe-area-inset-bottom, 0px));
}
.preview-immersive-toolbar {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
max-width: 100%;
padding: 10px 12px;
border-radius: 28px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.16);
backdrop-filter: blur(18px);
opacity: 0;
transform: translateY(10px);
pointer-events: none;
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
.preview-immersive-toolbar__button {
padding-inline: 14px;
}
.workspace-card--share-preview .preview-immersive-shell:hover .preview-immersive-toolbar,
.workspace-card--share-preview .preview-immersive-shell:focus-within .preview-immersive-toolbar,
.workspace-card--preview:fullscreen .preview-immersive-shell:hover .preview-immersive-toolbar,
.workspace-card--preview:fullscreen .preview-immersive-shell:focus-within .preview-immersive-toolbar,
.workspace-card--share-preview:focus-within .preview-immersive-toolbar,
.workspace-card--preview:fullscreen:focus-within .preview-immersive-toolbar {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.workspace-card__head,
.workspace-card__foot {
padding: 18px 20px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
}
.workspace-card__foot {
border-top: 1px solid rgba(15, 23, 42, 0.06);
border-bottom: 0;
}
.editor-textarea {
width: 100%;
min-height: 560px;
padding: 22px 20px;
border: 0;
resize: none;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.92), rgba(255, 255, 255, 0.98));
color: #0f172a;
font:
500 0.95rem/1.7 "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
}
.test-lab--dark .editor-textarea {
background: linear-gradient(180deg, rgba(2, 6, 23, 0.96), rgba(15, 23, 42, 0.94));
color: #e2e8f0;
}
.editor-textarea:focus {
outline: none;
}
.preview-surface {
min-height: 560px;
padding: 22px 20px;
overflow: auto;
box-sizing: border-box;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(246, 249, 253, 0.92));
}
.test-lab--dark .preview-surface {
background: linear-gradient(180deg, rgba(2, 6, 23, 0.98), rgba(15, 23, 42, 0.96));
}
.workspace-card--pane .editor-textarea,
.workspace-card--pane .preview-surface {
min-height: 0;
height: 100%;
}
.workspace-card--share-preview .workspace-card__head,
.workspace-card--share-preview .workspace-card__foot {
padding-left: min(4vw, 32px);
padding-right: min(4vw, 32px);
}
.workspace-card--share-preview .preview-surface {
min-height: 100vh;
height: 100%;
padding: 32px min(5vw, 48px) max(156px, calc(128px + env(safe-area-inset-bottom, 0px)));
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
}
.workspace-card--preview:fullscreen {
width: 100%;
height: 100%;
max-width: none;
min-height: 100vh;
box-sizing: border-box;
border-radius: 0;
border: 0;
box-shadow: none;
overflow: hidden;
background: #fff;
}
.workspace-card--preview:fullscreen::backdrop {
background: rgba(15, 23, 42, 0.78);
}
.workspace-card--preview:fullscreen .workspace-card__head,
.workspace-card--preview:fullscreen .workspace-card__foot {
display: none;
}
.workspace-card--preview:fullscreen .preview-surface {
min-height: 100vh;
height: 100%;
padding: 40px min(6vw, 72px) max(164px, calc(132px + env(safe-area-inset-bottom, 0px)));
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
}
.test-lab--dark .preview-immersive-toolbar {
border-color: rgba(148, 163, 184, 0.16);
background: rgba(9, 18, 32, 0.86);
box-shadow: 0 18px 44px rgba(2, 6, 23, 0.42);
}
.test-lab--dark .workspace-card--preview:fullscreen {
background: #020617;
}
.preview-surface .markdown-renderer,
.preview-surface .markstream-react {
min-height: 100%;
}
@media (min-width: 1181px) {
.hero-panel {
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.95fr);
align-items: start;
}
.hero-panel__copy {
grid-column: 1;
}
.hero-panel__actions {
grid-column: 2;
}
.hero-panel__metrics,
.framework-switcher {
grid-column: 1 / -1;
}
.lab-layout {
grid-template-columns: repeat(12, minmax(0, 1fr));
}
.panel-card--samples {
grid-column: 1 / -1;
}
.panel-card--stream {
grid-column: 1 / -1;
}
.workspace-grid {
grid-column: 1 / -1;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 20px;
}
.workspace-card--editor {
grid-column: 1 / 7;
}
.workspace-card--preview {
grid-column: 7 / -1;
}
}
@media (max-width: 1180px) {
.workspace-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 820px) {
.test-lab {
--workspace-pane-height: clamp(420px, 68vh, 680px);
padding: 18px 12px 28px;
}
.hero-panel,
.panel-card,
.workspace-card {
border-radius: 22px;
}
.hero-panel {
padding: 22px 18px;
}
.settings-dialog {
width: calc(100vw - 20px);
max-height: calc(100vh - 20px);
}
.settings-dialog__panel {
padding: 18px;
border-radius: 22px;
}
.hero-panel__metrics,
.framework-switcher,
.sample-list,
.control-grid--stream,
.button-grid {
grid-template-columns: 1fr;
}
.settings-dialog__head {
flex-direction: column;
align-items: stretch;
}
.workspace-card__head-actions {
justify-content: flex-start;
}
.preview-immersive-toolbar {
width: 100%;
justify-content: stretch;
}
.preview-immersive-toolbar__button {
flex: 1 1 100%;
}
.workspace-card {
min-height: 640px;
}
.workspace-card--share-preview {
min-height: 100vh;
}
.workspace-card--share-preview .preview-surface,
.workspace-card--preview:fullscreen .preview-surface {
padding: 24px 18px max(156px, calc(128px + env(safe-area-inset-bottom, 0px)));
}
}
@@ -0,0 +1,83 @@
import type { RefObject } from 'react'
import { useCallback, useEffect, useRef } from 'react'
const STICKY_BOTTOM_THRESHOLD_PX = 24
export function useChatAutoScroll(
containerRef: RefObject<HTMLElement | null>,
contentKey: string,
) {
const frameRef = useRef<number | null>(null)
const stickToBottomRef = useRef(true)
const syncStickToBottom = useCallback(() => {
const container = containerRef.current
if (!container)
return
const distanceFromBottom = container.scrollHeight - container.clientHeight - container.scrollTop
stickToBottomRef.current = distanceFromBottom <= STICKY_BOTTOM_THRESHOLD_PX
}, [containerRef])
const scheduleScrollToBottom = useCallback(() => {
if (typeof window === 'undefined')
return
if (frameRef.current != null)
return
frameRef.current = window.requestAnimationFrame(() => {
frameRef.current = null
const container = containerRef.current
if (!container)
return
container.scrollTop = container.scrollHeight
syncStickToBottom()
})
}, [containerRef, syncStickToBottom])
useEffect(() => {
const container = containerRef.current
if (!container)
return
syncStickToBottom()
const handleScroll = () => {
syncStickToBottom()
}
container.addEventListener('scroll', handleScroll, { passive: true })
let resizeObserver: ResizeObserver | null = null
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
if (stickToBottomRef.current)
scheduleScrollToBottom()
})
resizeObserver.observe(container)
const shell = container.querySelector('.chatbot-renderer-shell') as HTMLElement | null
if (shell)
resizeObserver.observe(shell)
}
if (stickToBottomRef.current)
scheduleScrollToBottom()
return () => {
container.removeEventListener('scroll', handleScroll)
resizeObserver?.disconnect()
}
}, [containerRef, scheduleScrollToBottom, syncStickToBottom])
useEffect(() => {
if (!stickToBottomRef.current)
return
scheduleScrollToBottom()
}, [contentKey, scheduleScrollToBottom])
useEffect(() => {
return () => {
if (frameRef.current != null)
window.cancelAnimationFrame(frameRef.current)
}
}, [])
}
@@ -0,0 +1,602 @@
import { useCallback, useEffect, useRef, useState } from 'react'
export interface ChunkInfo {
content: string
delay: number
index: number
}
export interface LocalStreamControl {
shouldPause?: () => boolean
signal?: AbortSignal
}
export type StreamSliceMode = 'pure-random' | 'boundary-aware'
export type StreamTransportMode = 'scheduler' | 'readable-stream'
export interface StreamSimulatorOptions {
source: string
chunkSizeMin: number
chunkSizeMax: number
chunkDelayMin: number
chunkDelayMax: number
burstiness: number
sliceMode?: StreamSliceMode
transportMode?: StreamTransportMode
random?: () => number
}
export interface CreateLocalTextStreamOptions {
chunkSizeMin: number
chunkSizeMax: number
chunkDelayMin: number
chunkDelayMax: number
onChunk?: (chunk: ChunkInfo) => void
control?: LocalStreamControl
random?: () => number
sliceMode?: StreamSliceMode
}
const WORD_BOUNDARY = /[\s,.;:!?()[\]{}"'`<>/\-\\]/
interface BurstState {
quickChunksRemaining: number
fastCadenceChunkPending: boolean
}
export function clampStreamControl(value: number, min: number, max: number, fallback: number) {
const normalized = Number.isFinite(value) ? value : fallback
return Math.min(max, Math.max(min, normalized))
}
export function normalizeStreamRange(
minValue: number,
maxValue: number,
lowerBound: number,
upperBound: number,
fallbackMin: number,
fallbackMax: number,
) {
const normalizedMin = Math.round(clampStreamControl(minValue, lowerBound, upperBound, fallbackMin))
const normalizedMax = Math.round(clampStreamControl(maxValue, lowerBound, upperBound, fallbackMax))
if (normalizedMin <= normalizedMax)
return { min: normalizedMin, max: normalizedMax }
return { min: normalizedMax, max: normalizedMin }
}
function normalizeRatio(value: number, fallback: number) {
return clampStreamControl(value, 0, 1, fallback)
}
function randomBetween(min: number, max: number, random: () => number) {
return min + ((max - min) * random())
}
function randomInt(min: number, max: number, random: () => number) {
return Math.floor(randomBetween(min, max + 1, random))
}
function isBoundaryChar(char: string | undefined) {
return char ? WORD_BOUNDARY.test(char) : false
}
function snapChunkToBoundary(source: string, start: number, desiredChunk: number) {
const preferredEnd = Math.min(source.length, start + desiredChunk)
if (preferredEnd >= source.length)
return source.length - start
if (isBoundaryChar(source[preferredEnd - 1]) || isBoundaryChar(source[preferredEnd]))
return preferredEnd - start
const lookaheadEnd = Math.min(source.length, preferredEnd + 12)
for (let index = preferredEnd; index < lookaheadEnd; index++) {
if (isBoundaryChar(source[index]))
return (index + 1) - start
}
return preferredEnd - start
}
function sampleChunkSizeFromRange(
source: string,
start: number,
chunkSizeMin: number,
chunkSizeMax: number,
burstState: BurstState,
sliceMode: StreamSliceMode,
random: () => number,
) {
const remaining = source.length - start
if (remaining <= 0)
return 0
const range = Math.max(0, chunkSizeMax - chunkSizeMin)
if (sliceMode === 'pure-random') {
const chunkSize = range === 0
? chunkSizeMin
: randomInt(chunkSizeMin, chunkSizeMax, random)
return Math.max(1, Math.min(remaining, chunkSize))
}
if (range === 0)
return Math.min(remaining, chunkSizeMin)
const useBurstChunkBias = burstState.fastCadenceChunkPending || burstState.quickChunksRemaining > 0
const useLowWindow = !useBurstChunkBias && random() < 0.22
let chunkSize = chunkSizeMax
if (useBurstChunkBias) {
const burstMin = Math.max(chunkSizeMin, chunkSizeMax - Math.max(1, Math.floor(range * 0.35)))
chunkSize = randomInt(burstMin, chunkSizeMax, random)
}
else if (useLowWindow) {
const lowMax = Math.min(chunkSizeMax, chunkSizeMin + Math.max(1, Math.floor(range * 0.4)))
chunkSize = randomInt(chunkSizeMin, lowMax, random)
}
else {
chunkSize = randomInt(chunkSizeMin, chunkSizeMax, random)
}
chunkSize = Math.min(remaining, chunkSize)
if (remaining > 6 && random() < 0.72)
chunkSize = Math.min(remaining, snapChunkToBoundary(source, start, chunkSize))
return Math.max(1, chunkSize)
}
function sampleDelayMsFromRange(
chunkDelayMin: number,
chunkDelayMax: number,
burstiness: number,
burstState: BurstState,
isFirstChunk: boolean,
pureRandom: boolean,
random: () => number,
) {
const range = Math.max(0, chunkDelayMax - chunkDelayMin)
if (range === 0)
return chunkDelayMin
if (pureRandom)
return randomInt(chunkDelayMin, chunkDelayMax, random)
const fastCadence = burstState.quickChunksRemaining > 0
if (fastCadence) {
const quickMax = Math.min(chunkDelayMax, chunkDelayMin + Math.max(1, Math.floor(range * 0.3)))
burstState.fastCadenceChunkPending = true
return randomInt(chunkDelayMin, quickMax, random)
}
const shouldStall = burstiness > 0.05 && random() < (burstiness * 0.24)
const shouldBurst = burstiness > 0.08 && random() < (burstiness * 0.18)
let delayMs: number
if (isFirstChunk) {
const warmupMin = Math.min(chunkDelayMax, chunkDelayMin + Math.max(1, Math.floor(range * 0.45)))
delayMs = randomInt(warmupMin, chunkDelayMax, random)
}
else if (shouldStall) {
const stallMin = Math.max(chunkDelayMin, chunkDelayMax - Math.max(1, Math.floor(range * 0.35)))
delayMs = randomInt(stallMin, chunkDelayMax, random)
burstState.quickChunksRemaining = randomInt(1, 2 + Math.round(burstiness * 4), random)
}
else {
delayMs = randomInt(chunkDelayMin, chunkDelayMax, random)
if (shouldBurst)
burstState.quickChunksRemaining = randomInt(1, 2 + Math.round(burstiness * 4), random)
}
return delayMs
}
export function createLocalTextStream(content: string, options: CreateLocalTextStreamOptions) {
const encoder = new TextEncoder()
const random = options.random ?? Math.random
const control = options.control ?? {}
let currentPosition = 0
let chunkIndex = 0
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
const waitIfPaused = async () => {
while (control.shouldPause?.()) {
if (control.signal?.aborted)
return
await sleep(50)
}
}
return new ReadableStream<Uint8Array>({
async start(controller) {
while (currentPosition < content.length) {
if (control.signal?.aborted)
break
await waitIfPaused()
if (control.signal?.aborted)
break
const rawChunkSize = randomInt(options.chunkSizeMin, options.chunkSizeMax, random)
const chunkSize = options.sliceMode === 'boundary-aware'
? snapChunkToBoundary(content, currentPosition, rawChunkSize)
: rawChunkSize
const delay = randomInt(options.chunkDelayMin, options.chunkDelayMax, random)
const chunkContent = content.slice(currentPosition, currentPosition + chunkSize)
await sleep(delay)
if (control.signal?.aborted)
break
await waitIfPaused()
if (control.signal?.aborted)
break
controller.enqueue(encoder.encode(chunkContent))
options.onChunk?.({
index: chunkIndex++,
content: chunkContent,
delay,
})
currentPosition += chunkSize
}
try {
controller.close()
}
catch {
// The consumer may have already canceled the stream.
}
},
})
}
export function useStreamSimulator(options: StreamSimulatorOptions) {
const [contentState, setContentState] = useState('')
const [chunksState, setChunksState] = useState<ChunkInfo[]>([])
const [isStreamingState, setIsStreamingState] = useState(false)
const [isPausedState, setIsPausedState] = useState(false)
const [lastDelayMsState, setLastDelayMsState] = useState(0)
const [lastChunkSizeState, setLastChunkSizeState] = useState(0)
const [chunkCountState, setChunkCountState] = useState(0)
const contentRef = useRef(contentState)
const chunksRef = useRef(chunksState)
const isStreamingRef = useRef(isStreamingState)
const isPausedRef = useRef(isPausedState)
const lastDelayMsRef = useRef(lastDelayMsState)
const lastChunkSizeRef = useRef(lastChunkSizeState)
const chunkCountRef = useRef(chunkCountState)
const optionsRef = useRef(options)
const burstStateRef = useRef<BurstState>({
quickChunksRemaining: 0,
fastCadenceChunkPending: false,
})
const timerRef = useRef<number | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
useEffect(() => {
optionsRef.current = options
}, [options])
const setContent = useCallback((value: string) => {
contentRef.current = value
setContentState(value)
}, [])
const setChunks = useCallback((value: ChunkInfo[]) => {
chunksRef.current = value
setChunksState(value)
}, [])
const setIsStreaming = useCallback((value: boolean) => {
isStreamingRef.current = value
setIsStreamingState(value)
}, [])
const setIsPaused = useCallback((value: boolean) => {
isPausedRef.current = value
setIsPausedState(value)
}, [])
const setLastDelayMs = useCallback((value: number) => {
lastDelayMsRef.current = value
setLastDelayMsState(value)
}, [])
const setLastChunkSize = useCallback((value: number) => {
lastChunkSizeRef.current = value
setLastChunkSizeState(value)
}, [])
const setChunkCount = useCallback((value: number) => {
chunkCountRef.current = value
setChunkCountState(value)
}, [])
const clearTimer = useCallback(() => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
const stop = useCallback(() => {
clearTimer()
abortControllerRef.current?.abort()
abortControllerRef.current = null
setIsStreaming(false)
setIsPaused(false)
}, [clearTimer, setIsPaused, setIsStreaming])
const scheduleNext = useCallback((isFirstChunk = false) => {
const source = optionsRef.current.source || ''
if (!isStreamingRef.current || isPausedRef.current)
return
if (contentRef.current.length >= source.length) {
stop()
return
}
const burstiness = normalizeRatio(Number(optionsRef.current.burstiness), 0.35)
const pureRandom = (optionsRef.current.sliceMode ?? 'boundary-aware') === 'pure-random'
const { min, max } = normalizeStreamRange(
Number(optionsRef.current.chunkDelayMin),
Number(optionsRef.current.chunkDelayMax),
8,
1800,
16,
40,
)
const delayMs = sampleDelayMsFromRange(
min,
max,
burstiness,
burstStateRef.current,
isFirstChunk,
pureRandom,
optionsRef.current.random ?? Math.random,
)
setLastDelayMs(delayMs)
timerRef.current = window.setTimeout(() => {
timerRef.current = null
const sourceValue = optionsRef.current.source || ''
if (!isStreamingRef.current || isPausedRef.current)
return
if (!sourceValue.length) {
stop()
return
}
const start = contentRef.current.length
if (start >= sourceValue.length) {
stop()
return
}
const { min: chunkSizeMin, max: chunkSizeMax } = normalizeStreamRange(
Number(optionsRef.current.chunkSizeMin),
Number(optionsRef.current.chunkSizeMax),
1,
160,
2,
6,
)
const nextChunkSize = sampleChunkSizeFromRange(
sourceValue,
start,
chunkSizeMin,
chunkSizeMax,
burstStateRef.current,
optionsRef.current.sliceMode ?? 'boundary-aware',
optionsRef.current.random ?? Math.random,
)
const nextChunk = sourceValue.slice(start, start + nextChunkSize)
const nextCount = chunkCountRef.current + 1
setLastChunkSize(nextChunkSize)
setChunkCount(nextCount)
setContent(sourceValue.slice(0, start + nextChunkSize))
setChunks([
...chunksRef.current,
{
index: nextCount - 1,
content: nextChunk,
delay: lastDelayMsRef.current,
},
])
if (burstStateRef.current.fastCadenceChunkPending) {
burstStateRef.current.fastCadenceChunkPending = false
burstStateRef.current.quickChunksRemaining = Math.max(0, burstStateRef.current.quickChunksRemaining - 1)
}
if (contentRef.current.length >= sourceValue.length) {
stop()
return
}
scheduleNext()
}, delayMs)
}, [setChunkCount, setChunks, setContent, setLastChunkSize, setLastDelayMs, stop])
const startReadableStream = useCallback(async (reset = true) => {
clearTimer()
abortControllerRef.current?.abort()
const controller = new AbortController()
abortControllerRef.current = controller
burstStateRef.current.quickChunksRemaining = 0
burstStateRef.current.fastCadenceChunkPending = false
if (reset)
setContent('')
setChunks([])
setLastChunkSize(0)
setLastDelayMs(0)
setChunkCount(0)
setIsPaused(false)
const source = optionsRef.current.source || ''
if (!source.length) {
abortControllerRef.current = null
setIsStreaming(false)
return
}
const { min: chunkSizeMin, max: chunkSizeMax } = normalizeStreamRange(
Number(optionsRef.current.chunkSizeMin),
Number(optionsRef.current.chunkSizeMax),
1,
160,
2,
6,
)
const { min: chunkDelayMin, max: chunkDelayMax } = normalizeStreamRange(
Number(optionsRef.current.chunkDelayMin),
Number(optionsRef.current.chunkDelayMax),
8,
1800,
16,
40,
)
setIsStreaming(true)
try {
const stream = createLocalTextStream(source, {
chunkDelayMax,
chunkDelayMin,
chunkSizeMax,
chunkSizeMin,
random: optionsRef.current.random,
sliceMode: optionsRef.current.sliceMode ?? 'boundary-aware',
control: {
shouldPause: () => isPausedRef.current,
signal: controller.signal,
},
onChunk: (chunk) => {
const nextCount = chunkCountRef.current + 1
setLastDelayMs(chunk.delay)
setLastChunkSize(chunk.content.length)
setChunkCount(nextCount)
setChunks([...chunksRef.current, chunk])
},
})
const reader = stream.getReader()
const decoder = new TextDecoder()
let accumulated = reset ? '' : contentRef.current
while (!controller.signal.aborted) {
const { done, value } = await reader.read()
if (done)
break
accumulated += decoder.decode(value, { stream: true })
setContent(accumulated)
}
}
finally {
if (abortControllerRef.current === controller)
abortControllerRef.current = null
if (abortControllerRef.current === null || abortControllerRef.current === controller) {
setIsStreaming(false)
setIsPaused(false)
}
}
}, [clearTimer, setChunkCount, setChunks, setContent, setIsPaused, setIsStreaming, setLastChunkSize, setLastDelayMs])
const start = useCallback((reset = true) => {
const source = optionsRef.current.source || ''
if (!source.length) {
setLastChunkSize(0)
setLastDelayMs(0)
setIsStreaming(false)
setIsPaused(false)
return
}
const transportMode = optionsRef.current.transportMode ?? 'scheduler'
if (transportMode === 'readable-stream') {
void startReadableStream(reset)
return
}
clearTimer()
abortControllerRef.current?.abort()
abortControllerRef.current = null
burstStateRef.current.quickChunksRemaining = 0
burstStateRef.current.fastCadenceChunkPending = false
if (reset)
setContent('')
setChunks([])
setLastChunkSize(0)
setLastDelayMs(0)
setChunkCount(0)
setIsStreaming(true)
setIsPaused(false)
scheduleNext(true)
}, [clearTimer, scheduleNext, setChunkCount, setChunks, setContent, setIsPaused, setIsStreaming, setLastChunkSize, setLastDelayMs, startReadableStream])
const pause = useCallback(() => {
if (!isStreamingRef.current)
return
setIsPaused(true)
clearTimer()
}, [clearTimer, setIsPaused])
const resume = useCallback(() => {
if (!isStreamingRef.current || !isPausedRef.current)
return
setIsPaused(false)
if ((optionsRef.current.transportMode ?? 'scheduler') === 'scheduler')
scheduleNext()
}, [scheduleNext, setIsPaused])
const togglePause = useCallback(() => {
if (isPausedRef.current)
resume()
else
pause()
}, [pause, resume])
const reset = useCallback(() => {
stop()
setContent('')
setChunks([])
setChunkCount(0)
setLastChunkSize(0)
setLastDelayMs(0)
burstStateRef.current.quickChunksRemaining = 0
burstStateRef.current.fastCadenceChunkPending = false
}, [setChunkCount, setChunks, setContent, setLastChunkSize, setLastDelayMs, stop])
useEffect(() => () => {
stop()
}, [stop])
return {
chunkCount: chunkCountState,
chunks: chunksState,
content: contentState,
isPaused: isPausedState,
isStreaming: isStreamingState,
lastChunkSize: lastChunkSizeState,
lastDelayMs: lastDelayMsState,
pause,
reset,
resume,
start,
stop,
togglePause,
}
}
@@ -0,0 +1,13 @@
import baseConfig from '../tailwind.config.js'
const baseContent = Array.isArray(baseConfig.content) ? baseConfig.content : []
export default {
...baseConfig,
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
'../packages/markstream-react/src/**/*.{js,ts,jsx,tsx,css}',
...baseContent,
],
}
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"types": ["vite/client"],
"allowJs": false
},
"include": ["src"],
"exclude": ["src/**/*.test.*", "dist", "node_modules"]
}
@@ -0,0 +1,11 @@
{
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"files": []
}
@@ -0,0 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1,44 @@
import type { PluginOption } from 'vite'
import path from 'node:path'
import react from '@vitejs/plugin-react-swc'
import { defineConfig } from 'vite'
import monacoEditorPlugin from 'vite-plugin-monaco-editor-esm'
export default defineConfig(({ mode }) => ({
base: './',
server: {
port: 4174,
fs: {
allow: [path.resolve(__dirname, '..')],
},
},
worker: {
format: 'es',
},
optimizeDeps: {
exclude: ['stream-monaco'],
},
// Only use alias in dev mode - for production build, use the built package
resolve: mode === 'development'
? {
alias: {
'markstream-react': path.resolve(__dirname, '../packages/markstream-react/src'),
},
}
: undefined,
plugins: [
react(),
monacoEditorPlugin({
languageWorkers: [
'editorWorkerService',
'typescript',
'css',
'html',
'json',
],
customDistPath(_root, buildOutDir) {
return path.resolve(buildOutDir, 'monacoeditorwork')
},
}) as unknown as PluginOption,
],
}))