forked from boos/BoosAPI
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,2 @@
|
||||
export default function App(): import("react/jsx-runtime").JSX.Element;
|
||||
//# sourceMappingURL=App.d.ts.map
|
||||
@@ -0,0 +1,734 @@
|
||||
import type { StreamPresetId } from '../../playground-react18/src/shared/streamPresets'
|
||||
import type { StreamSliceMode, StreamTransportMode } from '../../playground-react18/src/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 { PLAYGROUND_CUSTOM_HTML_TAGS, PLAYGROUND_CUSTOM_ID } from '../../playground-react18/src/shared/markstreamPlayground'
|
||||
import { CUSTOM_STREAM_PRESET_ID, findMatchingStreamPreset, getStreamPreset, STREAM_PRESETS } from '../../playground-react18/src/shared/streamPresets'
|
||||
import { TestLab } from '../../playground-react18/src/shared/TestLab'
|
||||
import { useChatAutoScroll } from '../../playground-react18/src/shared/useChatAutoScroll'
|
||||
import { clampStreamControl, normalizeStreamRange, useStreamSimulator } from '../../playground-react18/src/shared/useStreamSimulator'
|
||||
import { ThinkingNode } from './components/ThinkingNode'
|
||||
import { streamContent } from './markdown'
|
||||
|
||||
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'
|
||||
|
||||
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)
|
||||
return
|
||||
startStreamSimulation()
|
||||
return () => {
|
||||
stopStreamSimulation()
|
||||
}
|
||||
}, [isTestPage, startStreamSimulation, stopStreamSimulation])
|
||||
|
||||
const goToTest = () => {
|
||||
navigate('/test')
|
||||
}
|
||||
|
||||
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 19" onGoHome={() => navigate('/')} />
|
||||
|
||||
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-react19
|
||||
</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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import type { NodeComponentProps } from 'markstream-react';
|
||||
interface ThinkingNodeData {
|
||||
node: {
|
||||
type: 'thinking';
|
||||
content: string;
|
||||
loading?: boolean;
|
||||
attrs?: Array<{
|
||||
name: string;
|
||||
value: string | boolean;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
export declare function ThinkingNode(props: NodeComponentProps<ThinkingNodeData['node']>): import("react/jsx-runtime").JSX.Element;
|
||||
export default ThinkingNode;
|
||||
//# sourceMappingURL=ThinkingNode.d.ts.map
|
||||
@@ -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 '../../../playground-react18/src/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,4 @@
|
||||
import 'katex/dist/katex.min.css';
|
||||
import 'markstream-react/index.css';
|
||||
import './index.css';
|
||||
//# sourceMappingURL=main.d.ts.map
|
||||
@@ -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 'markstream-react/index.css'
|
||||
import 'monaco-editor/min/vs/editor/editor.main.css'
|
||||
import '../../playground-react18/src/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。
|
||||
`
|
||||
Reference in New Issue
Block a user