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,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>
|
||||
)
|
||||
}
|
||||
+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。
|
||||
`
|
||||
@@ -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,
|
||||
],
|
||||
}))
|
||||
Reference in New Issue
Block a user