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:
@@ -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
|
||||
|
||||

|
||||
*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,
|
||||
],
|
||||
}))
|
||||
Reference in New Issue
Block a user