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

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

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

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.next
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
import reactDom from 'eslint-plugin-react-dom'
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="markstream-react React 19 playground for streaming Markdown, Mermaid, KaTeX, and large-document rendering."
/>
<title>markstream-react Playground | React 19 demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,35 @@
{
"name": "playground-react19",
"type": "module",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc -b --noEmit && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@iconify/react": "^4.1.1",
"markstream-react": "workspace:*",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^3.11.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^17.4.0",
"monaco-editor": "^0.52.2",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"typescript-eslint": "^8.58.0",
"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
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -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>
)
}
@@ -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
![Vue Markdown Icon](/vue-markdown-icon.svg "Vue Markdown Icon")
*Figure: Vue Markdown Icon (served from /vue-markdown-icon.svg)*
这是 ~~已删除的文本~~,这是一个表情 :smile:。
- [ ] Star this repo
- [x] Fork this repo
- [ ] Create issues
- [x] Submit PRs
## 表格
| 姓名 | 年龄 | 职业 |
|------|------|------|
| 张三 | 25 | 工程师 |
| 李四 | 30 | 设计师 |
| 王五 | 28 | 产品经理 |
### 对齐表格
| 左对齐 | 居中对齐 | 右对齐 |
|:-------|:--------:|-------:|
| 内容1 | 内容2 | 内容3 |
| 内容4 | 内容5 | 内容6 |
我将为您输出泰勒公式的一般形式及其常见展开式。
---
## 0. 薛定谔方程(量子力学)
$$i\\hbar \\frac{\\partial}{\\partial t} \\Psi(\\mathbf{r},t) = \\left[ -\\frac{\\hbar^2}{2m} \\nabla^2 + V(\\mathbf{r},t) \\right] \\Psi(\\mathbf{r},t)$$
## 1. 泰勒公式(Taylor's Formula)
### 一般形式(在点 \\(x = a\\) 处展开):
\[
f(x) = f(a) + f'(a)(x-a) + \frac{f''(a)}{2!}(x-a)^2 + \frac{f'''(a)}{3!}(x-a)^3 + \cdots + \frac{f^{(n)}(a)}{n!}(x-a)^n + R_n(x)
\\]
其中:
- \\(f^{(k)}(a)\\) 是 \\(f(x)\\) 在 \\(x=a\\\) 处的 \\(k\\) 阶导数
- \\(R_n(x)\\) 是余项,常见形式有拉格朗日余项:
\[
R_n(x) = \frac{f^{(n+1)}(xi)}{(n+1)!}(x-a)^{n+1}, \quad xi \text{ 在 } a \text{ 和 } x \text{ 之间}
\\]
---
## 2. 麦克劳林公式(Maclaurin's Formula,即 \\(a=0\\) 时的泰勒公式):
\[
f(x) = f(0) + f'(0)x + \frac{f''(0)}{2!}x^2 + \frac{f'''(0)}{3!}x^3 + \cdots + \frac{f^{(n)}(0)}{n!}x^n + R_n(x)
\\]
---
## 3. 常见函数的麦克劳林展开(前几项)
- **指数函数**:
\\[
e^x = 1 + x + \frac{x^2}{2!} + \frac{x^3}{3!} + \cdots + \frac{x^n}{n!} + \cdots, \quad x \in \mathbb{R}
\\]
- **正弦函数**:
\\[
\sin x = x - \frac{x^3}{3!} + \frac{x^5}{5!} - \frac{x^7}{7!} + \cdots + (-1)^n \frac{x^{2n+1}}{(2n+1)!} + \cdots
\\]
- **余弦函数**:
\\[
\cos x = 1 - \frac{x^2}{2!} + \frac{x^4}{4!} - \frac{x^6}{6!} + \cdots + (-1)^n \frac{x^{2n}}{(2n)!} + \cdots
\\]
- **自然对数**(在 \\(x=0\\) 附近):
\\[
\ln(1+x) = x - \frac{x^2}{2} + \frac{x^3}{3} - \frac{x^4}{4} + \cdots + (-1)^{n-1} \frac{x^n}{n} + \cdots, \quad -1 < x \le 1
\\]
- **二项式展开**(\\( (1+x)^m \\),\\(m\\) 为实数):
\[
(1+x)^m = 1 + mx + \frac{m(m-1)}{2!}x^2 + \frac{m(m-1)(m-2)}{3!}x^3 + \cdots, \quad |x| < 1
\\]
- **矩阵**:
\\[
\begin{bmatrix}
2x_2 - 8x_3 = 8 \\
5x_1 - 5x_3 = 10
\end{bmatrix}
\\]
- **公式**
- **代入数据**
\\[
\frac{363}{15,\!135} \times 100\% = 2.398\%
\\]
- **计算工具验证**
通过数学计算工具确认结果:
\`363 ÷ 15,135 × 100 = 2.39841427...\`
- **差异说明**
$$E=mc^2$$
---
如果您需要某个特定函数在特定点的泰勒展开,请告诉我,我可以为您详细写出。
::: warning
这是一个警告块。
:::
::: tip 提示标题
这是带标题的提示。
:::
::: error 错误块
这是一个错误块。
:::
مرحبا بكم في عالم اللغة العربية!
1. First, let's set up the project:
\`\`\`shellscript
# Create Vue project
npm create vue@latest electron-vue-chat
# Navigate to project
cd electron-vue-chat
# Install dependencies
npm install
npm install electron electron-builder vue-router
# Install dev dependencies
npm install -D electron-dev-server concurrently wait-on
\`\`\`
2. Create the main Electron file:
\`\`\`javascript:electron/main.js
const { app, BrowserWindow } = require('electron');
const path = require('path');
const isDev = process.env.NODE_ENV === 'development';
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 900,
height: 680,
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
});
const url = isDev
? 'http://localhost:5173'
: \`file://\${path.join(__dirname, '../dist/index.html')}\`;
mainWindow.loadURL(url);
if (isDev) {
mainWindow.webContents.openDevTools();
}
mainWindow.on('closed', () => {
mainWindow = null;
});
}
app.on('ready', createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (mainWindow === null) {
createWindow();
}
});
\`\`\`
3. Update package.json:
\`\`\`diff json:package.json
{
"name": "markstream-vue",
"type": "module",
- "version": "0.0.49",
+ "version": "0.0.54-beta.1",
"packageManager": "pnpm@10.16.1",
"description": "A Vue 3 component that renders Markdown string content as HTML, supporting custom components and advanced markdown features.",
"author": "Simon He",
"license": "MIT",
"repository": {
"type": "git",
"url": "git + git@github.com:Simon-He95/markstream-vue.git"
},
"bugs": {
"url": "https://github.com/Simon-He95/markstream-vue/issues"
},
"keywords": [
"vue",
"vue3",
"markdown",
"markdown-to-html",
"markdown-renderer",
"vue-markdown",
"vue-component",
"html",
"renderer",
"custom-component"
],
"exports": {
".": {
"types": "./dist/types/exports.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./index.css": "./dist/index.css",
"./index.tailwind.css": "./dist/index.tailwind.css",
"./tailwind": "./dist/tailwind.ts"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/types/exports.d.ts",
"files": [
"dist"
],
}
\`\`\`
4. Create chat components \\(diversified languages\\):
\`\`\`python:src/server/app.py
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Message(BaseModel):
sender: str
text: str
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/echo")
def echo(msg: Message):
return {"reply": f"Echo: {msg.text}"}
\`\`\`
5. Create a native module example \(C++\):
\`\`\`cpp:src/native/compute.cpp
#include <bits/stdc++.h>
using namespace std;
int fibonacci(int n){
if(n<=1) return n;
int a=0,b=1;
for(int i=2;i<=n;++i){ int c=a+b; a=b; b=c; }
return b;
}
int main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout << "fib(10)=" << fibonacci(10) << "\n";
return 0;
}
\`\`\`
6. Update the main App.vue:
\`\`\`vue:src/App.vue
<template>
<router-view />
<\/template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
}
</style>
\`\`\`
7. Set up the router:
\`\`\`javascript:src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import ChatView from '../views/ChatView.vue';
const routes = [
{
path: '/',
name: 'chat',
component: ChatView
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
export default router;
\`\`\`
8. Update main.js:
\`\`\`javascript:src/main.js
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
createApp(App).use(router).mount('#app');
\`\`\`
9. Mermaid graphic:
\`\`\`mermaid
graph TD
Kira_Yamato[基拉·大和]
Lacus_Clyne[拉克丝·克莱因]
Athrun_Zala[阿斯兰·萨拉]
Cagalli_Yula_Athha[卡嘉莉·尤拉·阿斯哈]
Shinn_Asuka[真·飞鸟]
Lunamaria_Hawke[露娜玛丽亚·霍克]
COMPASS[世界和平监视组织COMPASS]
Foundation[芬德申王国]
Orphee_Lam_Tao[奥尔菲·拉姆·陶]
%% 节点定义结束,开始定义边
Kira_Yamato ---|恋人| Lacus_Clyne
Kira_Yamato ---|挚友| Athrun_Zala
Kira_Yamato -->|隶属| COMPASS
Kira_Yamato -->|前辈| Shinn_Asuka
Lacus_Clyne -->|初代总裁| COMPASS
Athrun_Zala ---|恋人| Cagalli_Yula_Athha
Athrun_Zala -.->|协力| COMPASS
Shinn_Asuka ---|恋人| Lunamaria_Hawke
Shinn_Asuka -->|隶属| COMPASS
Lunamaria_Hawke -->|隶属| COMPASS
COMPASS -->|对立| Foundation
Orphee_Lam_Tao -->|隶属| Foundation
Orphee_Lam_Tao -.->|追求| Lacus_Clyne
\`\`\`
\`\`\`mermaid
xychart
title "销售收入"
x-axis ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"]
y-axis "收入" 4000 --> 11000
line [5000, 6000, 7500, 8200, 9500, 10500, 11000, 10200, 9200, 8500, 7000, 6000]
\`\`\`
\`\`\`infographic
infographic list-row-simple-horizontal-arrow
data
items
- label 步骤 1
desc 开始
- label 步骤 2
desc 进行中
- label 步骤 3
desc 完成
\`\`\`
---
# 复杂数学公式
### 1. **理解 \\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 的含义**
- \\(\boldsymbol{\alpha}\\) 和 \\(\boldsymbol{\beta}\\) 是三维列向量,因此 \\(\boldsymbol{\alpha}^T \boldsymbol{\beta}\\) 表示它们的点积(内积)。
- \\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 意味着向量 \\(\boldsymbol{\alpha}\\) 和 \\(\boldsymbol{\beta}\\) 正交(即垂直),因为点积为零表示它们之间的夹角为 90 度。
### 2. **正交补空间的概念**
- 在线性代数中,对于一个子空间 \\(W\\),它的正交补空间(记为 \\(W^\perp\\))定义为所有与 \\(W\\) 中每个向量正交的向量的集合。即:
\[
W^\perp = \{ \mathbf{v} \in \mathbb{R}^3 \mid \mathbf{v} \cdot \mathbf{w} = 0 \text{ 对于所有 } \mathbf{w} \in W \}
\]
- 例如,如果 \\(W\\) 是由一个向量 \\(\boldsymbol{\alpha}\\) 张成的一维子空间(即 \\(W = \operatorname{span}\{\boldsymbol{\alpha}\}\\)),那么 \\(W^\perp\\) 就是所有与 \\(\boldsymbol{\alpha}\\) 正交的向量构成的二维平面。
### 3. **\\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 与正交补空间的联系**
- 当 \\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 时,这意味着:
- \\(\boldsymbol{\beta}\\) 属于 \\(\operatorname{span}\{\boldsymbol{\alpha}\}\\) 的正交补空间,即 \\(\boldsymbol{\beta} \in (\operatorname{span}\{\boldsymbol{\alpha}\})^\perp\\)。
- 同样,\\(\boldsymbol{\alpha}\\) 也属于 \\(\operatorname{span}\{\boldsymbol{\beta}\}\\) 的正交补空间,即 \\(\boldsymbol{\alpha} \in (\operatorname{span}\{\boldsymbol{\beta}\})^\perp\\)。
- 换句话说,\\(\boldsymbol{\beta}\\) 与 \\(\boldsymbol{\alpha}\\) 张成的直线正交,因此 \\(\boldsymbol{\beta}\\) 位于该直线的垂直平面(即正交补空间)上。反之亦然。
### 4. **在三维空间中的几何意义**
- 在三维空间中,如果 \\(\boldsymbol{\alpha}\\) 是一个非零向量,那么 \\(\operatorname{span}\{\boldsymbol{\alpha}\}\\) 是一条通过原点的直线,而它的正交补空间 \\((\operatorname{span}\{\boldsymbol{\alpha}\})^\perp\\) 是一个通过原点且与该直线垂直的平面。
- \\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 表示 \\(\boldsymbol{\beta}\\) 位于这个垂直平面上。同样,如果 \\(\boldsymbol{\beta}\\) 非零,那么 \\(\boldsymbol{\alpha}\\) 也位于与 \\(\boldsymbol{\beta}\\) 垂直的平面上。
### 5. **推广到更一般的情况**
- 如果考虑多个向量,正交补空间的概念可以扩展。例如,如果有一组向量 \\(\{\boldsymbol{\alpha}_1, \boldsymbol{\alpha}_2, \ldots, \boldsymbol{\alpha}_k\}\\),那么它们的张成子空间 \\(W = \operatorname{span}\{\boldsymbol{\alpha}_1, \ldots, \boldsymbol{\alpha}_k\}\\) 的正交补空间 \\(W^\perp\\) 包含所有与这些向量正交的向量。
- 在这种情况下,\\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 可以看作 \\(\boldsymbol{\beta}\\) 与 \\(W\\) 正交的一个特例(当 \\(W\\) 只由 \\(\boldsymbol{\alpha}\\) 张成时)。
总之,\\(\boldsymbol{\alpha}^T \boldsymbol{\beta} = 0\\) 直接体现了正交补空间的关系:它表明一个向量属于另一个向量张成子空间的正交补空间。如果你有更多向量或子空间,这种联系可以进一步深化。
**示例:** emm\`1-(5)\`、\`3-(3)\`、\`3-(4)\` complex test \`1-(4)\`“heiheihei”中,hello world。
`
@@ -0,0 +1,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", "../playground-react18/src/shared/**/*"],
"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,40 @@
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,
},
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')
},
}),
],
}))
@@ -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,
],
}))